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 screenshot preference.
86 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
88 // Disable screenshots if not allowed.
89 if (!allowScreenshots) {
90 window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
94 setTheme(R.style.PrivacyBrowser)
96 // Run the default commands.
97 super.onCreate(savedInstanceState)
99 // Get the launching intent
102 // Get the information from the intent.
103 val currentUrl = intent.getStringExtra(CURRENT_URL)!!
104 val userAgent = intent.getStringExtra(USER_AGENT)!!
106 // Set the content view.
107 setContentView(R.layout.view_source_coordinatorlayout)
109 // Get a handle for the toolbar.
110 val toolbar = findViewById<Toolbar>(R.id.view_source_toolbar)
112 // Set the support action bar.
113 setSupportActionBar(toolbar)
115 // Get a handle for the action bar.
116 val actionBar = supportActionBar!!
118 // Add the custom layout to the action bar.
119 actionBar.setCustomView(R.layout.view_source_app_bar)
121 // Instruct the action bar to display a custom layout.
122 actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM
124 // Get handles for the views.
125 urlEditText = findViewById(R.id.url_edittext)
126 val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
127 val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.view_source_swiperefreshlayout)
128 requestHeadersTitleTextView = findViewById(R.id.request_headers_title_textview)
129 requestHeadersTextView = findViewById(R.id.request_headers_textview)
130 responseMessageTitleTextView = findViewById(R.id.response_message_title_textview)
131 responseMessageTextView = findViewById(R.id.response_message_textview)
132 responseHeadersTitleTextView = findViewById(R.id.response_headers_title_textivew)
133 val responseHeadersTextView = findViewById<TextView>(R.id.response_headers_textview)
134 responseBodyTitleTextView = findViewById(R.id.response_body_title_textview)
135 val responseBodyTextView = findViewById<TextView>(R.id.response_body_textview)
137 // Populate the URL text box.
138 urlEditText.setText(currentUrl)
140 // Initialize the gray foreground color spans for highlighting the URLs. The deprecated `getColor()` must be used until the minimum API >= 23.
141 @Suppress("DEPRECATION")
142 initialGrayColorSpan = ForegroundColorSpan(resources.getColor(R.color.gray_500))
143 @Suppress("DEPRECATION")
144 finalGrayColorSpan = ForegroundColorSpan(resources.getColor(R.color.gray_500))
146 // Get the current theme status.
147 val currentThemeStatus = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
149 // Set the red color span according to the theme. The deprecated `getColor()` must be used until the minimum API >= 23.
150 redColorSpan = if (currentThemeStatus == Configuration.UI_MODE_NIGHT_NO) {
151 @Suppress("DEPRECATION")
152 ForegroundColorSpan(resources.getColor(R.color.red_a700))
154 @Suppress("DEPRECATION")
155 ForegroundColorSpan(resources.getColor(R.color.red_900))
158 // Apply text highlighting to the URL.
161 // Get a handle for the input method manager, which is used to hide the keyboard.
162 val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
164 // Remove the formatting from the URL when the user is editing the text.
165 urlEditText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
166 if (hasFocus) { // The user is editing the URL text box.
167 // Remove the highlighting.
168 urlEditText.text.removeSpan(redColorSpan)
169 urlEditText.text.removeSpan(initialGrayColorSpan)
170 urlEditText.text.removeSpan(finalGrayColorSpan)
171 } else { // The user has stopped editing the URL text box.
172 // Hide the soft keyboard.
173 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
175 // Move to the beginning of the string.
176 urlEditText.setSelection(0)
178 // Reapply the highlighting.
183 // Set the refresh color scheme according to the theme.
184 if (currentThemeStatus == Configuration.UI_MODE_NIGHT_NO) {
185 swipeRefreshLayout.setColorSchemeResources(R.color.blue_700)
187 swipeRefreshLayout.setColorSchemeResources(R.color.violet_500)
190 // Initialize a color background typed value.
191 val colorBackgroundTypedValue = TypedValue()
193 // Get the color background from the theme.
194 theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
196 // Get the color background int from the typed value.
197 val colorBackgroundInt = colorBackgroundTypedValue.data
199 // Set the swipe refresh background color.
200 swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
202 // Populate the locale string.
203 val localeString = if (Build.VERSION.SDK_INT >= 24) { // SDK >= 24 has a list of locales.
204 // Get the list of locales.
205 val localeList = resources.configuration.locales
207 // Initialize a string builder to extract the locales from the list.
208 val localesStringBuilder = StringBuilder()
210 // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
213 // Populate the string builder with the contents of the locales list.
214 for (i in 0 until localeList.size()) {
215 // Append a comma if there is already an item in the string builder.
217 localesStringBuilder.append(",")
220 // Get the locale from the list.
221 val locale = localeList[i]
223 // Add the locale to the string. `locale` by default displays as `en_US`, but WebView uses the `en-US` format.
224 localesStringBuilder.append(locale.language)
225 localesStringBuilder.append("-")
226 localesStringBuilder.append(locale.country)
228 // If not the first locale, append `;q=0.x`, which drops by .1 for each removal from the main locale until q=0.1.
230 localesStringBuilder.append(";q=0.")
231 localesStringBuilder.append(q)
234 // Decrement `q` if it is greater than 1.
239 // Add a second entry for the language only portion of the locale.
240 localesStringBuilder.append(",")
241 localesStringBuilder.append(locale.language)
243 // Append `1;q=0.x`, which drops by .1 for each removal form the main locale until q=0.1.
244 localesStringBuilder.append(";q=0.")
245 localesStringBuilder.append(q)
247 // Decrement `q` if it is greater than 1.
253 // Store the populated string builder in the locale string.
254 localesStringBuilder.toString()
255 } else { // SDK < 24 only has a primary locale.
256 // Store the locale in the locale string.
257 Locale.getDefault().toString()
260 // Instantiate the proxy helper.
261 val proxyHelper = ProxyHelper()
263 // Get the current proxy.
264 val proxy = proxyHelper.getCurrentProxy(this)
266 // Make the progress bar visible.
267 progressBar.visibility = View.VISIBLE
269 // Set the progress bar to be indeterminate.
270 progressBar.isIndeterminate = true
272 // Update the layout.
273 updateLayout(currentUrl)
275 // Instantiate the WebView source factory.
276 val webViewSourceFactory: ViewModelProvider.Factory = WebViewSourceFactory(currentUrl, userAgent, localeString, proxy, contentResolver, MainWebViewActivity.executorService)
278 // Instantiate the WebView source view model class.
279 webViewSource = ViewModelProvider(this, webViewSourceFactory).get(WebViewSource::class.java)
281 // Create a source observer.
282 webViewSource.observeSource().observe(this, { sourceStringArray: Array<SpannableStringBuilder> ->
283 // Populate the text views. This can take a long time, and freezes the user interface, if the response body is particularly large.
284 requestHeadersTextView.text = sourceStringArray[0]
285 responseMessageTextView.text = sourceStringArray[1]
286 responseHeadersTextView.text = sourceStringArray[2]
287 responseBodyTextView.text = sourceStringArray[3]
289 // Hide the progress bar.
290 progressBar.isIndeterminate = false
291 progressBar.visibility = View.GONE
293 //Stop the swipe to refresh indicator if it is running
294 swipeRefreshLayout.isRefreshing = false
297 // Create an error observer.
298 webViewSource.observeErrors().observe(this, { errorString: String ->
299 // Display an error snackbar if the string is not `""`.
300 if (errorString != "") {
301 if (errorString.startsWith("javax.net.ssl.SSLHandshakeException")) {
302 // Instantiate the untrusted SSL certificate dialog.
303 val untrustedSslCertificateDialog = UntrustedSslCertificateDialog()
305 // Show the untrusted SSL certificate dialog.
306 untrustedSslCertificateDialog.show(supportFragmentManager, getString(R.string.invalid_certificate))
308 // Display a snackbar with the error message.
309 Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
314 // Implement swipe to refresh.
315 swipeRefreshLayout.setOnRefreshListener {
316 // Make the progress bar visible.
317 progressBar.visibility = View.VISIBLE
319 // Set the progress bar to be indeterminate.
320 progressBar.isIndeterminate = true
323 val urlString = urlEditText.text.toString()
325 // Update the layout.
326 updateLayout(urlString)
328 // Get the updated source.
329 webViewSource.updateSource(urlString, false)
332 // Set the go button on the keyboard to request new source data.
333 urlEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
334 // Request new source data if the enter key was pressed.
335 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
336 // Hide the soft keyboard.
337 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
339 // Remove the focus from the URL box.
340 urlEditText.clearFocus()
342 // Make the progress bar visible.
343 progressBar.visibility = View.VISIBLE
345 // Set the progress bar to be indeterminate.
346 progressBar.isIndeterminate = true
349 val urlString = urlEditText.text.toString()
351 // Update the layout.
352 updateLayout(urlString)
354 // Get the updated source.
355 webViewSource.updateSource(urlString, false)
357 // Consume the key press.
358 return@setOnKeyListener true
360 // Do not consume the key press.
361 return@setOnKeyListener false
366 override fun onCreateOptionsMenu(menu: Menu): Boolean {
368 menuInflater.inflate(R.menu.view_source_options_menu, menu)
374 override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
375 // Instantiate the about dialog fragment.
376 val aboutDialogFragment: DialogFragment = AboutViewSourceDialog()
378 // Show the about alert dialog.
379 aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
381 // Consume the event.
385 // This method must be named `goBack()` and must have a View argument to match the default back arrow in the app bar.
386 fun goBack(@Suppress("UNUSED_PARAMETER") view: View) {
388 NavUtils.navigateUpFromSameTask(this)
391 override fun loadAnyway() {
392 // Load the URL anyway.
393 webViewSource.updateSource(urlEditText.text.toString(), true)
396 private fun highlightUrlText() {
397 // Get a handle for the URL edit text.
398 val urlEditText = findViewById<EditText>(R.id.url_edittext)
400 // Get the URL string.
401 val urlString = urlEditText.text.toString()
403 // Highlight the URL according to the protocol.
404 if (urlString.startsWith("file://")) { // This is a file URL.
405 // De-emphasize only the protocol.
406 urlEditText.text.setSpan(initialGrayColorSpan, 0, 7, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
407 } else if (urlString.startsWith("content://")) {
408 // De-emphasize only the protocol.
409 urlEditText.text.setSpan(initialGrayColorSpan, 0, 10, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
410 } else { // This is a web URL.
411 // Get the index of the `/` immediately after the domain name.
412 val endOfDomainName = urlString.indexOf("/", urlString.indexOf("//") + 2)
415 val baseUrl = if (endOfDomainName > 0) { // There is at least one character after the base URL.
417 urlString.substring(0, endOfDomainName)
418 } else { // There are no characters after the base URL.
419 // Set the base URL to be the entire URL string.
423 // Get the index of the last `.` in the domain.
424 val lastDotIndex = baseUrl.lastIndexOf(".")
426 // Get the index of the penultimate `.` in the domain.
427 val penultimateDotIndex = baseUrl.lastIndexOf(".", lastDotIndex - 1)
429 // Markup the beginning of the URL.
430 if (urlString.startsWith("http://")) { // Highlight the protocol of connections that are not encrypted.
431 urlEditText.text.setSpan(redColorSpan, 0, 7, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
433 // De-emphasize subdomains.
434 if (penultimateDotIndex > 0) { // There is more than one subdomain in the domain name.
435 urlEditText.text.setSpan(initialGrayColorSpan, 7, penultimateDotIndex + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
437 } else if (urlString.startsWith("https://")) { // De-emphasize the protocol of connections that are encrypted.
438 if (penultimateDotIndex > 0) { // There is more than one subdomain in the domain name.
439 // De-emphasize the protocol and the additional subdomains.
440 urlEditText.text.setSpan(initialGrayColorSpan, 0, penultimateDotIndex + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
441 } else { // There is only one subdomain in the domain name.
442 // De-emphasize only the protocol.
443 urlEditText.text.setSpan(initialGrayColorSpan, 0, 8, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
447 // De-emphasize the text after the domain name.
448 if (endOfDomainName > 0) {
449 urlEditText.text.setSpan(finalGrayColorSpan, endOfDomainName, urlString.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
454 private fun updateLayout(urlString: String) {
455 if (urlString.startsWith("content://")) { // This is a content URL.
456 // Hide the unused text views.
457 requestHeadersTitleTextView.visibility = View.GONE
458 requestHeadersTextView.visibility = View.GONE
459 responseMessageTitleTextView.visibility = View.GONE
460 responseMessageTextView.visibility = View.GONE
462 // Change the text of the remaining title text views.
463 responseHeadersTitleTextView.setText(R.string.content_metadata)
464 responseBodyTitleTextView.setText(R.string.content_data)
465 } else { // This is not a content URL.
467 requestHeadersTitleTextView.visibility = View.VISIBLE
468 requestHeadersTextView.visibility = View.VISIBLE
469 responseMessageTitleTextView.visibility = View.VISIBLE
470 responseMessageTextView.visibility = View.VISIBLE
472 // Restore the text of the other title text views.
473 responseHeadersTitleTextView.setText(R.string.response_headers)
474 responseBodyTitleTextView.setText(R.string.response_body)