eb8ba87cc0a3147738c6c7276eb1e1fb7d10211f
[PrivacyBrowser.git] / app / src / main / java / com / stoutner / privacybrowser / activities / ViewSourceActivity.kt
1 /*
2  * Copyright © 2017-2021 Soren Stoutner <soren@stoutner.com>.
3  *
4  * This file is part of Privacy Browser <https://www.stoutner.com/privacy-browser>.
5  *
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.
10  *
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.
15  *
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/>.
18  */
19
20 package com.stoutner.privacybrowser.activities
21
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
39
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
48
49 import com.google.android.material.snackbar.Snackbar
50
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
58
59 import java.util.Locale
60
61 // Declare the public constants.
62 const val CURRENT_URL = "current_url"
63 const val USER_AGENT = "user_agent"
64
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
71
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
80
81     override fun onCreate(savedInstanceState: Bundle?) {
82         // Get a handle for the shared preferences.
83         val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
84
85         // Get the screenshot preference.
86         val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
87
88         // Disable screenshots if not allowed.
89         if (!allowScreenshots) {
90             window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
91         }
92
93         // Set the theme.
94         setTheme(R.style.PrivacyBrowser)
95
96         // Run the default commands.
97         super.onCreate(savedInstanceState)
98
99         // Get the launching intent
100         val intent = intent
101
102         // Get the information from the intent.
103         val currentUrl = intent.getStringExtra(CURRENT_URL)!!
104         val userAgent = intent.getStringExtra(USER_AGENT)!!
105
106         // Set the content view.
107         setContentView(R.layout.view_source_coordinatorlayout)
108
109         // Get a handle for the toolbar.
110         val toolbar = findViewById<Toolbar>(R.id.view_source_toolbar)
111
112         // Set the support action bar.
113         setSupportActionBar(toolbar)
114
115         // Get a handle for the action bar.
116         val actionBar = supportActionBar!!
117
118         // Add the custom layout to the action bar.
119         actionBar.setCustomView(R.layout.view_source_app_bar)
120
121         // Instruct the action bar to display a custom layout.
122         actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM
123
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)
136
137         // Populate the URL text box.
138         urlEditText.setText(currentUrl)
139
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))
145
146         // Get the current theme status.
147         val currentThemeStatus = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
148
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))
153         } else {
154             @Suppress("DEPRECATION")
155             ForegroundColorSpan(resources.getColor(R.color.red_900))
156         }
157
158         // Apply text highlighting to the URL.
159         highlightUrlText()
160
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)
163
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)
174
175                 // Move to the beginning of the string.
176                 urlEditText.setSelection(0)
177
178                 // Reapply the highlighting.
179                 highlightUrlText()
180             }
181         }
182
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)
186         } else {
187             swipeRefreshLayout.setColorSchemeResources(R.color.violet_500)
188         }
189
190         // Initialize a color background typed value.
191         val colorBackgroundTypedValue = TypedValue()
192
193         // Get the color background from the theme.
194         theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
195
196         // Get the color background int from the typed value.
197         val colorBackgroundInt = colorBackgroundTypedValue.data
198
199         // Set the swipe refresh background color.
200         swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
201
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
206
207             // Initialize a string builder to extract the locales from the list.
208             val localesStringBuilder = StringBuilder()
209
210             // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
211             var q = 10
212
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.
216                 if (i > 0) {
217                     localesStringBuilder.append(",")
218                 }
219
220                 // Get the locale from the list.
221                 val locale = localeList[i]
222
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)
227
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.
229                 if (q < 10) {
230                     localesStringBuilder.append(";q=0.")
231                     localesStringBuilder.append(q)
232                 }
233
234                 // Decrement `q` if it is greater than 1.
235                 if (q > 1) {
236                     q--
237                 }
238
239                 // Add a second entry for the language only portion of the locale.
240                 localesStringBuilder.append(",")
241                 localesStringBuilder.append(locale.language)
242
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)
246
247                 // Decrement `q` if it is greater than 1.
248                 if (q > 1) {
249                     q--
250                 }
251             }
252
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()
258         }
259
260         // Instantiate the proxy helper.
261         val proxyHelper = ProxyHelper()
262
263         // Get the current proxy.
264         val proxy = proxyHelper.getCurrentProxy(this)
265
266         // Make the progress bar visible.
267         progressBar.visibility = View.VISIBLE
268
269         // Set the progress bar to be indeterminate.
270         progressBar.isIndeterminate = true
271
272         // Update the layout.
273         updateLayout(currentUrl)
274
275         // Instantiate the WebView source factory.
276         val webViewSourceFactory: ViewModelProvider.Factory = WebViewSourceFactory(currentUrl, userAgent, localeString, proxy, contentResolver, MainWebViewActivity.executorService)
277
278         // Instantiate the WebView source view model class.
279         webViewSource = ViewModelProvider(this, webViewSourceFactory).get(WebViewSource::class.java)
280
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]
288
289             // Hide the progress bar.
290             progressBar.isIndeterminate = false
291             progressBar.visibility = View.GONE
292
293             //Stop the swipe to refresh indicator if it is running
294             swipeRefreshLayout.isRefreshing = false
295         })
296
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()
304
305                     // Show the untrusted SSL certificate dialog.
306                     untrustedSslCertificateDialog.show(supportFragmentManager, getString(R.string.invalid_certificate))
307                 } else {
308                     // Display a snackbar with the error message.
309                     Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
310                 }
311             }
312         })
313
314         // Implement swipe to refresh.
315         swipeRefreshLayout.setOnRefreshListener {
316             // Make the progress bar visible.
317             progressBar.visibility = View.VISIBLE
318
319             // Set the progress bar to be indeterminate.
320             progressBar.isIndeterminate = true
321
322             // Get the URL.
323             val urlString = urlEditText.text.toString()
324
325             // Update the layout.
326             updateLayout(urlString)
327
328             // Get the updated source.
329             webViewSource.updateSource(urlString, false)
330         }
331
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)
338
339                 // Remove the focus from the URL box.
340                 urlEditText.clearFocus()
341
342                 // Make the progress bar visible.
343                 progressBar.visibility = View.VISIBLE
344
345                 // Set the progress bar to be indeterminate.
346                 progressBar.isIndeterminate = true
347
348                 // Get the URL.
349                 val urlString = urlEditText.text.toString()
350
351                 // Update the layout.
352                 updateLayout(urlString)
353
354                 // Get the updated source.
355                 webViewSource.updateSource(urlString, false)
356
357                 // Consume the key press.
358                 return@setOnKeyListener true
359             } else {
360                 // Do not consume the key press.
361                 return@setOnKeyListener false
362             }
363         }
364     }
365
366     override fun onCreateOptionsMenu(menu: Menu): Boolean {
367         // Inflate the menu.
368         menuInflater.inflate(R.menu.view_source_options_menu, menu)
369
370         // Display the menu.
371         return true
372     }
373
374     override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
375         // Instantiate the about dialog fragment.
376         val aboutDialogFragment: DialogFragment = AboutViewSourceDialog()
377
378         // Show the about alert dialog.
379         aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
380
381         // Consume the event.
382         return true
383     }
384
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) {
387         // Go home.
388         NavUtils.navigateUpFromSameTask(this)
389     }
390
391     override fun loadAnyway() {
392         // Load the URL anyway.
393         webViewSource.updateSource(urlEditText.text.toString(), true)
394     }
395
396     private fun highlightUrlText() {
397         // Get a handle for the URL edit text.
398         val urlEditText = findViewById<EditText>(R.id.url_edittext)
399
400         // Get the URL string.
401         val urlString = urlEditText.text.toString()
402
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)
413
414             // Get the base URL.
415             val baseUrl = if (endOfDomainName > 0) {  // There is at least one character after the base URL.
416                 // Get 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.
420                 urlString
421             }
422
423             // Get the index of the last `.` in the domain.
424             val lastDotIndex = baseUrl.lastIndexOf(".")
425
426             // Get the index of the penultimate `.` in the domain.
427             val penultimateDotIndex = baseUrl.lastIndexOf(".", lastDotIndex - 1)
428
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)
432
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)
436                 }
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)
444                 }
445             }
446
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)
450             }
451         }
452     }
453
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
461
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.
466             // Show the views.
467             requestHeadersTitleTextView.visibility = View.VISIBLE
468             requestHeadersTextView.visibility = View.VISIBLE
469             responseMessageTitleTextView.visibility = View.VISIBLE
470             responseMessageTextView.visibility = View.VISIBLE
471
472             // Restore the text of the other title text views.
473             responseHeadersTitleTextView.setText(R.string.response_headers)
474             responseBodyTitleTextView.setText(R.string.response_body)
475         }
476     }
477 }