2 * Copyright 2017-2023 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.content.ClipData
23 import android.content.ClipboardManager
24 import android.content.Context
25 import android.content.Intent
26 import android.net.Uri
27 import android.os.Build
28 import android.os.Bundle
29 import android.provider.OpenableColumns
30 import android.text.SpannableStringBuilder
31 import android.text.style.ForegroundColorSpan
32 import android.util.TypedValue
33 import android.view.KeyEvent
34 import android.view.Menu
35 import android.view.MenuItem
36 import android.view.View
37 import android.view.View.OnFocusChangeListener
38 import android.view.WindowManager
39 import android.view.inputmethod.InputMethodManager
40 import android.widget.EditText
41 import android.widget.ProgressBar
42 import android.widget.TextView
44 import androidx.activity.result.contract.ActivityResultContracts
45 import androidx.appcompat.app.ActionBar
46 import androidx.appcompat.app.AppCompatActivity
47 import androidx.appcompat.widget.Toolbar
48 import androidx.constraintlayout.widget.ConstraintLayout
49 import androidx.core.app.NavUtils
50 import androidx.lifecycle.ViewModelProvider
51 import androidx.preference.PreferenceManager
52 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
54 import com.google.android.material.snackbar.Snackbar
56 import com.stoutner.privacybrowser.R
57 import com.stoutner.privacybrowser.dialogs.AVAILABLE_CIPHERS
58 import com.stoutner.privacybrowser.dialogs.SSL_CERTIFICATE
59 import com.stoutner.privacybrowser.dialogs.AboutViewHeadersDialog
60 import com.stoutner.privacybrowser.dialogs.ViewHeadersDetailDialog
61 import com.stoutner.privacybrowser.dialogs.UntrustedSslCertificateDialog
62 import com.stoutner.privacybrowser.dialogs.UntrustedSslCertificateDialog.UntrustedSslCertificateListener
63 import com.stoutner.privacybrowser.helpers.ProxyHelper
64 import com.stoutner.privacybrowser.helpers.UrlHelper
65 import com.stoutner.privacybrowser.viewmodelfactories.ViewHeadersFactory
66 import com.stoutner.privacybrowser.viewmodels.HeadersViewModel
68 import kotlinx.coroutines.CoroutineScope
69 import kotlinx.coroutines.Dispatchers
70 import kotlinx.coroutines.launch
71 import kotlinx.coroutines.withContext
73 import java.lang.Exception
74 import java.nio.charset.StandardCharsets
76 // Define the public constants.
77 const val USER_AGENT = "user_agent"
79 class ViewHeadersActivity: AppCompatActivity(), UntrustedSslCertificateListener {
80 // Declare the class variables.
81 private lateinit var appliedCipherString: String
82 private lateinit var availableCiphersString: String
83 private lateinit var headersViewModel: HeadersViewModel
84 private lateinit var initialGrayColorSpan: ForegroundColorSpan
85 private lateinit var finalGrayColorSpan: ForegroundColorSpan
86 private lateinit var redColorSpan: ForegroundColorSpan
87 private lateinit var sslCertificateString: String
89 // Declare the class views.
90 private lateinit var urlEditText: EditText
91 private lateinit var sslInformationTitleTextView: TextView
92 private lateinit var sslInformationTextView: TextView
93 private lateinit var sslButtonsConstraintLayout: ConstraintLayout
94 private lateinit var requestHeadersTitleTextView: TextView
95 private lateinit var requestHeadersTextView: TextView
96 private lateinit var responseMessageTitleTextView: TextView
97 private lateinit var responseMessageTextView: TextView
98 private lateinit var responseHeadersTitleTextView: TextView
99 private lateinit var responseHeadersTextView: TextView
100 private lateinit var responseBodyTitleTextView: TextView
101 private lateinit var responseBodyTextView: TextView
103 // Define the save text activity result launcher. It must be defined before `onCreate()` is run or the app will crash.
104 private val saveTextActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { fileUri ->
105 // Only save the file if the URI is not null, which happens if the user exited the file picker by pressing back.
106 if (fileUri != null) {
107 // Initialize the file name string from the file URI last path segment.
108 var fileNameString = fileUri.lastPathSegment
110 // Query the exact file name if the API >= 26.
111 if (Build.VERSION.SDK_INT >= 26) {
112 // Get a cursor from the content resolver.
113 val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
115 // Move to the first row.
116 contentResolverCursor.moveToFirst()
118 // Get the file name from the cursor.
119 fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
122 contentResolverCursor.close()
126 // Get the about version string.
127 val headersString = getHeadersString()
129 // Open an output stream.
130 val outputStream = contentResolver.openOutputStream(fileUri)!!
132 // Save the headers using a coroutine with Dispatchers.IO.
133 CoroutineScope(Dispatchers.Main).launch {
134 withContext(Dispatchers.IO) {
135 // Write the headers string to the output stream.
136 outputStream.write(headersString.toByteArray(StandardCharsets.UTF_8))
138 // Close the output stream.
143 // Display a snackbar with the saved logcat information.
144 Snackbar.make(urlEditText, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show()
145 } catch (exception: Exception) {
146 // Display a snackbar with the error message.
147 Snackbar.make(urlEditText, getString(R.string.error_saving_file, fileNameString, exception.toString()), Snackbar.LENGTH_INDEFINITE).show()
152 override fun onCreate(savedInstanceState: Bundle?) {
153 // Get a handle for the shared preferences.
154 val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
156 // Get the preferences.
157 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
158 val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
160 // Disable screenshots if not allowed.
161 if (!allowScreenshots) {
162 window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
165 // Run the default commands.
166 super.onCreate(savedInstanceState)
168 // Get the launching intent
171 // Get the information from the intent.
172 val currentUrl = intent.getStringExtra(CURRENT_URL)!!
173 val userAgent = intent.getStringExtra(USER_AGENT)!!
175 // Set the content view.
177 setContentView(R.layout.view_headers_bottom_appbar)
179 setContentView(R.layout.view_headers_top_appbar)
182 // Get a handle for the toolbar.
183 val toolbar = findViewById<Toolbar>(R.id.toolbar)
185 // Set the support action bar.
186 setSupportActionBar(toolbar)
188 // Get a handle for the action bar.
189 val actionBar = supportActionBar!!
191 // Add the custom layout to the action bar.
192 actionBar.setCustomView(R.layout.view_headers_appbar_custom_view)
194 // Instruct the action bar to display a custom layout.
195 actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM
197 // Get handles for the views.
198 urlEditText = findViewById(R.id.url_edittext)
199 val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
200 val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swiperefreshlayout)
201 sslInformationTitleTextView = findViewById(R.id.ssl_information_title_textview)
202 sslInformationTextView = findViewById(R.id.ssl_information_textview)
203 sslButtonsConstraintLayout = findViewById(R.id.ssl_buttons_constraintlayout)
204 requestHeadersTitleTextView = findViewById(R.id.request_headers_title_textview)
205 requestHeadersTextView = findViewById(R.id.request_headers_textview)
206 responseMessageTitleTextView = findViewById(R.id.response_message_title_textview)
207 responseMessageTextView = findViewById(R.id.response_message_textview)
208 responseHeadersTitleTextView = findViewById(R.id.response_headers_title_textview)
209 responseHeadersTextView = findViewById(R.id.response_headers_textview)
210 responseBodyTitleTextView = findViewById(R.id.response_body_title_textview)
211 responseBodyTextView = findViewById(R.id.response_body_textview)
213 // Initialize the gray foreground color spans for highlighting the URLs.
214 initialGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
215 finalGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
216 redColorSpan = ForegroundColorSpan(getColor(R.color.red_text))
218 // Get a handle for the input method manager, which is used to hide the keyboard.
219 val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
221 // Remove the formatting from the URL when the user is editing the text.
222 urlEditText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
223 if (hasFocus) { // The user is editing the URL text box.
224 // Get the foreground color spans.
225 val foregroundColorSpans: Array<ForegroundColorSpan> = urlEditText.text.getSpans(0, urlEditText.text.length, ForegroundColorSpan::class.java)
227 // Remove each foreground color span that highlights the text.
228 for (foregroundColorSpan in foregroundColorSpans)
229 urlEditText.text.removeSpan(foregroundColorSpan)
230 } else { // The user has stopped editing the URL text box.
231 // Hide the soft keyboard.
232 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
234 // Move to the beginning of the string.
235 urlEditText.setSelection(0)
237 // Store the URL text in the intent, so update layout uses the new text if the app is restarted.
238 intent.putExtra(CURRENT_URL, urlEditText.text.toString())
240 // Reapply the highlighting.
241 UrlHelper.highlightSyntax(urlEditText, initialGrayColorSpan, finalGrayColorSpan, redColorSpan)
245 // Populate the URL text box.
246 urlEditText.setText(currentUrl)
248 // Apply the initial text highlighting to the URL.
249 UrlHelper.highlightSyntax(urlEditText, initialGrayColorSpan, finalGrayColorSpan, redColorSpan)
251 // Set the refresh color scheme according to the theme.
252 swipeRefreshLayout.setColorSchemeResources(R.color.blue_text)
254 // Initialize a color background typed value.
255 val colorBackgroundTypedValue = TypedValue()
257 // Get the color background from the theme.
258 theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
260 // Get the color background int from the typed value.
261 val colorBackgroundInt = colorBackgroundTypedValue.data
263 // Set the swipe refresh background color.
264 swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
266 // Get the list of locales.
267 val localeList = resources.configuration.locales
269 // Initialize a string builder to extract the locales from the list.
270 val localesStringBuilder = StringBuilder()
272 // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
275 // Populate the string builder with the contents of the locales list.
276 for (i in 0 until localeList.size()) {
277 // Append a comma if there is already an item in the string builder.
279 localesStringBuilder.append(",")
282 // Get the locale from the list.
283 val locale = localeList[i]
285 // Add the locale to the string. `locale` by default displays as `en_US`, but WebView uses the `en-US` format.
286 localesStringBuilder.append(locale.language)
287 localesStringBuilder.append("-")
288 localesStringBuilder.append(locale.country)
290 // If not the first locale, append `;q=0.x`, which drops by .1 for each removal from the main locale until q=0.1.
292 localesStringBuilder.append(";q=0.")
293 localesStringBuilder.append(q)
296 // Decrement `q` if it is greater than 1.
301 // Add a second entry for the language only portion of the locale.
302 localesStringBuilder.append(",")
303 localesStringBuilder.append(locale.language)
305 // Append `1;q=0.x`, which drops by .1 for each removal form the main locale until q=0.1.
306 localesStringBuilder.append(";q=0.")
307 localesStringBuilder.append(q)
309 // Decrement `q` if it is greater than 1.
315 // Instantiate the proxy helper.
316 val proxyHelper = ProxyHelper()
318 // Get the current proxy.
319 val proxy = proxyHelper.getCurrentProxy(this)
321 // Make the progress bar visible.
322 progressBar.visibility = View.VISIBLE
324 // Set the progress bar to be indeterminate.
325 progressBar.isIndeterminate = true
327 // Update the layout.
328 updateLayout(currentUrl)
330 // Instantiate the view headers factory.
331 val viewHeadersFactory: ViewModelProvider.Factory = ViewHeadersFactory(application, currentUrl, userAgent, localesStringBuilder.toString(), proxy, contentResolver, MainWebViewActivity.executorService)
333 // Instantiate the headers view model.
334 headersViewModel = ViewModelProvider(this, viewHeadersFactory)[HeadersViewModel::class.java]
336 // Create a headers observer.
337 headersViewModel.observeHeaders().observe(this) { headersStringArray: Array<SpannableStringBuilder> ->
338 // Populate the text views. This can take a long time, and freezes the user interface, if the response body is particularly large.
339 sslInformationTextView.text = headersStringArray[0]
340 requestHeadersTextView.text = headersStringArray[4]
341 responseMessageTextView.text = headersStringArray[5]
342 responseHeadersTextView.text = headersStringArray[6]
343 responseBodyTextView.text = headersStringArray[7]
345 // Populate the dialog strings.
346 appliedCipherString = headersStringArray[1].toString()
347 availableCiphersString = headersStringArray[2].toString()
348 sslCertificateString = headersStringArray[3].toString()
350 // Hide the progress bar.
351 progressBar.isIndeterminate = false
352 progressBar.visibility = View.GONE
354 // Stop the swipe to refresh indicator if it is running
355 swipeRefreshLayout.isRefreshing = false
358 // Create an error observer.
359 headersViewModel.observeErrors().observe(this) { errorString: String ->
360 // Display an error snackbar if the string is not `""`.
361 if (errorString != "") {
362 if (errorString.startsWith("javax.net.ssl.SSLHandshakeException")) {
363 // Instantiate the untrusted SSL certificate dialog.
364 val untrustedSslCertificateDialog = UntrustedSslCertificateDialog()
366 // Show the untrusted SSL certificate dialog.
367 untrustedSslCertificateDialog.show(supportFragmentManager, getString(R.string.invalid_certificate))
369 // Display a snackbar with the error message.
370 Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
375 // Implement swipe to refresh.
376 swipeRefreshLayout.setOnRefreshListener {
377 // Make the progress bar visible.
378 progressBar.visibility = View.VISIBLE
380 // Set the progress bar to be indeterminate.
381 progressBar.isIndeterminate = true
384 val urlString = urlEditText.text.toString()
386 // Update the layout.
387 updateLayout(urlString)
389 // Get the updated headers.
390 headersViewModel.updateHeaders(urlString, false)
393 // Set the go button on the keyboard to request new headers data.
394 urlEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
395 // Request new headers data if the enter key was pressed.
396 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
397 // Hide the soft keyboard.
398 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
400 // Remove the focus from the URL box.
401 urlEditText.clearFocus()
403 // Make the progress bar visible.
404 progressBar.visibility = View.VISIBLE
406 // Set the progress bar to be indeterminate.
407 progressBar.isIndeterminate = true
410 val urlString = urlEditText.text.toString()
412 // Update the layout.
413 updateLayout(urlString)
415 // Get the updated headers.
416 headersViewModel.updateHeaders(urlString, false)
418 // Consume the key press.
419 return@setOnKeyListener true
421 // Do not consume the key press.
422 return@setOnKeyListener false
427 override fun onCreateOptionsMenu(menu: Menu): Boolean {
429 menuInflater.inflate(R.menu.view_headers_options_menu, menu)
435 override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
436 // Run the appropriate commands.
437 when (menuItem.itemId) {
438 R.id.copy_headers -> { // Copy the headers.
439 // Get the headers string.
440 val headersString = getHeadersString()
442 // Get a handle for the clipboard manager.
443 val clipboardManager = (getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
445 // Place the headers string in a clip data.
446 val headersClipData = ClipData.newPlainText(getString(R.string.view_headers), headersString)
448 // Place the clip data on the clipboard.
449 clipboardManager.setPrimaryClip(headersClipData)
451 // Display a snackbar if the API <= 32 (Android 12L). Beginning in Android 13 the OS displays a notification that covers up the snackbar.
452 if (Build.VERSION.SDK_INT <= 32)
453 Snackbar.make(urlEditText, R.string.headers_copied, Snackbar.LENGTH_SHORT).show()
455 // Consume the event.
459 R.id.share_headers -> { // Share the headers.
460 // Get the headers string.
461 val headersString = getHeadersString()
463 // Create a share intent.
464 val shareIntent = Intent(Intent.ACTION_SEND)
466 // Add the headers string to the intent.
467 shareIntent.putExtra(Intent.EXTRA_TEXT, headersString)
469 // Set the MIME type.
470 shareIntent.type = "text/plain"
472 // Set the intent to open in a new task.
473 shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
476 startActivity(Intent.createChooser(shareIntent, getString(R.string.share)))
478 // Consume the event.
482 R.id.save_headers -> { // Save the headers as a text file.
483 // Get the current URL.
484 val currentUrlString = urlEditText.text.toString()
486 // Get a URI for the current URL.
487 val currentUri = Uri.parse(currentUrlString)
489 // Get the current domain name.
490 val currentDomainName = currentUri.host
492 // Open the file picker.
493 saveTextActivityResultLauncher.launch(getString(R.string.headers_txt, currentDomainName))
495 // Consume the event.
499 R.id.about_view_headers -> { // Display the about dialog.
500 // Instantiate the about dialog fragment.
501 val aboutDialogFragment = AboutViewHeadersDialog()
503 // Show the about alert dialog.
504 aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
506 // Consume the event.
510 else -> { // The home button was selected.
511 // Run the parents class on return.
512 return super.onOptionsItemSelected(menuItem)
517 // This method must be named `goBack()` and must have a View argument to match the default back arrow in the app bar or a crash occurs.
518 fun goBack(@Suppress("UNUSED_PARAMETER") view: View) {
520 NavUtils.navigateUpFromSameTask(this)
523 private fun getHeadersString(): String {
524 // Initialize a headers string builder.
525 val headersStringBuilder = StringBuilder()
527 // Populate the SSL information if it is visible (an HTTPS URL is loaded).
528 if (sslInformationTitleTextView.visibility == View.VISIBLE) {
529 headersStringBuilder.append(sslInformationTitleTextView.text)
530 headersStringBuilder.append("\n")
531 headersStringBuilder.append(sslInformationTextView.text)
532 headersStringBuilder.append("\n\n")
533 headersStringBuilder.append(getString(R.string.available_ciphers))
534 headersStringBuilder.append("\n")
535 headersStringBuilder.append(availableCiphersString)
536 headersStringBuilder.append("\n\n")
537 headersStringBuilder.append(getString(R.string.ssl_certificate))
538 headersStringBuilder.append("\n")
539 headersStringBuilder.append(sslCertificateString)
540 headersStringBuilder.append("\n") // Only a single new line is needed after the certificate as it already ends in one.
543 // Populate the request information if it is visible (an HTTP URL is loaded).
544 if (requestHeadersTitleTextView.visibility == View.VISIBLE) {
545 headersStringBuilder.append(requestHeadersTitleTextView.text)
546 headersStringBuilder.append("\n")
547 headersStringBuilder.append(requestHeadersTextView.text)
548 headersStringBuilder.append("\n\n")
549 headersStringBuilder.append(responseMessageTitleTextView.text)
550 headersStringBuilder.append("\n")
551 headersStringBuilder.append(responseMessageTextView.text)
552 headersStringBuilder.append("\n\n")
555 // Populate the response information, which is visible for both HTTP and content URLs.
556 headersStringBuilder.append(responseHeadersTitleTextView.text)
557 headersStringBuilder.append("\n")
558 headersStringBuilder.append(responseHeadersTextView.text)
559 headersStringBuilder.append("\n\n")
560 headersStringBuilder.append(responseBodyTitleTextView.text)
561 headersStringBuilder.append("\n")
562 headersStringBuilder.append(responseBodyTextView.text)
564 // Return the string.
565 return headersStringBuilder.toString()
568 override fun loadAnyway() {
569 // Load the URL anyway.
570 headersViewModel.updateHeaders(urlEditText.text.toString(), true)
573 // The view parameter cannot be removed because it is called from the layout onClick.
574 fun showCertificate(@Suppress("UNUSED_PARAMETER")view: View) {
575 // Instantiate an SSL certificate dialog.
576 val sslCertificateDialogFragment= ViewHeadersDetailDialog.displayDialog(SSL_CERTIFICATE, sslCertificateString)
579 sslCertificateDialogFragment.show(supportFragmentManager, getString(R.string.ssl_certificate))
582 // The view parameter cannot be removed because it is called from the layout onClick.
583 fun showCiphers(@Suppress("UNUSED_PARAMETER")view: View) {
584 // Instantiate an SSL certificate dialog.
585 val ciphersDialogFragment= ViewHeadersDetailDialog.displayDialog(AVAILABLE_CIPHERS, availableCiphersString, appliedCipherString)
588 ciphersDialogFragment.show(supportFragmentManager, getString(R.string.ssl_certificate))
591 private fun updateLayout(urlString: String) {
592 if (urlString.startsWith("content://")) { // This is a content URL.
593 // Hide the unused views.
594 sslInformationTitleTextView.visibility = View.GONE
595 sslInformationTextView.visibility = View.GONE
596 sslButtonsConstraintLayout.visibility = View.GONE
597 requestHeadersTitleTextView.visibility = View.GONE
598 requestHeadersTextView.visibility = View.GONE
599 responseMessageTitleTextView.visibility = View.GONE
600 responseMessageTextView.visibility = View.GONE
602 // Change the text of the remaining title text views.
603 responseHeadersTitleTextView.setText(R.string.content_metadata)
604 responseBodyTitleTextView.setText(R.string.content_data)
605 } else { // This is not a content URL.
606 // Set the status if the the SSL information views.
607 if (urlString.startsWith("http://")) { // This is an HTTP URL.
608 // Hide the SSL information views.
609 sslInformationTitleTextView.visibility = View.GONE
610 sslInformationTextView.visibility = View.GONE
611 sslButtonsConstraintLayout.visibility = View.GONE
612 } else { // This is not an HTTP URL.
613 // Show the SSL information views.
614 sslInformationTitleTextView.visibility = View.VISIBLE
615 sslInformationTextView.visibility = View.VISIBLE
616 sslButtonsConstraintLayout.visibility = View.VISIBLE
619 // Show the other views.
620 requestHeadersTitleTextView.visibility = View.VISIBLE
621 requestHeadersTextView.visibility = View.VISIBLE
622 responseMessageTitleTextView.visibility = View.VISIBLE
623 responseMessageTextView.visibility = View.VISIBLE
625 // Restore the text of the other title text views.
626 responseHeadersTitleTextView.setText(R.string.response_headers)
627 responseBodyTitleTextView.setText(R.string.response_body)