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