2 * Copyright © 2017-2021 Soren Stoutner <soren@stoutner.com>.
4 * This file is part of Privacy Browser <https://www.stoutner.com/privacy-browser>.
6 * Privacy Browser is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
11 * Privacy Browser is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License
17 * along with Privacy Browser. If not, see <http://www.gnu.org/licenses/>.
20 package com.stoutner.privacybrowser.activities
22 import android.content.res.Configuration
23 import android.os.Build
24 import android.os.Bundle
25 import android.text.SpannableStringBuilder
26 import android.text.Spanned
27 import android.text.style.ForegroundColorSpan
28 import android.util.TypedValue
29 import android.view.KeyEvent
30 import android.view.Menu
31 import android.view.MenuItem
32 import android.view.View
33 import android.view.View.OnFocusChangeListener
34 import android.view.WindowManager
35 import android.view.inputmethod.InputMethodManager
36 import android.widget.EditText
37 import android.widget.ProgressBar
38 import android.widget.TextView
40 import androidx.appcompat.app.ActionBar
41 import androidx.appcompat.app.AppCompatActivity
42 import androidx.appcompat.widget.Toolbar
43 import androidx.core.app.NavUtils
44 import androidx.fragment.app.DialogFragment
45 import androidx.lifecycle.ViewModelProvider
46 import androidx.preference.PreferenceManager
47 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
49 import com.google.android.material.snackbar.Snackbar
51 import com.stoutner.privacybrowser.R
52 import com.stoutner.privacybrowser.dialogs.AboutViewSourceDialog
53 import com.stoutner.privacybrowser.dialogs.UntrustedSslCertificateDialog
54 import com.stoutner.privacybrowser.dialogs.UntrustedSslCertificateDialog.UntrustedSslCertificateListener
55 import com.stoutner.privacybrowser.helpers.ProxyHelper
56 import com.stoutner.privacybrowser.viewmodelfactories.WebViewSourceFactory
57 import com.stoutner.privacybrowser.viewmodels.WebViewSource
59 import java.util.Locale
61 // Declare the public constants.
62 const val CURRENT_URL = "current_url"
63 const val USER_AGENT = "user_agent"
65 class ViewSourceActivity: AppCompatActivity(), UntrustedSslCertificateListener {
66 // Declare the class variables.
67 private lateinit var initialGrayColorSpan: ForegroundColorSpan
68 private lateinit var finalGrayColorSpan: ForegroundColorSpan
69 private lateinit var redColorSpan: ForegroundColorSpan
70 private lateinit var webViewSource: WebViewSource
72 // Declare the class views.
73 private lateinit var urlEditText: EditText
74 private lateinit var requestHeadersTitleTextView: TextView
75 private lateinit var requestHeadersTextView: TextView
76 private lateinit var responseMessageTitleTextView: TextView
77 private lateinit var responseMessageTextView: TextView
78 private lateinit var responseHeadersTitleTextView: TextView
79 private lateinit var responseBodyTitleTextView: TextView
81 override fun onCreate(savedInstanceState: Bundle?) {
82 // Get a handle for the shared preferences.
83 val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
85 // Get the preferences.
86 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
87 val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
89 // Disable screenshots if not allowed.
90 if (!allowScreenshots) {
91 window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
95 setTheme(R.style.PrivacyBrowser)
97 // Run the default commands.
98 super.onCreate(savedInstanceState)
100 // Get the launching intent
103 // Get the information from the intent.
104 val currentUrl = intent.getStringExtra(CURRENT_URL)!!
105 val userAgent = intent.getStringExtra(USER_AGENT)!!
107 // Set the content view.
109 setContentView(R.layout.view_source_coordinatorlayout_bottom_appbar)
111 setContentView(R.layout.view_source_coordinatorlayout_top_appbar)
114 // Get a handle for the toolbar.
115 val toolbar = findViewById<Toolbar>(R.id.view_source_toolbar)
117 // Set the support action bar.
118 setSupportActionBar(toolbar)
120 // Get a handle for the action bar.
121 val actionBar = supportActionBar!!
123 // Add the custom layout to the action bar.
124 actionBar.setCustomView(R.layout.view_source_app_bar)
126 // Instruct the action bar to display a custom layout.
127 actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM
129 // Get handles for the views.
130 urlEditText = findViewById(R.id.url_edittext)
131 val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
132 val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.view_source_swiperefreshlayout)
133 requestHeadersTitleTextView = findViewById(R.id.request_headers_title_textview)
134 requestHeadersTextView = findViewById(R.id.request_headers_textview)
135 responseMessageTitleTextView = findViewById(R.id.response_message_title_textview)
136 responseMessageTextView = findViewById(R.id.response_message_textview)
137 responseHeadersTitleTextView = findViewById(R.id.response_headers_title_textivew)
138 val responseHeadersTextView = findViewById<TextView>(R.id.response_headers_textview)
139 responseBodyTitleTextView = findViewById(R.id.response_body_title_textview)
140 val responseBodyTextView = findViewById<TextView>(R.id.response_body_textview)
142 // Populate the URL text box.
143 urlEditText.setText(currentUrl)
145 // Initialize the gray foreground color spans for highlighting the URLs. The deprecated `getColor()` must be used until the minimum API >= 23.
146 @Suppress("DEPRECATION")
147 initialGrayColorSpan = ForegroundColorSpan(resources.getColor(R.color.gray_500))
148 @Suppress("DEPRECATION")
149 finalGrayColorSpan = ForegroundColorSpan(resources.getColor(R.color.gray_500))
151 // Get the current theme status.
152 val currentThemeStatus = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
154 // Set the red color span according to the theme. The deprecated `getColor()` must be used until the minimum API >= 23.
155 redColorSpan = if (currentThemeStatus == Configuration.UI_MODE_NIGHT_NO) {
156 @Suppress("DEPRECATION")
157 ForegroundColorSpan(resources.getColor(R.color.red_a700))
159 @Suppress("DEPRECATION")
160 ForegroundColorSpan(resources.getColor(R.color.red_900))
163 // Apply text highlighting to the URL.
166 // Get a handle for the input method manager, which is used to hide the keyboard.
167 val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
169 // Remove the formatting from the URL when the user is editing the text.
170 urlEditText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
171 if (hasFocus) { // The user is editing the URL text box.
172 // Remove the highlighting.
173 urlEditText.text.removeSpan(redColorSpan)
174 urlEditText.text.removeSpan(initialGrayColorSpan)
175 urlEditText.text.removeSpan(finalGrayColorSpan)
176 } else { // The user has stopped editing the URL text box.
177 // Hide the soft keyboard.
178 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
180 // Move to the beginning of the string.
181 urlEditText.setSelection(0)
183 // Reapply the highlighting.
188 // Set the refresh color scheme according to the theme.
189 if (currentThemeStatus == Configuration.UI_MODE_NIGHT_NO) {
190 swipeRefreshLayout.setColorSchemeResources(R.color.blue_700)
192 swipeRefreshLayout.setColorSchemeResources(R.color.violet_500)
195 // Initialize a color background typed value.
196 val colorBackgroundTypedValue = TypedValue()
198 // Get the color background from the theme.
199 theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
201 // Get the color background int from the typed value.
202 val colorBackgroundInt = colorBackgroundTypedValue.data
204 // Set the swipe refresh background color.
205 swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
207 // Populate the locale string.
208 val localeString = if (Build.VERSION.SDK_INT >= 24) { // SDK >= 24 has a list of locales.
209 // Get the list of locales.
210 val localeList = resources.configuration.locales
212 // Initialize a string builder to extract the locales from the list.
213 val localesStringBuilder = StringBuilder()
215 // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
218 // Populate the string builder with the contents of the locales list.
219 for (i in 0 until localeList.size()) {
220 // Append a comma if there is already an item in the string builder.
222 localesStringBuilder.append(",")
225 // Get the locale from the list.
226 val locale = localeList[i]
228 // Add the locale to the string. `locale` by default displays as `en_US`, but WebView uses the `en-US` format.
229 localesStringBuilder.append(locale.language)
230 localesStringBuilder.append("-")
231 localesStringBuilder.append(locale.country)
233 // If not the first locale, append `;q=0.x`, which drops by .1 for each removal from the main locale until q=0.1.
235 localesStringBuilder.append(";q=0.")
236 localesStringBuilder.append(q)
239 // Decrement `q` if it is greater than 1.
244 // Add a second entry for the language only portion of the locale.
245 localesStringBuilder.append(",")
246 localesStringBuilder.append(locale.language)
248 // Append `1;q=0.x`, which drops by .1 for each removal form the main locale until q=0.1.
249 localesStringBuilder.append(";q=0.")
250 localesStringBuilder.append(q)
252 // Decrement `q` if it is greater than 1.
258 // Store the populated string builder in the locale string.
259 localesStringBuilder.toString()
260 } else { // SDK < 24 only has a primary locale.
261 // Store the locale in the locale string.
262 Locale.getDefault().toString()
265 // Instantiate the proxy helper.
266 val proxyHelper = ProxyHelper()
268 // Get the current proxy.
269 val proxy = proxyHelper.getCurrentProxy(this)
271 // Make the progress bar visible.
272 progressBar.visibility = View.VISIBLE
274 // Set the progress bar to be indeterminate.
275 progressBar.isIndeterminate = true
277 // Update the layout.
278 updateLayout(currentUrl)
280 // Instantiate the WebView source factory.
281 val webViewSourceFactory: ViewModelProvider.Factory = WebViewSourceFactory(currentUrl, userAgent, localeString, proxy, contentResolver, MainWebViewActivity.executorService)
283 // Instantiate the WebView source view model class.
284 webViewSource = ViewModelProvider(this, webViewSourceFactory).get(WebViewSource::class.java)
286 // Create a source observer.
287 webViewSource.observeSource().observe(this, { sourceStringArray: Array<SpannableStringBuilder> ->
288 // Populate the text views. This can take a long time, and freezes the user interface, if the response body is particularly large.
289 requestHeadersTextView.text = sourceStringArray[0]
290 responseMessageTextView.text = sourceStringArray[1]
291 responseHeadersTextView.text = sourceStringArray[2]
292 responseBodyTextView.text = sourceStringArray[3]
294 // Hide the progress bar.
295 progressBar.isIndeterminate = false
296 progressBar.visibility = View.GONE
298 //Stop the swipe to refresh indicator if it is running
299 swipeRefreshLayout.isRefreshing = false
302 // Create an error observer.
303 webViewSource.observeErrors().observe(this, { errorString: String ->
304 // Display an error snackbar if the string is not `""`.
305 if (errorString != "") {
306 if (errorString.startsWith("javax.net.ssl.SSLHandshakeException")) {
307 // Instantiate the untrusted SSL certificate dialog.
308 val untrustedSslCertificateDialog = UntrustedSslCertificateDialog()
310 // Show the untrusted SSL certificate dialog.
311 untrustedSslCertificateDialog.show(supportFragmentManager, getString(R.string.invalid_certificate))
313 // Display a snackbar with the error message.
314 Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
319 // Implement swipe to refresh.
320 swipeRefreshLayout.setOnRefreshListener {
321 // Make the progress bar visible.
322 progressBar.visibility = View.VISIBLE
324 // Set the progress bar to be indeterminate.
325 progressBar.isIndeterminate = true
328 val urlString = urlEditText.text.toString()
330 // Update the layout.
331 updateLayout(urlString)
333 // Get the updated source.
334 webViewSource.updateSource(urlString, false)
337 // Set the go button on the keyboard to request new source data.
338 urlEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
339 // Request new source data if the enter key was pressed.
340 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
341 // Hide the soft keyboard.
342 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
344 // Remove the focus from the URL box.
345 urlEditText.clearFocus()
347 // Make the progress bar visible.
348 progressBar.visibility = View.VISIBLE
350 // Set the progress bar to be indeterminate.
351 progressBar.isIndeterminate = true
354 val urlString = urlEditText.text.toString()
356 // Update the layout.
357 updateLayout(urlString)
359 // Get the updated source.
360 webViewSource.updateSource(urlString, false)
362 // Consume the key press.
363 return@setOnKeyListener true
365 // Do not consume the key press.
366 return@setOnKeyListener false
371 override fun onCreateOptionsMenu(menu: Menu): Boolean {
373 menuInflater.inflate(R.menu.view_source_options_menu, menu)
379 override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
380 // Instantiate the about dialog fragment.
381 val aboutDialogFragment: DialogFragment = AboutViewSourceDialog()
383 // Show the about alert dialog.
384 aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
386 // Consume the event.
390 // This method must be named `goBack()` and must have a View argument to match the default back arrow in the app bar.
391 fun goBack(@Suppress("UNUSED_PARAMETER") view: View) {
393 NavUtils.navigateUpFromSameTask(this)
396 override fun loadAnyway() {
397 // Load the URL anyway.
398 webViewSource.updateSource(urlEditText.text.toString(), true)
401 private fun highlightUrlText() {
402 // Get a handle for the URL edit text.
403 val urlEditText = findViewById<EditText>(R.id.url_edittext)
405 // Get the URL string.
406 val urlString = urlEditText.text.toString()
408 // Highlight the URL according to the protocol.
409 if (urlString.startsWith("file://")) { // This is a file URL.
410 // De-emphasize only the protocol.
411 urlEditText.text.setSpan(initialGrayColorSpan, 0, 7, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
412 } else if (urlString.startsWith("content://")) {
413 // De-emphasize only the protocol.
414 urlEditText.text.setSpan(initialGrayColorSpan, 0, 10, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
415 } else { // This is a web URL.
416 // Get the index of the `/` immediately after the domain name.
417 val endOfDomainName = urlString.indexOf("/", urlString.indexOf("//") + 2)
420 val baseUrl = if (endOfDomainName > 0) { // There is at least one character after the base URL.
422 urlString.substring(0, endOfDomainName)
423 } else { // There are no characters after the base URL.
424 // Set the base URL to be the entire URL string.
428 // Get the index of the last `.` in the domain.
429 val lastDotIndex = baseUrl.lastIndexOf(".")
431 // Get the index of the penultimate `.` in the domain.
432 val penultimateDotIndex = baseUrl.lastIndexOf(".", lastDotIndex - 1)
434 // Markup the beginning of the URL.
435 if (urlString.startsWith("http://")) { // Highlight the protocol of connections that are not encrypted.
436 urlEditText.text.setSpan(redColorSpan, 0, 7, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
438 // De-emphasize subdomains.
439 if (penultimateDotIndex > 0) { // There is more than one subdomain in the domain name.
440 urlEditText.text.setSpan(initialGrayColorSpan, 7, penultimateDotIndex + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
442 } else if (urlString.startsWith("https://")) { // De-emphasize the protocol of connections that are encrypted.
443 if (penultimateDotIndex > 0) { // There is more than one subdomain in the domain name.
444 // De-emphasize the protocol and the additional subdomains.
445 urlEditText.text.setSpan(initialGrayColorSpan, 0, penultimateDotIndex + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
446 } else { // There is only one subdomain in the domain name.
447 // De-emphasize only the protocol.
448 urlEditText.text.setSpan(initialGrayColorSpan, 0, 8, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
452 // De-emphasize the text after the domain name.
453 if (endOfDomainName > 0) {
454 urlEditText.text.setSpan(finalGrayColorSpan, endOfDomainName, urlString.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
459 private fun updateLayout(urlString: String) {
460 if (urlString.startsWith("content://")) { // This is a content URL.
461 // Hide the unused text views.
462 requestHeadersTitleTextView.visibility = View.GONE
463 requestHeadersTextView.visibility = View.GONE
464 responseMessageTitleTextView.visibility = View.GONE
465 responseMessageTextView.visibility = View.GONE
467 // Change the text of the remaining title text views.
468 responseHeadersTitleTextView.setText(R.string.content_metadata)
469 responseBodyTitleTextView.setText(R.string.content_data)
470 } else { // This is not a content URL.
472 requestHeadersTitleTextView.visibility = View.VISIBLE
473 requestHeadersTextView.visibility = View.VISIBLE
474 responseMessageTitleTextView.visibility = View.VISIBLE
475 responseMessageTextView.visibility = View.VISIBLE
477 // Restore the text of the other title text views.
478 responseHeadersTitleTextView.setText(R.string.response_headers)
479 responseBodyTitleTextView.setText(R.string.response_body)