2 * Copyright 2017-2022 Soren Stoutner <soren@stoutner.com>.
4 * This file is part of Privacy Browser Android <https://www.stoutner.com/privacy-browser-android>.
6 * Privacy Browser Android 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 Android 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 Android. If not, see <http://www.gnu.org/licenses/>.
20 package com.stoutner.privacybrowser.activities
22 import android.os.Build
23 import android.os.Bundle
24 import android.text.SpannableStringBuilder
25 import android.text.Spanned
26 import android.text.style.ForegroundColorSpan
27 import android.util.TypedValue
28 import android.view.KeyEvent
29 import android.view.Menu
30 import android.view.MenuItem
31 import android.view.View
32 import android.view.View.OnFocusChangeListener
33 import android.view.WindowManager
34 import android.view.inputmethod.InputMethodManager
35 import android.widget.EditText
36 import android.widget.ProgressBar
37 import android.widget.TextView
39 import androidx.appcompat.app.ActionBar
40 import androidx.appcompat.app.AppCompatActivity
41 import androidx.appcompat.widget.Toolbar
42 import androidx.core.app.NavUtils
43 import androidx.fragment.app.DialogFragment
44 import androidx.lifecycle.ViewModelProvider
45 import androidx.preference.PreferenceManager
46 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
48 import com.google.android.material.snackbar.Snackbar
50 import com.stoutner.privacybrowser.R
51 import com.stoutner.privacybrowser.dialogs.AboutViewSourceDialog
52 import com.stoutner.privacybrowser.dialogs.UntrustedSslCertificateDialog
53 import com.stoutner.privacybrowser.dialogs.UntrustedSslCertificateDialog.UntrustedSslCertificateListener
54 import com.stoutner.privacybrowser.helpers.ProxyHelper
55 import com.stoutner.privacybrowser.viewmodelfactories.WebViewSourceFactory
56 import com.stoutner.privacybrowser.viewmodels.WebViewSource
58 import java.util.Locale
60 // Define the public constants.
61 const val CURRENT_URL = "current_url"
62 const val USER_AGENT = "user_agent"
64 class ViewSourceActivity: AppCompatActivity(), UntrustedSslCertificateListener {
65 // Declare the class variables.
66 private lateinit var initialGrayColorSpan: ForegroundColorSpan
67 private lateinit var finalGrayColorSpan: ForegroundColorSpan
68 private lateinit var redColorSpan: ForegroundColorSpan
69 private lateinit var webViewSource: WebViewSource
71 // Declare the class views.
72 private lateinit var urlEditText: EditText
73 private lateinit var requestHeadersTitleTextView: TextView
74 private lateinit var requestHeadersTextView: TextView
75 private lateinit var responseMessageTitleTextView: TextView
76 private lateinit var responseMessageTextView: TextView
77 private lateinit var responseHeadersTitleTextView: TextView
78 private lateinit var responseBodyTitleTextView: TextView
80 override fun onCreate(savedInstanceState: Bundle?) {
81 // Get a handle for the shared preferences.
82 val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
84 // Get the preferences.
85 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
86 val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
88 // Disable screenshots if not allowed.
89 if (!allowScreenshots) {
90 window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
93 // Run the default commands.
94 super.onCreate(savedInstanceState)
96 // Get the launching intent
99 // Get the information from the intent.
100 val currentUrl = intent.getStringExtra(CURRENT_URL)!!
101 val userAgent = intent.getStringExtra(USER_AGENT)!!
103 // Set the content view.
105 setContentView(R.layout.view_source_bottom_appbar)
107 setContentView(R.layout.view_source_top_appbar)
110 // Get a handle for the toolbar.
111 val toolbar = findViewById<Toolbar>(R.id.view_source_toolbar)
113 // Set the support action bar.
114 setSupportActionBar(toolbar)
116 // Get a handle for the action bar.
117 val actionBar = supportActionBar!!
119 // Add the custom layout to the action bar.
120 actionBar.setCustomView(R.layout.view_source_appbar_custom_view)
122 // Instruct the action bar to display a custom layout.
123 actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM
125 // Get handles for the views.
126 urlEditText = findViewById(R.id.url_edittext)
127 val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
128 val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.view_source_swiperefreshlayout)
129 requestHeadersTitleTextView = findViewById(R.id.request_headers_title_textview)
130 requestHeadersTextView = findViewById(R.id.request_headers_textview)
131 responseMessageTitleTextView = findViewById(R.id.response_message_title_textview)
132 responseMessageTextView = findViewById(R.id.response_message_textview)
133 responseHeadersTitleTextView = findViewById(R.id.response_headers_title_textview)
134 val responseHeadersTextView = findViewById<TextView>(R.id.response_headers_textview)
135 responseBodyTitleTextView = findViewById(R.id.response_body_title_textview)
136 val responseBodyTextView = findViewById<TextView>(R.id.response_body_textview)
138 // Populate the URL text box.
139 urlEditText.setText(currentUrl)
141 // Initialize the gray foreground color spans for highlighting the URLs.
142 initialGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
143 finalGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
144 redColorSpan = ForegroundColorSpan(getColor(R.color.red_text))
146 // Apply text highlighting to the URL.
149 // Get a handle for the input method manager, which is used to hide the keyboard.
150 val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
152 // Remove the formatting from the URL when the user is editing the text.
153 urlEditText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
154 if (hasFocus) { // The user is editing the URL text box.
155 // Remove the highlighting.
156 urlEditText.text.removeSpan(redColorSpan)
157 urlEditText.text.removeSpan(initialGrayColorSpan)
158 urlEditText.text.removeSpan(finalGrayColorSpan)
159 } else { // The user has stopped editing the URL text box.
160 // Hide the soft keyboard.
161 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
163 // Move to the beginning of the string.
164 urlEditText.setSelection(0)
166 // Reapply the highlighting.
171 // Set the refresh color scheme according to the theme.
172 swipeRefreshLayout.setColorSchemeResources(R.color.blue_text)
174 // Initialize a color background typed value.
175 val colorBackgroundTypedValue = TypedValue()
177 // Get the color background from the theme.
178 theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
180 // Get the color background int from the typed value.
181 val colorBackgroundInt = colorBackgroundTypedValue.data
183 // Set the swipe refresh background color.
184 swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
186 // Populate the locale string.
187 val localeString = if (Build.VERSION.SDK_INT >= 24) { // SDK >= 24 has a list of locales.
188 // Get the list of locales.
189 val localeList = resources.configuration.locales
191 // Initialize a string builder to extract the locales from the list.
192 val localesStringBuilder = StringBuilder()
194 // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
197 // Populate the string builder with the contents of the locales list.
198 for (i in 0 until localeList.size()) {
199 // Append a comma if there is already an item in the string builder.
201 localesStringBuilder.append(",")
204 // Get the locale from the list.
205 val locale = localeList[i]
207 // Add the locale to the string. `locale` by default displays as `en_US`, but WebView uses the `en-US` format.
208 localesStringBuilder.append(locale.language)
209 localesStringBuilder.append("-")
210 localesStringBuilder.append(locale.country)
212 // If not the first locale, append `;q=0.x`, which drops by .1 for each removal from the main locale until q=0.1.
214 localesStringBuilder.append(";q=0.")
215 localesStringBuilder.append(q)
218 // Decrement `q` if it is greater than 1.
223 // Add a second entry for the language only portion of the locale.
224 localesStringBuilder.append(",")
225 localesStringBuilder.append(locale.language)
227 // Append `1;q=0.x`, which drops by .1 for each removal form the main locale until q=0.1.
228 localesStringBuilder.append(";q=0.")
229 localesStringBuilder.append(q)
231 // Decrement `q` if it is greater than 1.
237 // Store the populated string builder in the locale string.
238 localesStringBuilder.toString()
239 } else { // SDK < 24 only has a primary locale.
240 // Store the locale in the locale string.
241 Locale.getDefault().toString()
244 // Instantiate the proxy helper.
245 val proxyHelper = ProxyHelper()
247 // Get the current proxy.
248 val proxy = proxyHelper.getCurrentProxy(this)
250 // Make the progress bar visible.
251 progressBar.visibility = View.VISIBLE
253 // Set the progress bar to be indeterminate.
254 progressBar.isIndeterminate = true
256 // Update the layout.
257 updateLayout(currentUrl)
259 // Instantiate the WebView source factory.
260 val webViewSourceFactory: ViewModelProvider.Factory = WebViewSourceFactory(currentUrl, userAgent, localeString, proxy, contentResolver, MainWebViewActivity.executorService)
262 // Instantiate the WebView source view model class.
263 webViewSource = ViewModelProvider(this, webViewSourceFactory)[WebViewSource::class.java]
265 // Create a source observer.
266 webViewSource.observeSource().observe(this) { sourceStringArray: Array<SpannableStringBuilder> ->
267 // Populate the text views. This can take a long time, and freezes the user interface, if the response body is particularly large.
268 requestHeadersTextView.text = sourceStringArray[0]
269 responseMessageTextView.text = sourceStringArray[1]
270 responseHeadersTextView.text = sourceStringArray[2]
271 responseBodyTextView.text = sourceStringArray[3]
273 // Hide the progress bar.
274 progressBar.isIndeterminate = false
275 progressBar.visibility = View.GONE
277 //Stop the swipe to refresh indicator if it is running
278 swipeRefreshLayout.isRefreshing = false
281 // Create an error observer.
282 webViewSource.observeErrors().observe(this) { errorString: String ->
283 // Display an error snackbar if the string is not `""`.
284 if (errorString != "") {
285 if (errorString.startsWith("javax.net.ssl.SSLHandshakeException")) {
286 // Instantiate the untrusted SSL certificate dialog.
287 val untrustedSslCertificateDialog = UntrustedSslCertificateDialog()
289 // Show the untrusted SSL certificate dialog.
290 untrustedSslCertificateDialog.show(supportFragmentManager, getString(R.string.invalid_certificate))
292 // Display a snackbar with the error message.
293 Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
298 // Implement swipe to refresh.
299 swipeRefreshLayout.setOnRefreshListener {
300 // Make the progress bar visible.
301 progressBar.visibility = View.VISIBLE
303 // Set the progress bar to be indeterminate.
304 progressBar.isIndeterminate = true
307 val urlString = urlEditText.text.toString()
309 // Update the layout.
310 updateLayout(urlString)
312 // Get the updated source.
313 webViewSource.updateSource(urlString, false)
316 // Set the go button on the keyboard to request new source data.
317 urlEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
318 // Request new source data if the enter key was pressed.
319 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
320 // Hide the soft keyboard.
321 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
323 // Remove the focus from the URL box.
324 urlEditText.clearFocus()
326 // Make the progress bar visible.
327 progressBar.visibility = View.VISIBLE
329 // Set the progress bar to be indeterminate.
330 progressBar.isIndeterminate = true
333 val urlString = urlEditText.text.toString()
335 // Update the layout.
336 updateLayout(urlString)
338 // Get the updated source.
339 webViewSource.updateSource(urlString, false)
341 // Consume the key press.
342 return@setOnKeyListener true
344 // Do not consume the key press.
345 return@setOnKeyListener false
350 override fun onCreateOptionsMenu(menu: Menu): Boolean {
352 menuInflater.inflate(R.menu.view_source_options_menu, menu)
358 override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
359 // Instantiate the about dialog fragment.
360 val aboutDialogFragment: DialogFragment = AboutViewSourceDialog()
362 // Show the about alert dialog.
363 aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
365 // Consume the event.
369 // This method must be named `goBack()` and must have a View argument to match the default back arrow in the app bar.
370 fun goBack(@Suppress("UNUSED_PARAMETER") view: View) {
372 NavUtils.navigateUpFromSameTask(this)
375 override fun loadAnyway() {
376 // Load the URL anyway.
377 webViewSource.updateSource(urlEditText.text.toString(), true)
380 private fun highlightUrlText() {
381 // Get a handle for the URL edit text.
382 val urlEditText = findViewById<EditText>(R.id.url_edittext)
384 // Get the URL string.
385 val urlString = urlEditText.text.toString()
387 // Highlight the URL according to the protocol.
388 if (urlString.startsWith("file://")) { // This is a file URL.
389 // De-emphasize only the protocol.
390 urlEditText.text.setSpan(initialGrayColorSpan, 0, 7, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
391 } else if (urlString.startsWith("content://")) {
392 // De-emphasize only the protocol.
393 urlEditText.text.setSpan(initialGrayColorSpan, 0, 10, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
394 } else { // This is a web URL.
395 // Get the index of the `/` immediately after the domain name.
396 val endOfDomainName = urlString.indexOf("/", urlString.indexOf("//") + 2)
399 val baseUrl = if (endOfDomainName > 0) { // There is at least one character after the base URL.
401 urlString.substring(0, endOfDomainName)
402 } else { // There are no characters after the base URL.
403 // Set the base URL to be the entire URL string.
407 // Get the index of the last `.` in the domain.
408 val lastDotIndex = baseUrl.lastIndexOf(".")
410 // Get the index of the penultimate `.` in the domain.
411 val penultimateDotIndex = baseUrl.lastIndexOf(".", lastDotIndex - 1)
413 // Markup the beginning of the URL.
414 if (urlString.startsWith("http://")) { // Highlight the protocol of connections that are not encrypted.
415 urlEditText.text.setSpan(redColorSpan, 0, 7, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
417 // De-emphasize subdomains.
418 if (penultimateDotIndex > 0) { // There is more than one subdomain in the domain name.
419 urlEditText.text.setSpan(initialGrayColorSpan, 7, penultimateDotIndex + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
421 } else if (urlString.startsWith("https://")) { // De-emphasize the protocol of connections that are encrypted.
422 if (penultimateDotIndex > 0) { // There is more than one subdomain in the domain name.
423 // De-emphasize the protocol and the additional subdomains.
424 urlEditText.text.setSpan(initialGrayColorSpan, 0, penultimateDotIndex + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
425 } else { // There is only one subdomain in the domain name.
426 // De-emphasize only the protocol.
427 urlEditText.text.setSpan(initialGrayColorSpan, 0, 8, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
431 // De-emphasize the text after the domain name.
432 if (endOfDomainName > 0) {
433 urlEditText.text.setSpan(finalGrayColorSpan, endOfDomainName, urlString.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
438 private fun updateLayout(urlString: String) {
439 if (urlString.startsWith("content://")) { // This is a content URL.
440 // Hide the unused text views.
441 requestHeadersTitleTextView.visibility = View.GONE
442 requestHeadersTextView.visibility = View.GONE
443 responseMessageTitleTextView.visibility = View.GONE
444 responseMessageTextView.visibility = View.GONE
446 // Change the text of the remaining title text views.
447 responseHeadersTitleTextView.setText(R.string.content_metadata)
448 responseBodyTitleTextView.setText(R.string.content_data)
449 } else { // This is not a content URL.
451 requestHeadersTitleTextView.visibility = View.VISIBLE
452 requestHeadersTextView.visibility = View.VISIBLE
453 responseMessageTitleTextView.visibility = View.VISIBLE
454 responseMessageTextView.visibility = View.VISIBLE
456 // Restore the text of the other title text views.
457 responseHeadersTitleTextView.setText(R.string.response_headers)
458 responseBodyTitleTextView.setText(R.string.response_body)