Handle content:// URLs in View Source. https://redmine.stoutner.com/issues/361
[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.helpers.ProxyHelper
54 import com.stoutner.privacybrowser.viewmodelfactories.WebViewSourceFactory
55 import com.stoutner.privacybrowser.viewmodels.WebViewSource
56
57 import java.util.Locale
58
59 // Declare the public constants.
60 const val CURRENT_URL = "current_url"
61 const val USER_AGENT = "user_agent"
62
63 class ViewSourceActivity: AppCompatActivity() {
64     // Declare the class variables.
65     private lateinit var initialGrayColorSpan: ForegroundColorSpan
66     private lateinit var finalGrayColorSpan: ForegroundColorSpan
67     private lateinit var redColorSpan: ForegroundColorSpan
68
69     // Declare the class views.
70     private lateinit var requestHeadersTitleTextView: TextView
71     private lateinit var requestHeadersTextView: TextView
72     private lateinit var responseMessageTitleTextView: TextView
73     private lateinit var responseMessageTextView: TextView
74     private lateinit var responseHeadersTitleTextView: TextView
75     private lateinit var responseBodyTitleTextView: TextView
76
77     override fun onCreate(savedInstanceState: Bundle?) {
78         // Get a handle for the shared preferences.
79         val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
80
81         // Get the screenshot preference.
82         val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
83
84         // Disable screenshots if not allowed.
85         if (!allowScreenshots) {
86             window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
87         }
88
89         // Set the theme.
90         setTheme(R.style.PrivacyBrowser)
91
92         // Run the default commands.
93         super.onCreate(savedInstanceState)
94
95         // Get the launching intent
96         val intent = intent
97
98         // Get the information from the intent.
99         val currentUrl = intent.getStringExtra(CURRENT_URL)!!
100         val userAgent = intent.getStringExtra(USER_AGENT)!!
101
102         // Set the content view.
103         setContentView(R.layout.view_source_coordinatorlayout)
104
105         // Get a handle for the toolbar.
106         val toolbar = findViewById<Toolbar>(R.id.view_source_toolbar)
107
108         // Set the support action bar.
109         setSupportActionBar(toolbar)
110
111         // Get a handle for the action bar.
112         val actionBar = supportActionBar!!
113
114         // Add the custom layout to the action bar.
115         actionBar.setCustomView(R.layout.view_source_app_bar)
116
117         // Instruct the action bar to display a custom layout.
118         actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM
119
120         // Get handles for the views.
121         val urlEditText = findViewById<EditText>(R.id.url_edittext)
122         val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
123         val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.view_source_swiperefreshlayout)
124         requestHeadersTitleTextView = findViewById(R.id.request_headers_title_textview)
125         requestHeadersTextView = findViewById(R.id.request_headers_textview)
126         responseMessageTitleTextView = findViewById(R.id.response_message_title_textview)
127         responseMessageTextView = findViewById(R.id.response_message_textview)
128         responseHeadersTitleTextView = findViewById(R.id.response_headers_title_textivew)
129         val responseHeadersTextView = findViewById<TextView>(R.id.response_headers_textview)
130         responseBodyTitleTextView = findViewById(R.id.response_body_title_textview)
131         val responseBodyTextView = findViewById<TextView>(R.id.response_body_textview)
132
133         // Populate the URL text box.
134         urlEditText.setText(currentUrl)
135
136         // Initialize the gray foreground color spans for highlighting the URLs.  The deprecated `getColor()` must be used until the minimum API >= 23.
137         @Suppress("DEPRECATION")
138         initialGrayColorSpan = ForegroundColorSpan(resources.getColor(R.color.gray_500))
139         @Suppress("DEPRECATION")
140         finalGrayColorSpan = ForegroundColorSpan(resources.getColor(R.color.gray_500))
141
142         // Get the current theme status.
143         val currentThemeStatus = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
144
145         // Set the red color span according to the theme.  The deprecated `getColor()` must be used until the minimum API >= 23.
146         redColorSpan = if (currentThemeStatus == Configuration.UI_MODE_NIGHT_NO) {
147             @Suppress("DEPRECATION")
148             ForegroundColorSpan(resources.getColor(R.color.red_a700))
149         } else {
150             @Suppress("DEPRECATION")
151             ForegroundColorSpan(resources.getColor(R.color.red_900))
152         }
153
154         // Apply text highlighting to the URL.
155         highlightUrlText()
156
157         // Get a handle for the input method manager, which is used to hide the keyboard.
158         val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
159
160         // Remove the formatting from the URL when the user is editing the text.
161         urlEditText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
162             if (hasFocus) {  // The user is editing the URL text box.
163                 // Remove the highlighting.
164                 urlEditText.text.removeSpan(redColorSpan)
165                 urlEditText.text.removeSpan(initialGrayColorSpan)
166                 urlEditText.text.removeSpan(finalGrayColorSpan)
167             } else {  // The user has stopped editing the URL text box.
168                 // Hide the soft keyboard.
169                 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
170
171                 // Move to the beginning of the string.
172                 urlEditText.setSelection(0)
173
174                 // Reapply the highlighting.
175                 highlightUrlText()
176             }
177         }
178
179         // Set the refresh color scheme according to the theme.
180         if (currentThemeStatus == Configuration.UI_MODE_NIGHT_NO) {
181             swipeRefreshLayout.setColorSchemeResources(R.color.blue_700)
182         } else {
183             swipeRefreshLayout.setColorSchemeResources(R.color.violet_500)
184         }
185
186         // Initialize a color background typed value.
187         val colorBackgroundTypedValue = TypedValue()
188
189         // Get the color background from the theme.
190         theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
191
192         // Get the color background int from the typed value.
193         val colorBackgroundInt = colorBackgroundTypedValue.data
194
195         // Set the swipe refresh background color.
196         swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
197
198         // Populate the locale string.
199         val localeString = if (Build.VERSION.SDK_INT >= 24) {  // SDK >= 24 has a list of locales.
200             // Get the list of locales.
201             val localeList = resources.configuration.locales
202
203             // Initialize a string builder to extract the locales from the list.
204             val localesStringBuilder = StringBuilder()
205
206             // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
207             var q = 10
208
209             // Populate the string builder with the contents of the locales list.
210             for (i in 0 until localeList.size()) {
211                 // Append a comma if there is already an item in the string builder.
212                 if (i > 0) {
213                     localesStringBuilder.append(",")
214                 }
215
216                 // Get the locale from the list.
217                 val locale = localeList[i]
218
219                 // Add the locale to the string.  `locale` by default displays as `en_US`, but WebView uses the `en-US` format.
220                 localesStringBuilder.append(locale.language)
221                 localesStringBuilder.append("-")
222                 localesStringBuilder.append(locale.country)
223
224                 // If not the first locale, append `;q=0.x`, which drops by .1 for each removal from the main locale until q=0.1.
225                 if (q < 10) {
226                     localesStringBuilder.append(";q=0.")
227                     localesStringBuilder.append(q)
228                 }
229
230                 // Decrement `q` if it is greater than 1.
231                 if (q > 1) {
232                     q--
233                 }
234
235                 // Add a second entry for the language only portion of the locale.
236                 localesStringBuilder.append(",")
237                 localesStringBuilder.append(locale.language)
238
239                 // Append `1;q=0.x`, which drops by .1 for each removal form the main locale until q=0.1.
240                 localesStringBuilder.append(";q=0.")
241                 localesStringBuilder.append(q)
242
243                 // Decrement `q` if it is greater than 1.
244                 if (q > 1) {
245                     q--
246                 }
247             }
248
249             // Store the populated string builder in the locale string.
250             localesStringBuilder.toString()
251         } else {  // SDK < 24 only has a primary locale.
252             // Store the locale in the locale string.
253             Locale.getDefault().toString()
254         }
255
256         // Instantiate the proxy helper.
257         val proxyHelper = ProxyHelper()
258
259         // Get the current proxy.
260         val proxy = proxyHelper.getCurrentProxy(this)
261
262         // Make the progress bar visible.
263         progressBar.visibility = View.VISIBLE
264
265         // Set the progress bar to be indeterminate.
266         progressBar.isIndeterminate = true
267
268         // Update the layout.
269         updateLayout(currentUrl)
270
271         // Instantiate the WebView source factory.
272         val webViewSourceFactory: ViewModelProvider.Factory = WebViewSourceFactory(currentUrl, userAgent, localeString, proxy, contentResolver, MainWebViewActivity.executorService)
273
274         // Instantiate the WebView source view model class.
275         val webViewSource = ViewModelProvider(this, webViewSourceFactory).get(WebViewSource::class.java)
276
277         // Create a source observer.
278         webViewSource.observeSource().observe(this, { sourceStringArray: Array<SpannableStringBuilder> ->
279             // Populate the text views.  This can take a long time, and freezes the user interface, if the response body is particularly large.
280             requestHeadersTextView.text = sourceStringArray[0]
281             responseMessageTextView.text = sourceStringArray[1]
282             responseHeadersTextView.text = sourceStringArray[2]
283             responseBodyTextView.text = sourceStringArray[3]
284
285             // Hide the progress bar.
286             progressBar.isIndeterminate = false
287             progressBar.visibility = View.GONE
288
289             //Stop the swipe to refresh indicator if it is running
290             swipeRefreshLayout.isRefreshing = false
291         })
292
293         // Create an error observer.
294         webViewSource.observeErrors().observe(this, { errorString: String ->
295             // Display an error snackbar if the string is not `""`.
296             if (errorString != "") {
297                 Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
298             }
299         })
300
301         // Implement swipe to refresh.
302         swipeRefreshLayout.setOnRefreshListener {
303             // Make the progress bar visible.
304             progressBar.visibility = View.VISIBLE
305
306             // Set the progress bar to be indeterminate.
307             progressBar.isIndeterminate = true
308
309             // Get the URL.
310             val urlString = urlEditText.text.toString()
311
312             // Update the layout.
313             updateLayout(urlString)
314
315             // Get the updated source.
316             webViewSource.updateSource(urlString)
317         }
318
319         // Set the go button on the keyboard to request new source data.
320         urlEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
321             // Request new source data if the enter key was pressed.
322             if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
323                 // Hide the soft keyboard.
324                 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
325
326                 // Remove the focus from the URL box.
327                 urlEditText.clearFocus()
328
329                 // Make the progress bar visible.
330                 progressBar.visibility = View.VISIBLE
331
332                 // Set the progress bar to be indeterminate.
333                 progressBar.isIndeterminate = true
334
335                 // Get the URL.
336                 val urlString = urlEditText.text.toString()
337
338                 // Update the layout.
339                 updateLayout(urlString)
340
341                 // Get the updated source.
342                 webViewSource.updateSource(urlString)
343
344                 // Consume the key press.
345                 return@setOnKeyListener true
346             } else {
347                 // Do not consume the key press.
348                 return@setOnKeyListener false
349             }
350         }
351     }
352
353     override fun onCreateOptionsMenu(menu: Menu): Boolean {
354         // Inflate the menu.  This adds items to the action bar if it is present.
355         menuInflater.inflate(R.menu.view_source_options_menu, menu)
356
357         // Display the menu.
358         return true
359     }
360
361     override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
362         // Get a handle for the about alert dialog.
363         val aboutDialogFragment: DialogFragment = AboutViewSourceDialog()
364
365         // Show the about alert dialog.
366         aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
367
368         // Consume the event.
369         return true
370     }
371
372     // This method must be named `goBack()` and must have a View argument to match the default back arrow in the app bar.
373     fun goBack(@Suppress("UNUSED_PARAMETER") view: View) {
374         // Go home.
375         NavUtils.navigateUpFromSameTask(this)
376     }
377
378     private fun highlightUrlText() {
379         // Get a handle for the URL edit text.
380         val urlEditText = findViewById<EditText>(R.id.url_edittext)
381
382         // Get the URL string.
383         val urlString = urlEditText.text.toString()
384
385         // Highlight the URL according to the protocol.
386         if (urlString.startsWith("file://")) {  // This is a file URL.
387             // De-emphasize only the protocol.
388             urlEditText.text.setSpan(initialGrayColorSpan, 0, 7, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
389         } else if (urlString.startsWith("content://")) {
390             // De-emphasize only the protocol.
391             urlEditText.text.setSpan(initialGrayColorSpan, 0, 10, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
392         } else {  // This is a web URL.
393             // Get the index of the `/` immediately after the domain name.
394             val endOfDomainName = urlString.indexOf("/", urlString.indexOf("//") + 2)
395
396             // Get the base URL.
397             val baseUrl = if (endOfDomainName > 0) {  // There is at least one character after the base URL.
398                 // Get the base URL.
399                 urlString.substring(0, endOfDomainName)
400             } else {  // There are no characters after the base URL.
401                 // Set the base URL to be the entire URL string.
402                 urlString
403             }
404
405             // Get the index of the last `.` in the domain.
406             val lastDotIndex = baseUrl.lastIndexOf(".")
407
408             // Get the index of the penultimate `.` in the domain.
409             val penultimateDotIndex = baseUrl.lastIndexOf(".", lastDotIndex - 1)
410
411             // Markup the beginning of the URL.
412             if (urlString.startsWith("http://")) {  // Highlight the protocol of connections that are not encrypted.
413                 urlEditText.text.setSpan(redColorSpan, 0, 7, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
414
415                 // De-emphasize subdomains.
416                 if (penultimateDotIndex > 0) {  // There is more than one subdomain in the domain name.
417                     urlEditText.text.setSpan(initialGrayColorSpan, 7, penultimateDotIndex + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
418                 }
419             } else if (urlString.startsWith("https://")) {  // De-emphasize the protocol of connections that are encrypted.
420                 if (penultimateDotIndex > 0) {  // There is more than one subdomain in the domain name.
421                     // De-emphasize the protocol and the additional subdomains.
422                     urlEditText.text.setSpan(initialGrayColorSpan, 0, penultimateDotIndex + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
423                 } else {  // There is only one subdomain in the domain name.
424                     // De-emphasize only the protocol.
425                     urlEditText.text.setSpan(initialGrayColorSpan, 0, 8, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
426                 }
427             }
428
429             // De-emphasize the text after the domain name.
430             if (endOfDomainName > 0) {
431                 urlEditText.text.setSpan(finalGrayColorSpan, endOfDomainName, urlString.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
432             }
433         }
434     }
435
436     private fun updateLayout(urlString: String) {
437         if (urlString.startsWith("content://")) {  // This is a content URL.
438             // Hide the unused text views.
439             requestHeadersTitleTextView.visibility = View.GONE
440             requestHeadersTextView.visibility = View.GONE
441             responseMessageTitleTextView.visibility = View.GONE
442             responseMessageTextView.visibility = View.GONE
443
444             // Change the text of the remaining title text views.
445             responseHeadersTitleTextView.setText(R.string.content_metadata)
446             responseBodyTitleTextView.setText(R.string.content_data)
447         } else {  // This is not a content URL.
448             // Show the views.
449             requestHeadersTitleTextView.visibility = View.VISIBLE
450             requestHeadersTextView.visibility = View.VISIBLE
451             responseMessageTitleTextView.visibility = View.VISIBLE
452             responseMessageTextView.visibility = View.VISIBLE
453
454             // Restore the text of the other title text views.
455             responseHeadersTitleTextView.setText(R.string.response_headers)
456             responseBodyTitleTextView.setText(R.string.response_body)
457         }
458     }
459 }