From c1c9a0bf83ecef671356d554bb6e4927392b1cc8 Mon Sep 17 00:00:00 2001 From: Soren Stoutner Date: Tue, 25 Jun 2019 17:17:48 -0700 Subject: [PATCH] Implement saving a webpage as an image. https://redmine.stoutner.com/issues/187 --- .../activities/ImportExportActivity.java | 58 +--- .../activities/LogcatActivity.java | 73 +---- .../activities/MainWebViewActivity.java | 277 +++++++++++++++--- .../asynctasks/PopulateBlocklists.java | 5 +- .../asynctasks/SaveWebpageImage.java | 152 ++++++++++ .../CreateHomeScreenShortcutDialog.java | 1 + .../dialogs/DownloadFileDialog.java | 34 ++- .../dialogs/DownloadImageDialog.java | 22 +- .../dialogs/SaveLogcatDialog.java | 53 ++-- .../dialogs/SaveWebpageImageDialog.java | 213 ++++++++++++++ .../helpers/FileNameHelper.java | 73 +++++ .../main/res/drawable/images_enabled_dark.xml | 2 +- .../res/drawable/images_enabled_light.xml | 2 +- .../main/res/drawable/save_dialog_dark.xml | 2 +- .../main/res/drawable/save_dialog_light.xml | 2 +- ...save_logcat_dialog.xml => save_dialog.xml} | 0 app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 3 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-ru/strings.xml | 3 +- app/src/main/res/values-tr/strings.xml | 3 +- app/src/main/res/values/strings.xml | 14 +- 22 files changed, 779 insertions(+), 219 deletions(-) create mode 100644 app/src/main/java/com/stoutner/privacybrowser/asynctasks/SaveWebpageImage.java create mode 100644 app/src/main/java/com/stoutner/privacybrowser/dialogs/SaveWebpageImageDialog.java create mode 100644 app/src/main/java/com/stoutner/privacybrowser/helpers/FileNameHelper.java rename app/src/main/res/layout/{save_logcat_dialog.xml => save_dialog.xml} (100%) diff --git a/app/src/main/java/com/stoutner/privacybrowser/activities/ImportExportActivity.java b/app/src/main/java/com/stoutner/privacybrowser/activities/ImportExportActivity.java index c8ff219b..bea58d3f 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/activities/ImportExportActivity.java +++ b/app/src/main/java/com/stoutner/privacybrowser/activities/ImportExportActivity.java @@ -25,7 +25,6 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.media.MediaScannerConnection; -import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Environment; @@ -60,6 +59,7 @@ import com.google.android.material.textfield.TextInputLayout; import com.stoutner.privacybrowser.R; import com.stoutner.privacybrowser.dialogs.StoragePermissionDialog; +import com.stoutner.privacybrowser.helpers.FileNameHelper; import com.stoutner.privacybrowser.helpers.ImportExportDatabaseHelper; import java.io.File; @@ -566,56 +566,14 @@ public class ImportExportActivity extends AppCompatActivity implements StoragePe // Get a handle for the file name edit text. EditText fileNameEditText = findViewById(R.id.file_name_edittext); - // Get the file name URI. - Uri fileNameUri = data.getData(); - - // Remove the lint warning that the file name URI might be null. - assert fileNameUri != null; - - // Get the raw file name path. - String rawFileNamePath = fileNameUri.getPath(); - - // Remove the incorrect lint warning that the file name path might be null. - assert rawFileNamePath != null; - - // Check to see if the file name Path includes a valid storage location. - if (rawFileNamePath.contains(":")) { // The path is valid. - // Split the path into the initial content uri and the final path information. - String fileNameContentPath = rawFileNamePath.substring(0, rawFileNamePath.indexOf(":")); - String fileNameFinalPath = rawFileNamePath.substring(rawFileNamePath.indexOf(":") + 1); - - // Create the file name path string. - String fileNamePath; - - // Check to see if the current file name final patch is a complete, valid path - if (fileNameFinalPath.startsWith("/storage/emulated/")) { // The existing file name final path is a complete, valid path. - // Use the provided file name path as is. - fileNamePath = fileNameFinalPath; - } else { // The existing file name final path is not a complete, valid path. - // Construct the file name path. - switch (fileNameContentPath) { - // The documents home has a special content path. - case "/document/home": - fileNamePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + "/" + fileNameFinalPath; - break; - - // Everything else for the primary user should be in `/document/primary`. - case "/document/primary": - fileNamePath = Environment.getExternalStorageDirectory() + "/" + fileNameFinalPath; - break; - - // Just in case, catch everything else and place it in the external storage directory. - default: - fileNamePath = Environment.getExternalStorageDirectory() + "/" + fileNameFinalPath; - break; - } - } + // Instantiate the file name helper. + FileNameHelper fileNameHelper = new FileNameHelper(); - // Set the file name path as the text of the file name edit text. - fileNameEditText.setText(fileNamePath); - } else { // The path is invalid. - Snackbar.make(fileNameEditText, rawFileNamePath + " " + getString(R.string.invalid_location), Snackbar.LENGTH_INDEFINITE).show(); - } + // 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; diff --git a/app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.java b/app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.java index 46c0a88a..f270cd24 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.java +++ b/app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.java @@ -28,10 +28,8 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.media.MediaScannerConnection; -import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; -import android.os.Environment; import android.preference.PreferenceManager; import android.view.Menu; import android.view.MenuItem; @@ -49,9 +47,11 @@ import androidx.fragment.app.DialogFragment; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.android.material.snackbar.Snackbar; + import com.stoutner.privacybrowser.R; import com.stoutner.privacybrowser.dialogs.StoragePermissionDialog; import com.stoutner.privacybrowser.dialogs.SaveLogcatDialog; +import com.stoutner.privacybrowser.helpers.FileNameHelper; import java.io.BufferedReader; import java.io.BufferedWriter; @@ -166,7 +166,7 @@ public class LogcatActivity extends AppCompatActivity implements SaveLogcatDialo return true; case R.id.save: - // Get a handle for the save alert dialog. + // Instantiate the save alert dialog. DialogFragment saveDialogFragment = new SaveLogcatDialog(); // Show the save alert dialog. @@ -233,7 +233,7 @@ public class LogcatActivity extends AppCompatActivity implements SaveLogcatDialo // 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 storage permission. The logcat will be saved when it finishes. + // Request the write external storage permission. The logcat will be saved when it finishes. ActivityCompat.requestPermissions(this, new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE}, 0); } @@ -325,63 +325,14 @@ public class LogcatActivity extends AppCompatActivity implements SaveLogcatDialo // Get a handle for the file name edit text. EditText fileNameEditText = saveDialog.findViewById(R.id.file_name_edittext); - // Get the file name URI. - Uri fileNameUri = data.getData(); - - // Remove the incorrect lint warning that the file name URI might be null. - assert fileNameUri != null; - - // Get the raw file name path. - String rawFileNamePath = fileNameUri.getPath(); - - // Remove the incorrect lint warning that the file name path might be null. - assert rawFileNamePath != null; - - // Check to see if the file name Path includes a valid storage location. - if (rawFileNamePath.contains(":")) { // The path is valid. - // Split the path into the initial content uri and the final path information. - String fileNameContentPath = rawFileNamePath.substring(0, rawFileNamePath.indexOf(":")); - String fileNameFinalPath = rawFileNamePath.substring(rawFileNamePath.indexOf(":") + 1); - - // Create the file name path string. - String fileNamePath; - - // Check to see if the current file name final patch is a complete, valid path - if (fileNameFinalPath.startsWith("/storage/emulated/")) { // The existing file name final path is a complete, valid path. - // Use the provided file name path as is. - fileNamePath = fileNameFinalPath; - } else { // The existing file name final path is not a complete, valid path. - // Construct the file name path. - switch (fileNameContentPath) { - // The documents home has a special content path. - case "/document/home": - fileNamePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + "/" + fileNameFinalPath; - break; - - // Everything else for the primary user should be in `/document/primary`. - case "/document/primary": - fileNamePath = Environment.getExternalStorageDirectory() + "/" + fileNameFinalPath; - break; - - // Just in case, catch everything else and place it in the external storage directory. - default: - fileNamePath = Environment.getExternalStorageDirectory() + "/" + fileNameFinalPath; - break; - } - } - - // Set the file name path as the text of the file name edit text. - fileNameEditText.setText(fileNamePath); - } else { // The path is invalid. - // Close the alert dialog. - saveDialog.dismiss(); - - // Get a handle for the logcat text view. - TextView logcatTextView = findViewById(R.id.logcat_textview); - - // Display a snackbar with the error message. - Snackbar.make(logcatTextView, rawFileNamePath + " " + getString(R.string.invalid_location), Snackbar.LENGTH_INDEFINITE).show(); - } + // 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); } } } diff --git a/app/src/main/java/com/stoutner/privacybrowser/activities/MainWebViewActivity.java b/app/src/main/java/com/stoutner/privacybrowser/activities/MainWebViewActivity.java index 657a2706..8ae5e7ad 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/activities/MainWebViewActivity.java +++ b/app/src/main/java/com/stoutner/privacybrowser/activities/MainWebViewActivity.java @@ -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; @@ -117,6 +117,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 +128,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 +139,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 +163,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 +191,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()`, @@ -295,9 +302,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`. @@ -963,6 +975,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 +1083,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 +1111,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 +1133,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 +1156,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 +1180,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 +1204,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook } }) .show(); + + // Consume the event. return true; case R.id.clear_dom_storage: @@ -1235,6 +1261,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,6 +1283,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook } }) .show(); + + // Consume the event. return true; case R.id.easylist: @@ -1266,6 +1296,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook // Reload the current WebView. currentWebView.reload(); + + // Consume the event. return true; case R.id.easyprivacy: @@ -1277,6 +1309,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook // Reload the current WebView. currentWebView.reload(); + + // Consume the event. return true; case R.id.fanboys_annoyance_list: @@ -1292,6 +1326,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,6 +1339,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook // Reload the current WebView. currentWebView.reload(); + + // Consume the event. return true; case R.id.ultraprivacy: @@ -1314,6 +1352,8 @@ public class MainWebViewActivity extends AppCompatActivity implements CreateBook // Reload the current WebView. currentWebView.reload(); + + // Consume the event. return true; case R.id.block_all_third_party_requests: @@ -1325,6 +1365,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 +1375,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 +1385,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 +1395,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 +1405,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 +1415,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 +1425,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 +1435,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 +1445,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 +1455,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 +1465,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 +1475,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 +1485,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 +1495,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 +1570,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 +1592,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 +1617,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 +1651,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 +1667,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(); + // Instantiate the save webpage image dialog. + DialogFragment saveWebpageImageDialogFragment = new SaveWebpageImageDialog(); - // Convert the bitmap to a PNG. `0` is for lossless compression (the only option for a PNG). - webpageBitmap.compress(Bitmap.CompressFormat.PNG, 0, webpageByteArrayOutputStream); + // Show the save webpage image dialog. + saveWebpageImageDialogFragment.show(getSupportFragmentManager(), getString(R.string.save_as_image)); - // 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(); - } - - try { - // Create an image file output stream. - FileOutputStream imageFileOutputStream = new FileOutputStream(imageFile); - - // 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 +1688,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 +1702,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 +1717,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 +1741,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 +1753,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: @@ -2378,6 +2476,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 +2645,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 +2807,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) { @@ -4903,8 +5094,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; } diff --git a/app/src/main/java/com/stoutner/privacybrowser/asynctasks/PopulateBlocklists.java b/app/src/main/java/com/stoutner/privacybrowser/asynctasks/PopulateBlocklists.java index 88160849..7a7fc4da 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/asynctasks/PopulateBlocklists.java +++ b/app/src/main/java/com/stoutner/privacybrowser/asynctasks/PopulateBlocklists.java @@ -43,13 +43,14 @@ public class PopulateBlocklists extends AsyncTask>> combinedBlocklists); } - // Declare a populate blocklists listener. + // Define a populate blocklists listener. private PopulateBlocklistsListener populateBlocklistsListener; - // Declare weak references for the activity and context. + // Define weak references for the activity and context. private WeakReference contextWeakReference; private WeakReference activityWeakReference; + // The public constructor. public PopulateBlocklists(Context context, Activity activity) { // Populate the weak reference to the context. contextWeakReference = new WeakReference<>(context); diff --git a/app/src/main/java/com/stoutner/privacybrowser/asynctasks/SaveWebpageImage.java b/app/src/main/java/com/stoutner/privacybrowser/asynctasks/SaveWebpageImage.java new file mode 100644 index 00000000..aeb92988 --- /dev/null +++ b/app/src/main/java/com/stoutner/privacybrowser/asynctasks/SaveWebpageImage.java @@ -0,0 +1,152 @@ +/* + * Copyright © 2019 Soren Stoutner . + * + * This file is part of Privacy Browser . + * + * Privacy Browser is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Privacy Browser is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Privacy Browser. If not, see . + */ + +package com.stoutner.privacybrowser.asynctasks; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.os.AsyncTask; + +import com.google.android.material.snackbar.Snackbar; + +import com.stoutner.privacybrowser.R; +import com.stoutner.privacybrowser.views.NestedScrollWebView; +import com.stoutner.privacybrowser.views.NoSwipeViewPager; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.lang.ref.WeakReference; + +public class SaveWebpageImage extends AsyncTask { + // Define the weak references. + private WeakReference activityWeakReference; + private WeakReference nestedScrollWebViewWeakReference; + + // Define a success string constant. + private final String SUCCESS = "Success"; + + // Define the saving image snackbar and the webpage bitmap. + private Snackbar savingImageSnackbar; + private Bitmap webpageBitmap; + + // The public constructor. + public SaveWebpageImage(Activity activity, NestedScrollWebView nestedScrollWebView) { + // Populate the weak references. + activityWeakReference = new WeakReference<>(activity); + nestedScrollWebViewWeakReference = new WeakReference<>(nestedScrollWebView); + } + + // `onPreExecute()` operates on the UI thread. + @Override + protected void onPreExecute() { + // Get a handle for the activity and the nested scroll WebView. + Activity activity = activityWeakReference.get(); + NestedScrollWebView nestedScrollWebView = nestedScrollWebViewWeakReference.get(); + + // Abort if the activity or the nested scroll WebView is gone. + if ((activity == null) || activity.isFinishing() || nestedScrollWebView == null) { + return; + } + + // Create a saving image snackbar. + savingImageSnackbar = Snackbar.make(nestedScrollWebView, R.string.saving_image, Snackbar.LENGTH_INDEFINITE); + + // Display the saving image snackbar. + savingImageSnackbar.show(); + + // Create a webpage bitmap. Once the Minimum API >= 26 Bitmap.Config.RBGA_F16 can be used instead of ARGB_8888. The nested scroll WebView commands must be run on the UI thread. + webpageBitmap = Bitmap.createBitmap(nestedScrollWebView.getHorizontalScrollRange(), nestedScrollWebView.getVerticalScrollRange(), Bitmap.Config.ARGB_8888); + + // Create a canvas. + Canvas webpageCanvas = new Canvas(webpageBitmap); + + // Draw the current webpage onto the bitmap. The nested scroll WebView commands must be run on the UI thread. + nestedScrollWebView.draw(webpageCanvas); + } + + @Override + protected String doInBackground(String... fileName) { + // Get a handle for the activity. + Activity activity = activityWeakReference.get(); + + // Abort if the activity is gone. + if ((activity == null) || activity.isFinishing()) { + return ""; + } + + // 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). This compression takes a long time. + webpageBitmap.compress(Bitmap.CompressFormat.PNG, 0, webpageByteArrayOutputStream); + + // Get a file for the image. + File imageFile = new File(fileName[0]); + + // Delete the current file if it exists. + if (imageFile.exists()) { + //noinspection ResultOfMethodCallIgnored + imageFile.delete(); + } + + // Create a file creation disposition string. + String fileCreationDisposition = SUCCESS; + + try { + // Create an image file output stream. + FileOutputStream imageFileOutputStream = new FileOutputStream(imageFile); + + // Write the webpage image to the image file. + webpageByteArrayOutputStream.writeTo(imageFileOutputStream); + } catch (Exception exception) { + // Store the error in the file creation disposition string. + fileCreationDisposition = exception.toString(); + } + + // Return the file creation disposition string. + return fileCreationDisposition; + } + + // `onPostExecute()` operates on the UI thread. + @Override + protected void onPostExecute(String fileCreationDisposition) { + // Get a handle for the activity. + Activity activity = activityWeakReference.get(); + + // Abort if the activity is gone. + if ((activity == null) || activity.isFinishing()) { + return; + } + + // Get a handle for the no swipe view pager. + NoSwipeViewPager noSwipeViewPager = activity.findViewById(R.id.webviewpager); + + // Dismiss the saving image snackbar. + savingImageSnackbar.dismiss(); + + // Display a file creation disposition snackbar. + if (fileCreationDisposition.equals(SUCCESS)) { + Snackbar.make(noSwipeViewPager, R.string.image_saved, Snackbar.LENGTH_SHORT).show(); + } else { + Snackbar.make(noSwipeViewPager, activity.getString(R.string.error_saving_image) + " " + fileCreationDisposition, Snackbar.LENGTH_INDEFINITE).show(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/stoutner/privacybrowser/dialogs/CreateHomeScreenShortcutDialog.java b/app/src/main/java/com/stoutner/privacybrowser/dialogs/CreateHomeScreenShortcutDialog.java index dc4b2f8f..93e0ec86 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/dialogs/CreateHomeScreenShortcutDialog.java +++ b/app/src/main/java/com/stoutner/privacybrowser/dialogs/CreateHomeScreenShortcutDialog.java @@ -61,6 +61,7 @@ public class CreateHomeScreenShortcutDialog extends DialogFragment { private EditText urlEditText; private RadioButton openWithPrivacyBrowserRadioButton; + // The public constructor. public static CreateHomeScreenShortcutDialog createDialog(String shortcutName, String urlString, Bitmap favoriteIconBitmap) { // Create a favorite icon byte array output stream. ByteArrayOutputStream favoriteIconByteArrayOutputStream = new ByteArrayOutputStream(); diff --git a/app/src/main/java/com/stoutner/privacybrowser/dialogs/DownloadFileDialog.java b/app/src/main/java/com/stoutner/privacybrowser/dialogs/DownloadFileDialog.java index 0553f804..eb2b0ebf 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/dialogs/DownloadFileDialog.java +++ b/app/src/main/java/com/stoutner/privacybrowser/dialogs/DownloadFileDialog.java @@ -29,7 +29,6 @@ import android.net.Uri; import android.os.Bundle; import android.preference.PreferenceManager; import android.view.KeyEvent; -import android.view.LayoutInflater; import android.view.View; import android.view.WindowManager; import android.widget.EditText; @@ -67,14 +66,17 @@ public class DownloadFileDialog extends DialogFragment { // Create a variable for the file name string. String fileNameString; + // Get the index of the end of `filename=` from the file name string. + int fileNameIndex = contentDisposition.indexOf("filename=") + 9; + // Parse the filename from `contentDisposition`. if (contentDisposition.contains("filename=\"")) { // The file name is contained in a string surrounded by `""`. fileNameString = contentDisposition.substring(contentDisposition.indexOf("filename=\"") + 10, contentDisposition.indexOf("\"", contentDisposition.indexOf("filename=\"") + 10)); - } else if (contentDisposition.contains("filename=") && ((contentDisposition.indexOf(";", contentDisposition.indexOf("filename=") + 9)) > 0 )) { + } else if (contentDisposition.contains("filename=") && ((contentDisposition.indexOf(";", fileNameIndex)) > 0 )) { // The file name is contained in a string beginning with `filename=` and ending with `;`. - fileNameString = contentDisposition.substring(contentDisposition.indexOf("filename=") + 9, contentDisposition.indexOf(";", contentDisposition.indexOf("filename=") + 9)); + fileNameString = contentDisposition.substring(fileNameIndex, contentDisposition.indexOf(";", fileNameIndex)); } else if (contentDisposition.contains("filename=")) { // The file name is contained in a string beginning with `filename=` and proceeding to the end of `contentDisposition`. - fileNameString = contentDisposition.substring(contentDisposition.indexOf("filename=") + 9); + fileNameString = contentDisposition.substring(fileNameIndex); } else { // `contentDisposition` does not contain the filename, so use the last path segment of the URL. Uri downloadUri = Uri.parse(urlString); fileNameString = downloadUri.getLastPathSegment(); @@ -114,15 +116,6 @@ public class DownloadFileDialog extends DialogFragment { fileSize = String.format(Locale.getDefault(), "%.3g", (float) fileSizeLong / 1048576) + " MB"; } - // Remove the warning below that `getActivity()` might be null; - assert getActivity() != null; - - // Get the activity's layout inflater. - LayoutInflater layoutInflater = getActivity().getLayoutInflater(); - - // Use an alert dialog builder to create the alert dialog. - AlertDialog.Builder dialogBuilder; - // Get a handle for the shared preferences. SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -130,6 +123,9 @@ public class DownloadFileDialog extends DialogFragment { boolean allowScreenshots = sharedPreferences.getBoolean("allow_screenshots", false); boolean darkTheme = sharedPreferences.getBoolean("dark_theme", false); + // Use an alert dialog builder to create the alert dialog. + AlertDialog.Builder dialogBuilder; + // Set the style according to the theme. if (darkTheme) { dialogBuilder = new AlertDialog.Builder(getActivity(), R.style.PrivacyBrowserAlertDialogDark); @@ -140,8 +136,18 @@ public class DownloadFileDialog extends DialogFragment { // Set the title. dialogBuilder.setTitle(R.string.save_as); + // Set the icon according to the theme. + if (darkTheme) { + dialogBuilder.setIcon(R.drawable.save_dialog_dark); + } else { + dialogBuilder.setIcon(R.drawable.save_dialog_light); + } + + // Remove the warning below that `getActivity()` might be null; + assert getActivity() != null; + // Set the view. The parent view is `null` because it will be assigned by `AlertDialog`. - dialogBuilder.setView(layoutInflater.inflate(R.layout.download_file_dialog, null)); + dialogBuilder.setView(getActivity().getLayoutInflater().inflate(R.layout.download_file_dialog, null)); // Set an listener on the negative button. dialogBuilder.setNegativeButton(R.string.cancel, (DialogInterface dialog, int which) -> { diff --git a/app/src/main/java/com/stoutner/privacybrowser/dialogs/DownloadImageDialog.java b/app/src/main/java/com/stoutner/privacybrowser/dialogs/DownloadImageDialog.java index 06d765c2..f200d95a 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/dialogs/DownloadImageDialog.java +++ b/app/src/main/java/com/stoutner/privacybrowser/dialogs/DownloadImageDialog.java @@ -29,7 +29,6 @@ import android.net.Uri; import android.os.Bundle; import android.preference.PreferenceManager; import android.view.KeyEvent; -import android.view.LayoutInflater; import android.view.View; import android.view.WindowManager; import android.widget.EditText; @@ -94,12 +93,6 @@ public class DownloadImageDialog extends DialogFragment { // Remove the warning below that `.getActivity()` might be null. assert getActivity() != null; - // Get the activity's layout inflater. - LayoutInflater layoutInflater = getActivity().getLayoutInflater(); - - // Use and alert dialog builder to create the alert dialog. - AlertDialog.Builder dialogBuilder; - // Get a handle for the shared preferences. SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -107,6 +100,9 @@ public class DownloadImageDialog extends DialogFragment { boolean allowScreenshots = sharedPreferences.getBoolean("allow_screenshots", false); boolean darkTheme = sharedPreferences.getBoolean("dark_theme", false); + // Use and alert dialog builder to create the alert dialog. + AlertDialog.Builder dialogBuilder; + // Set the style according to the theme. if (darkTheme) { dialogBuilder = new AlertDialog.Builder(getActivity(), R.style.PrivacyBrowserAlertDialogDark); @@ -117,8 +113,18 @@ public class DownloadImageDialog extends DialogFragment { // Set the title. dialogBuilder.setTitle(R.string.save_image_as); + // Set the icon according to the theme. + if (darkTheme) { + dialogBuilder.setIcon(R.drawable.images_enabled_dark); + } else { + dialogBuilder.setIcon(R.drawable.images_enabled_light); + } + + // Remove the incorrect lint warning below that `getActivity() might be null. + assert getActivity() != null; + // Set the view. The parent view is `null` because it will be assigned by `AlertDialog`. - dialogBuilder.setView(layoutInflater.inflate(R.layout.download_image_dialog, null)); + dialogBuilder.setView(getActivity().getLayoutInflater().inflate(R.layout.download_image_dialog, null)); // Set an listener on the negative button. dialogBuilder.setNegativeButton(R.string.cancel, (DialogInterface dialog, int which) -> { diff --git a/app/src/main/java/com/stoutner/privacybrowser/dialogs/SaveLogcatDialog.java b/app/src/main/java/com/stoutner/privacybrowser/dialogs/SaveLogcatDialog.java index ec1ef33c..8178a8f2 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/dialogs/SaveLogcatDialog.java +++ b/app/src/main/java/com/stoutner/privacybrowser/dialogs/SaveLogcatDialog.java @@ -21,6 +21,7 @@ package com.stoutner.privacybrowser.dialogs; import android.Manifest; import android.annotation.SuppressLint; +import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; import android.content.Context; @@ -48,34 +49,28 @@ import androidx.fragment.app.DialogFragment; // The AndroidX dialog fragment is import com.stoutner.privacybrowser.R; public class SaveLogcatDialog extends DialogFragment { - // Instantiate the class variables. + // Define the save logcat listener. private SaveLogcatListener saveLogcatListener; - private Context parentContext; // The public interface is used to send information back to the parent activity. public interface SaveLogcatListener { void onSaveLogcat(DialogFragment dialogFragment); } + @Override public void onAttach(Context context) { // Run the default commands. super.onAttach(context); - // Store a handle for the context. - parentContext = context; - - // Get a handle for `SaveLogcatListener` from the launching context. + // Get a handle for save logcat listener from the launching context. saveLogcatListener = (SaveLogcatListener) context; } - // `@SuppressLing("InflateParams")` removes the warning about using `null` as the parent view group when inflating the `AlertDialog`. + // `@SuppressLing("InflateParams")` removes the warning about using null as the parent view group when inflating the alert dialog. @SuppressLint("InflateParams") @Override @NonNull public Dialog onCreateDialog(Bundle savedInstanceState) { - // Use an alert dialog builder to create the alert dialog. - AlertDialog.Builder dialogBuilder; - // Get a handle for the shared preferences. SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); @@ -83,22 +78,25 @@ public class SaveLogcatDialog extends DialogFragment { boolean darkTheme = sharedPreferences.getBoolean("dark_theme", false); boolean allowScreenshots = sharedPreferences.getBoolean("allow_screenshots", false); + // Use an alert dialog builder to create the alert dialog. + AlertDialog.Builder dialogBuilder; + + // Get a handle for the activity. + Activity activity = getActivity(); + + // Remove the incorrect lint warning below that the activity might be null. + assert activity != null; + // Set the style according to the theme. if (darkTheme) { - dialogBuilder = new AlertDialog.Builder(getActivity(), R.style.PrivacyBrowserAlertDialogDark); + dialogBuilder = new AlertDialog.Builder(activity, R.style.PrivacyBrowserAlertDialogDark); } else { - dialogBuilder = new AlertDialog.Builder(getActivity(), R.style.PrivacyBrowserAlertDialogLight); + dialogBuilder = new AlertDialog.Builder(activity, R.style.PrivacyBrowserAlertDialogLight); } // Set the title. dialogBuilder.setTitle(R.string.save_logcat); - // Remove the incorrect lint warning that `getActivity().getLayoutInflater()` might be null. - assert getActivity() != null; - - // Set the view. The parent view is null because it will be assigned by the alert dialog. - dialogBuilder.setView(getActivity().getLayoutInflater().inflate(R.layout.save_logcat_dialog, null)); - // Set the icon according to the theme. if (darkTheme) { dialogBuilder.setIcon(R.drawable.save_dialog_dark); @@ -106,6 +104,9 @@ public class SaveLogcatDialog extends DialogFragment { dialogBuilder.setIcon(R.drawable.save_dialog_light); } + // Set the view. The parent view is null because it will be assigned by the alert dialog. + dialogBuilder.setView(activity.getLayoutInflater().inflate(R.layout.save_dialog, null)); + // Set the cancel button listener. dialogBuilder.setNegativeButton(R.string.cancel, (DialogInterface dialog, int which) -> { // Do nothing. The alert dialog will close automatically. @@ -120,7 +121,7 @@ public class SaveLogcatDialog extends DialogFragment { // Create an alert dialog from the builder. AlertDialog alertDialog = dialogBuilder.create(); - // Remove the incorrect lint warning below that `getWindow().addFlags()` might be null. + // Remove the incorrect lint warning below that `getWindow()` might be null. assert alertDialog.getWindow() != null; // Disable screenshots if not allowed. @@ -140,13 +141,19 @@ public class SaveLogcatDialog extends DialogFragment { // Create a string for the default file path. String defaultFilePath; + // Get a handle for the context. + Context context = getContext(); + + // Remove the incorrect lint warning below that context might be null. + assert context != null; + // Set the default file path according to the storage permission state. - if (ContextCompat.checkSelfPermission(parentContext, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { // The storage permission has been granted. + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { // The storage permission has been granted. // Set the default file path to use the external public directory. defaultFilePath = Environment.getExternalStorageDirectory() + "/" + getString(R.string.privacy_browser_logcat_txt); } else { // The storage permission has not been granted. // Set the default file path to use the external private directory. - defaultFilePath = parentContext.getExternalFilesDir(null) + "/" + getString(R.string.privacy_browser_logcat_txt); + defaultFilePath = context.getExternalFilesDir(null) + "/" + getString(R.string.privacy_browser_logcat_txt); } // Display the default file path. @@ -190,8 +197,8 @@ public class SaveLogcatDialog extends DialogFragment { // Request a file that can be opened. browseIntent.addCategory(Intent.CATEGORY_OPENABLE); - // Launch the file picker. There is only one `startActivityForResult()`, so the request code is simply set to 0. - startActivityForResult(browseIntent, 0); + // Launch the file picker. There is only one `startActivityForResult()`, so the request code is simply set to 0, but it must be run under `activity` so the request code is correct. + activity.startActivityForResult(browseIntent, 0); }); // Hide the storage permission text view on API < 23 as permissions on older devices are automatically granted. diff --git a/app/src/main/java/com/stoutner/privacybrowser/dialogs/SaveWebpageImageDialog.java b/app/src/main/java/com/stoutner/privacybrowser/dialogs/SaveWebpageImageDialog.java new file mode 100644 index 00000000..11127d92 --- /dev/null +++ b/app/src/main/java/com/stoutner/privacybrowser/dialogs/SaveWebpageImageDialog.java @@ -0,0 +1,213 @@ +/* + * Copyright © 2019 Soren Stoutner . + * + * This file is part of Privacy Browser . + * + * Privacy Browser is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Privacy Browser is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Privacy Browser. If not, see . + */ + +package com.stoutner.privacybrowser.dialogs; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.preference.PreferenceManager; +import android.provider.DocumentsContract; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.DialogFragment; + +import com.stoutner.privacybrowser.R; +import com.stoutner.privacybrowser.activities.MainWebViewActivity; + +public class SaveWebpageImageDialog extends DialogFragment { + // Define the save webpage image listener. + private SaveWebpageImageListener saveWebpageImageListener; + + // The public interface is used to send information back to the parent activity. + public interface SaveWebpageImageListener { + void onSaveWebpageImage(DialogFragment dialogFragment); + } + + @Override + public void onAttach(Context context) { + // Run the default commands. + super.onAttach(context); + + // Get a handle for the save webpage image listener from the launching context. + saveWebpageImageListener = (SaveWebpageImageListener) context; + } + + // `@SuppressLing("InflateParams")` removes the warning about using null as the parent view group when inflating the alert dialog. + @SuppressLint("InflateParams") + @Override + @NonNull + public Dialog onCreateDialog(Bundle savedInstanceState) { + // Get a handle for the shared preferences. + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); + + // Get the screenshot and theme preferences. + boolean darkTheme = sharedPreferences.getBoolean("dark_theme", false); + boolean allowScreenshots = sharedPreferences.getBoolean("allow_screenshots", false); + + // Use an alert dialog builder to create the alert dialog. + AlertDialog.Builder dialogBuilder; + + // Get a handle for the activity. + Activity activity = getActivity(); + + // Remove the incorrect lint warning below that the activity might be null. + assert activity != null; + + // Set the style according to the theme. + if (darkTheme) { + dialogBuilder = new AlertDialog.Builder(activity, R.style.PrivacyBrowserAlertDialogDark); + } else { + dialogBuilder = new AlertDialog.Builder(activity, R.style.PrivacyBrowserAlertDialogLight); + } + + // Set the title. + dialogBuilder.setTitle(R.string.save_image); + + // Set the icon according to the theme. + if (darkTheme) { + dialogBuilder.setIcon(R.drawable.images_enabled_dark); + } else { + dialogBuilder.setIcon(R.drawable.images_enabled_light); + } + + // Set the view. The parent view is null because it will be assigned by the alert dialog. + dialogBuilder.setView(activity.getLayoutInflater().inflate(R.layout.save_dialog, null)); + + // Set the cancel button listener. + dialogBuilder.setNegativeButton(R.string.cancel, (DialogInterface dialog, int which) -> { + // Do nothing. The alert dialog will close automatically. + }); + + // Set the save button listener. + dialogBuilder.setPositiveButton(R.string.save, (DialogInterface dialog, int which) -> { + // Return the dialog fragment to the parent activity. + saveWebpageImageListener.onSaveWebpageImage(this); + }); + + // Create an alert dialog from the builder. + AlertDialog alertDialog = dialogBuilder.create(); + + // Remove the incorrect lint warning below that `getWindows()` might be null. + assert alertDialog.getWindow() != null; + + // Disable screenshots if not allowed. + if (!allowScreenshots) { + alertDialog.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + + // The alert dialog must be shown before items in the layout can be modified. + alertDialog.show(); + + // Get handles for the layout items. + EditText fileNameEditText = alertDialog.findViewById(R.id.file_name_edittext); + Button browseButton = alertDialog.findViewById(R.id.browse_button); + TextView storagePermissionTextView = alertDialog.findViewById(R.id.storage_permission_textview); + Button saveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE); + + // Create a string for the default file path. + String defaultFilePath; + + // Get a handle for the context. + Context context = getContext(); + + // Remove the incorrect lint warning that context might be null. + assert context != null; + + // Set the default file path according to the storage permission state. + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { // The storage permission has been granted. + // Set the default file path to use the external public directory. + defaultFilePath = Environment.getExternalStorageDirectory() + "/" + getString(R.string.webpage_png); + } else { // The storage permission has not been granted. + // Set the default file path to use the external private directory. + defaultFilePath = context.getExternalFilesDir(null) + "/" + getString(R.string.webpage_png); + } + + // Display the default file path. + fileNameEditText.setText(defaultFilePath); + + // Update the status of the save button when the file name changes. + fileNameEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // Do nothing. + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // Do nothing. + } + + @Override + public void afterTextChanged(Editable s) { + // // Enable the save button if a file name exists. + saveButton.setEnabled(!fileNameEditText.getText().toString().isEmpty()); + } + }); + + // Handle clicks on the browse button. + browseButton.setOnClickListener((View view) -> { + // Create the file picker intent. + Intent browseIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + + // Set the intent MIME type to include all files so that everything is visible. + browseIntent.setType("*/*"); + + // Set the initial file name. + browseIntent.putExtra(Intent.EXTRA_TITLE, getString(R.string.webpage_png)); + + // Set the initial directory if the minimum API >= 26. + if (Build.VERSION.SDK_INT >= 26) { + browseIntent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Environment.getExternalStorageDirectory()); + } + + // Request a file that can be opened. + browseIntent.addCategory(Intent.CATEGORY_OPENABLE); + + // Start the file picker. This must be started under `activity` so that the request code is returned correctly. + activity.startActivityForResult(browseIntent, MainWebViewActivity.BROWSE_SAVE_WEBPAGE_IMAGE_REQUEST_CODE); + }); + + // Hide the storage permission text view on API < 23 as permissions on older devices are automatically granted. + if (Build.VERSION.SDK_INT < 23) { + storagePermissionTextView.setVisibility(View.GONE); + } + + // Return the alert dialog. + return alertDialog; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/stoutner/privacybrowser/helpers/FileNameHelper.java b/app/src/main/java/com/stoutner/privacybrowser/helpers/FileNameHelper.java new file mode 100644 index 00000000..5eb19484 --- /dev/null +++ b/app/src/main/java/com/stoutner/privacybrowser/helpers/FileNameHelper.java @@ -0,0 +1,73 @@ +/* + * Copyright © 2019 Soren Stoutner . + * + * This file is part of Privacy Browser . + * + * Privacy Browser is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Privacy Browser is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Privacy Browser. If not, see . + */ + +package com.stoutner.privacybrowser.helpers; + +import android.net.Uri; +import android.os.Environment; + +public class FileNameHelper { + public String convertUriToFileNamePath(Uri Uri) { + // Initialize a file name path string. + String fileNamePath = ""; + + // Convert the URI to a raw file name path. + String rawFileNamePath = Uri.getPath(); + + // Only process the raw file name path if it is not null. + if (rawFileNamePath != null) { + // Check to see if the file name Path includes a valid storage location. + if (rawFileNamePath.contains(":")) { // The path contains a `:`. + // Split the path into the initial content uri and the final path information. + String fileNameContentPath = rawFileNamePath.substring(0, rawFileNamePath.indexOf(":")); + String fileNameFinalPath = rawFileNamePath.substring(rawFileNamePath.indexOf(":") + 1); + + // Check to see if the current file name final patch is a complete, valid path + if (fileNameFinalPath.startsWith("/storage/emulated/")) { // The existing file name final path is a complete, valid path. + // Use the provided file name path as is. + fileNamePath = fileNameFinalPath; + } else { // The existing file name final path is not a complete, valid path. + // Construct the file name path. + switch (fileNameContentPath) { + // The documents home has a special content path. + case "/document/home": + fileNamePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + "/" + fileNameFinalPath; + break; + + // Everything else for the primary user should be in `/document/primary`. + case "/document/primary": + fileNamePath = Environment.getExternalStorageDirectory() + "/" + fileNameFinalPath; + break; + + // Just in case, catch everything else and place it in the external storage directory. + default: + fileNamePath = Environment.getExternalStorageDirectory() + "/" + fileNameFinalPath; + break; + } + } + } else { // The path does not contain a `:`. + // Use the raw file name path. + fileNamePath = rawFileNamePath; + } + } + + // Return the file name path string. + return fileNamePath; + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/images_enabled_dark.xml b/app/src/main/res/drawable/images_enabled_dark.xml index cb45f476..41874845 100644 --- a/app/src/main/res/drawable/images_enabled_dark.xml +++ b/app/src/main/res/drawable/images_enabled_dark.xml @@ -1,4 +1,4 @@ - + + + + Export erfolgreich. Export fehlgeschlagen: Import fehlgeschlagen: - ist kein gültiger Ordner. Speicher-Berechtigung Privacy Browser benötigt die Speicher-Berechtigung, um auf öffentliche Ordner zuzugreifen. Wenn diese verweigert wird, können die Ordner der Anwendung trotzdem verwendet werden. @@ -389,7 +388,7 @@ Orbot-Proxy wird nicht funktionieren, solange Orbot nicht installiert ist. - Warte, bis sich Orbot verbindet... + Warte, bis sich Orbot verbindet… Über Privacy Browser diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d94d4788..fd14e0cf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -358,7 +358,6 @@ Exportación exitosa. Exportación fallida: Importación fallida: - no es una ubicación válida. Permiso de almacenamiento Navegador Privado necesita el permiso de almacenamiento para acceder a los directorios públicos. Si se deniega, los directorios de la aplicación pueden seguir utilizándose. @@ -389,7 +388,7 @@ Enviar a través de Orbot no funcionará a menos que se instale Orbot. - Esperando a Orbot para conectar... + Esperando a Orbot para conectar… Acerca de Navegador Privado diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c79bee3f..444ee8a5 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -357,7 +357,6 @@ Esportazione riuscita Esportazione fallita: Importazione fallita: - non è una cartella valida. Permesso di accesso alla memoria Privacy Browser necessita del permesso di accesso alla memoria per poter accedere alle cartelle pubbliche. Se questo permesso è negato possono comunque essere utilizzate le cartelle dell\'applicazione. @@ -388,7 +387,7 @@ Il Proxy con Orbot funziona solo se è installato Orbot. - In attesa della connessione di Orbot... + In attesa della connessione di Orbot… Informazioni su Privacy Browser diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index a591478d..35e3a4b7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -354,7 +354,6 @@ Экспорт выполнен. Сбой при экспорте: Сбой при импорте: - - недопустимое расположение. Доступ к хранилищу Privacy Browser необходимо разрешение на доступ к внешним папкам. Если доступ предоставлен не будет, можно использовать локальную папку приложения. Для доступа к файлам во внешних папках требуется соответствующее разрешение. В противном случае будут работать только локальные папки. @@ -383,7 +382,7 @@ Проксирование Orbot работает только с установленным Orbot. - Ожидание Orbot для подключения... + Ожидание Orbot для подключения… О Privacy Browser diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 88f473c8..ebb1189f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -354,7 +354,6 @@ Dışa aktarım başarılı Dışa aktarım başarısız: İçe aktarım başarısız: - geçerli bir konum değildir Depolama İzni Privacy Browser, genel dizinlere erişmek için depolama iznine ihtiyaç duymaktadır. Reddedildiği takdirde, uygulamanın dizinleri hala kullanılabilir. Genel dizinlerdeki dosyalara erişim icin depolama izni gerekmektedir. Aksi takdirde, sadece uygulamanın dizinleri çalışacaktır. @@ -383,7 +382,7 @@ Orbot yüklenmeden Orbot vekil sunucusu çalışmayacaktır. - Orbot\'un bağlanması bekleniyor... + Orbot\'un bağlanması bekleniyor… Privacy Browser Hakkında diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a64d221f..1f899688 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -206,6 +206,13 @@ Previous Next + + Save Image + Webpage.png + Saving image… + Image saved. + Error saving image: + Request Headers Response Message @@ -363,7 +370,6 @@ Export successful. Export failed: Import failed: - is not a valid location. Storage Permission Privacy Browser needs the storage permission to access public directories. If it is denied, the app’s directories can still be used. Accessing files in public directories requires the storage permission. Otherwise, only app directories will work. @@ -391,7 +397,7 @@ Orbot proxy will not work unless Orbot is installed. - Waiting for Orbot to connect... + Waiting for Orbot to connect… About Privacy Browser @@ -497,14 +503,14 @@ WebView default user agent Mozilla/5.0 (Android 9; Mobile; rv:67.0) Gecko/67.0 Firefox/67.0 Mozilla/5.0 (Linux; Android 9; Pixel 2 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Mobile Safari/537.36 - Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1 + Mozilla/5.0 (iPhone; CPU iPhone OS 12_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.1 Mobile/15E148 Safari/604.1 Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36 Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:67.0) Gecko/20100101 Firefox/67.0 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763 Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0.2 Safari/605.1.15 + Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0.3 Safari/605.1.15 Custom user agent Custom user agent -- 2.45.2