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.Button
41 import android.widget.EditText
42 import android.widget.ProgressBar
43 import android.widget.TextView
45 import androidx.activity.result.contract.ActivityResultContracts
46 import androidx.appcompat.app.ActionBar
47 import androidx.appcompat.app.AppCompatActivity
48 import androidx.appcompat.widget.Toolbar
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 ciphersButton: Button
94 private lateinit var certificateButton: Button
95 private lateinit var requestHeadersTitleTextView: TextView
96 private lateinit var requestHeadersTextView: TextView
97 private lateinit var responseMessageTitleTextView: TextView
98 private lateinit var responseMessageTextView: TextView
99 private lateinit var responseHeadersTitleTextView: TextView
100 private lateinit var responseHeadersTextView: TextView
101 private lateinit var responseBodyTitleTextView: TextView
102 private lateinit var responseBodyTextView: TextView
104 // Define the save text activity result launcher. It must be defined before `onCreate()` is run or the app will crash.
105 private val saveTextActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { fileUri ->
106 // Only save the file if the URI is not null, which happens if the user exited the file picker by pressing back.
107 if (fileUri != null) {
108 // Initialize the file name string from the file URI last path segment.
109 var fileNameString = fileUri.lastPathSegment
111 // Query the exact file name if the API >= 26.
112 if (Build.VERSION.SDK_INT >= 26) {
113 // Get a cursor from the content resolver.
114 val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
116 // Move to the first row.
117 contentResolverCursor.moveToFirst()
119 // Get the file name from the cursor.
120 fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
123 contentResolverCursor.close()
127 // Get the about version string.
128 val headersString = getHeadersString()
130 // Open an output stream.
131 val outputStream = contentResolver.openOutputStream(fileUri)!!
133 // Save the headers using a coroutine with Dispatchers.IO.
134 CoroutineScope(Dispatchers.Main).launch {
135 withContext(Dispatchers.IO) {
136 // Write the headers string to the output stream.
137 outputStream.write(headersString.toByteArray(StandardCharsets.UTF_8))
139 // Close the output stream.
144 // Display a snackbar with the saved logcat information.
145 Snackbar.make(urlEditText, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show()
146 } catch (exception: Exception) {
147 // Display a snackbar with the error message.
148 Snackbar.make(urlEditText, getString(R.string.error_saving_file, fileNameString, exception.toString()), Snackbar.LENGTH_INDEFINITE).show()
153 override fun onCreate(savedInstanceState: Bundle?) {
154 // Get a handle for the shared preferences.
155 val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
157 // Get the preferences.
158 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
159 val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
161 // Disable screenshots if not allowed.
162 if (!allowScreenshots) {
163 window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
166 // Run the default commands.
167 super.onCreate(savedInstanceState)
169 // Get the launching intent
172 // Get the information from the intent.
173 val currentUrl = intent.getStringExtra(CURRENT_URL)!!
174 val userAgent = intent.getStringExtra(USER_AGENT)!!
176 // Set the content view.
178 setContentView(R.layout.view_headers_bottom_appbar)
180 setContentView(R.layout.view_headers_top_appbar)
183 // Get a handle for the toolbar.
184 val toolbar = findViewById<Toolbar>(R.id.toolbar)
186 // Set the support action bar.
187 setSupportActionBar(toolbar)
189 // Get a handle for the action bar.
190 val actionBar = supportActionBar!!
192 // Add the custom layout to the action bar.
193 actionBar.setCustomView(R.layout.view_headers_appbar_custom_view)
195 // Instruct the action bar to display a custom layout.
196 actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM
198 // Get handles for the views.
199 urlEditText = findViewById(R.id.url_edittext)
200 val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
201 val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swiperefreshlayout)
202 sslInformationTitleTextView = findViewById(R.id.ssl_information_title_textview)
203 sslInformationTextView = findViewById(R.id.ssl_information_textview)
204 ciphersButton = findViewById(R.id.ciphers_button)
205 certificateButton = findViewById(R.id.certificate_button)
206 requestHeadersTitleTextView = findViewById(R.id.request_headers_title_textview)
207 requestHeadersTextView = findViewById(R.id.request_headers_textview)
208 responseMessageTitleTextView = findViewById(R.id.response_message_title_textview)
209 responseMessageTextView = findViewById(R.id.response_message_textview)
210 responseHeadersTitleTextView = findViewById(R.id.response_headers_title_textview)
211 responseHeadersTextView = findViewById(R.id.response_headers_textview)
212 responseBodyTitleTextView = findViewById(R.id.response_body_title_textview)
213 responseBodyTextView = findViewById(R.id.response_body_textview)
215 // Initialize the gray foreground color spans for highlighting the URLs.
216 initialGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
217 finalGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
218 redColorSpan = ForegroundColorSpan(getColor(R.color.red_text))
220 // Get a handle for the input method manager, which is used to hide the keyboard.
221 val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
223 // Remove the formatting from the URL when the user is editing the text.
224 urlEditText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
225 if (hasFocus) { // The user is editing the URL text box.
226 // Get the foreground color spans.
227 val foregroundColorSpans: Array<ForegroundColorSpan> = urlEditText.text.getSpans(0, urlEditText.text.length, ForegroundColorSpan::class.java)
229 // Remove each foreground color span that highlights the text.
230 for (foregroundColorSpan in foregroundColorSpans)
231 urlEditText.text.removeSpan(foregroundColorSpan)
232 } else { // The user has stopped editing the URL text box.
233 // Hide the soft keyboard.
234 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
236 // Move to the beginning of the string.
237 urlEditText.setSelection(0)
239 // Store the URL text in the intent, so update layout uses the new text if the app is restarted.
240 intent.putExtra(CURRENT_URL, urlEditText.text.toString())
242 // Reapply the highlighting.
243 UrlHelper.highlightSyntax(urlEditText, initialGrayColorSpan, finalGrayColorSpan, redColorSpan)
247 // Populate the URL text box.
248 urlEditText.setText(currentUrl)
250 // Apply the initial text highlighting to the URL.
251 UrlHelper.highlightSyntax(urlEditText, initialGrayColorSpan, finalGrayColorSpan, redColorSpan)
253 // Set the refresh color scheme according to the theme.
254 swipeRefreshLayout.setColorSchemeResources(R.color.blue_text)
256 // Initialize a color background typed value.
257 val colorBackgroundTypedValue = TypedValue()
259 // Get the color background from the theme.
260 theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
262 // Get the color background int from the typed value.
263 val colorBackgroundInt = colorBackgroundTypedValue.data
265 // Set the swipe refresh background color.
266 swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
268 // Get the list of locales.
269 val localeList = resources.configuration.locales
271 // Initialize a string builder to extract the locales from the list.
272 val localesStringBuilder = StringBuilder()
274 // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
277 // Populate the string builder with the contents of the locales list.
278 for (i in 0 until localeList.size()) {
279 // Append a comma if there is already an item in the string builder.
281 localesStringBuilder.append(",")
284 // Get the locale from the list.
285 val locale = localeList[i]
287 // Add the locale to the string. `locale` by default displays as `en_US`, but WebView uses the `en-US` format.
288 localesStringBuilder.append(locale.language)
289 localesStringBuilder.append("-")
290 localesStringBuilder.append(locale.country)
292 // If not the first locale, append `;q=0.x`, which drops by .1 for each removal from the main locale until q=0.1.
294 localesStringBuilder.append(";q=0.")
295 localesStringBuilder.append(q)
298 // Decrement `q` if it is greater than 1.
303 // Add a second entry for the language only portion of the locale.
304 localesStringBuilder.append(",")
305 localesStringBuilder.append(locale.language)
307 // Append `1;q=0.x`, which drops by .1 for each removal form the main locale until q=0.1.
308 localesStringBuilder.append(";q=0.")
309 localesStringBuilder.append(q)
311 // Decrement `q` if it is greater than 1.
317 // Instantiate the proxy helper.
318 val proxyHelper = ProxyHelper()
320 // Get the current proxy.
321 val proxy = proxyHelper.getCurrentProxy(this)
323 // Make the progress bar visible.
324 progressBar.visibility = View.VISIBLE
326 // Set the progress bar to be indeterminate.
327 progressBar.isIndeterminate = true
329 // Update the layout.
330 updateLayout(currentUrl)
332 // Instantiate the view headers factory.
333 val viewHeadersFactory: ViewModelProvider.Factory = ViewHeadersFactory(application, currentUrl, userAgent, localesStringBuilder.toString(), proxy, contentResolver, MainWebViewActivity.executorService)
335 // Instantiate the headers view model.
336 headersViewModel = ViewModelProvider(this, viewHeadersFactory)[HeadersViewModel::class.java]
338 // Create a headers observer.
339 headersViewModel.observeHeaders().observe(this) { headersStringArray: Array<SpannableStringBuilder> ->
340 // Populate the text views. This can take a long time, and freezes the user interface, if the response body is particularly large.
341 sslInformationTextView.text = headersStringArray[0]
342 requestHeadersTextView.text = headersStringArray[4]
343 responseMessageTextView.text = headersStringArray[5]
344 responseHeadersTextView.text = headersStringArray[6]
345 responseBodyTextView.text = headersStringArray[7]
347 // Populate the dialog strings.
348 appliedCipherString = headersStringArray[1].toString()
349 availableCiphersString = headersStringArray[2].toString()
350 sslCertificateString = headersStringArray[3].toString()
352 // Hide the progress bar.
353 progressBar.isIndeterminate = false
354 progressBar.visibility = View.GONE
356 // Stop the swipe to refresh indicator if it is running
357 swipeRefreshLayout.isRefreshing = false
360 // Create an error observer.
361 headersViewModel.observeErrors().observe(this) { errorString: String ->
362 // Display an error snackbar if the string is not `""`.
363 if (errorString != "") {
364 if (errorString.startsWith("javax.net.ssl.SSLHandshakeException")) {
365 // Instantiate the untrusted SSL certificate dialog.
366 val untrustedSslCertificateDialog = UntrustedSslCertificateDialog()
368 // Show the untrusted SSL certificate dialog.
369 untrustedSslCertificateDialog.show(supportFragmentManager, getString(R.string.invalid_certificate))
371 // Display a snackbar with the error message.
372 Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
377 // Implement swipe to refresh.
378 swipeRefreshLayout.setOnRefreshListener {
379 // Make the progress bar visible.
380 progressBar.visibility = View.VISIBLE
382 // Set the progress bar to be indeterminate.
383 progressBar.isIndeterminate = true
386 val urlString = urlEditText.text.toString()
388 // Update the layout.
389 updateLayout(urlString)
391 // Get the updated headers.
392 headersViewModel.updateHeaders(urlString, false)
395 // Set the go button on the keyboard to request new headers data.
396 urlEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
397 // Request new headers data if the enter key was pressed.
398 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
399 // Hide the soft keyboard.
400 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
402 // Remove the focus from the URL box.
403 urlEditText.clearFocus()
405 // Make the progress bar visible.
406 progressBar.visibility = View.VISIBLE
408 // Set the progress bar to be indeterminate.
409 progressBar.isIndeterminate = true
412 val urlString = urlEditText.text.toString()
414 // Update the layout.
415 updateLayout(urlString)
417 // Get the updated headers.
418 headersViewModel.updateHeaders(urlString, false)
420 // Consume the key press.
421 return@setOnKeyListener true
423 // Do not consume the key press.
424 return@setOnKeyListener false
429 override fun onCreateOptionsMenu(menu: Menu): Boolean {
431 menuInflater.inflate(R.menu.view_headers_options_menu, menu)
437 override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
438 // Run the appropriate commands.
439 when (menuItem.itemId) {
440 R.id.copy_headers -> { // Copy the headers.
441 // Get the headers string.
442 val headersString = getHeadersString()
444 // Get a handle for the clipboard manager.
445 val clipboardManager = (getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
447 // Place the headers string in a clip data.
448 val headersClipData = ClipData.newPlainText(getString(R.string.view_headers), headersString)
450 // Place the clip data on the clipboard.
451 clipboardManager.setPrimaryClip(headersClipData)
453 // Display a snackbar if the API <= 32 (Android 12L). Beginning in Android 13 the OS displays a notification that covers up the snackbar.
454 if (Build.VERSION.SDK_INT <= 32)
455 Snackbar.make(urlEditText, R.string.version_info_copied, Snackbar.LENGTH_SHORT).show()
457 // Consume the event.
461 R.id.share_headers -> { // Share the headers.
462 // Get the headers string.
463 val headersString = getHeadersString()
465 // Create a share intent.
466 val shareIntent = Intent(Intent.ACTION_SEND)
468 // Add the headers string to the intent.
469 shareIntent.putExtra(Intent.EXTRA_TEXT, headersString)
471 // Set the MIME type.
472 shareIntent.type = "text/plain"
474 // Set the intent to open in a new task.
475 shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
478 startActivity(Intent.createChooser(shareIntent, getString(R.string.share)))
480 // Consume the event.
484 R.id.save_headers -> { // Save the headers as a text file.
485 // Get the current URL.
486 val currentUrlString = urlEditText.text.toString()
488 // Get a URI for the current URL.
489 val currentUri = Uri.parse(currentUrlString)
491 // Get the current domain name.
492 val currentDomainName = currentUri.host
494 // Open the file picker.
495 saveTextActivityResultLauncher.launch(getString(R.string.headers_txt, currentDomainName))
497 // Consume the event.
501 R.id.about_view_headers -> { // Display the about dialog.
502 // Instantiate the about dialog fragment.
503 val aboutDialogFragment = AboutViewHeadersDialog()
505 // Show the about alert dialog.
506 aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
508 // Consume the event.
512 else -> { // The home button was selected.
513 // Run the parents class on return.
514 return super.onOptionsItemSelected(menuItem)
519 // 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.
520 fun goBack(@Suppress("UNUSED_PARAMETER") view: View) {
522 NavUtils.navigateUpFromSameTask(this)
525 private fun getHeadersString(): String {
526 // Initialize a headers string builder.
527 val headersStringBuilder = StringBuilder()
529 // Populate the SSL information if it is visible (an HTTPS URL is loaded).
530 if (sslInformationTitleTextView.visibility == View.VISIBLE) {
531 headersStringBuilder.append(sslInformationTitleTextView.text)
532 headersStringBuilder.append("\n")
533 headersStringBuilder.append(sslInformationTextView.text)
534 headersStringBuilder.append("\n\n")
535 headersStringBuilder.append(getString(R.string.available_ciphers))
536 headersStringBuilder.append("\n")
537 headersStringBuilder.append(availableCiphersString)
538 headersStringBuilder.append("\n\n")
539 headersStringBuilder.append(getString(R.string.ssl_certificate))
540 headersStringBuilder.append("\n")
541 headersStringBuilder.append(sslCertificateString)
542 headersStringBuilder.append("\n") // Only a single new line is needed after the certificate as it already ends in one.
545 // Populate the request information if it is visible (an HTTP URL is loaded).
546 if (requestHeadersTitleTextView.visibility == View.VISIBLE) {
547 headersStringBuilder.append(requestHeadersTitleTextView.text)
548 headersStringBuilder.append("\n")
549 headersStringBuilder.append(requestHeadersTextView.text)
550 headersStringBuilder.append("\n\n")
551 headersStringBuilder.append(responseMessageTitleTextView.text)
552 headersStringBuilder.append("\n")
553 headersStringBuilder.append(responseMessageTextView.text)
554 headersStringBuilder.append("\n\n")
557 // Populate the response information, which is visible for both HTTP and content URLs.
558 headersStringBuilder.append(responseHeadersTitleTextView.text)
559 headersStringBuilder.append("\n")
560 headersStringBuilder.append(responseHeadersTextView.text)
561 headersStringBuilder.append("\n\n")
562 headersStringBuilder.append(responseBodyTitleTextView.text)
563 headersStringBuilder.append("\n")
564 headersStringBuilder.append(responseBodyTextView.text)
566 // Return the string.
567 return headersStringBuilder.toString()
570 override fun loadAnyway() {
571 // Load the URL anyway.
572 headersViewModel.updateHeaders(urlEditText.text.toString(), true)
575 // The view parameter cannot be removed because it is called from the layout onClick.
576 fun showCertificate(@Suppress("UNUSED_PARAMETER")view: View) {
577 // Instantiate an SSL certificate dialog.
578 val sslCertificateDialogFragment= ViewHeadersDetailDialog.displayDialog(SSL_CERTIFICATE, sslCertificateString)
581 sslCertificateDialogFragment.show(supportFragmentManager, getString(R.string.ssl_certificate))
584 // The view parameter cannot be removed because it is called from the layout onClick.
585 fun showCiphers(@Suppress("UNUSED_PARAMETER")view: View) {
586 // Instantiate an SSL certificate dialog.
587 val ciphersDialogFragment= ViewHeadersDetailDialog.displayDialog(AVAILABLE_CIPHERS, availableCiphersString, appliedCipherString)
590 ciphersDialogFragment.show(supportFragmentManager, getString(R.string.ssl_certificate))
593 private fun updateLayout(urlString: String) {
594 if (urlString.startsWith("content://")) { // This is a content URL.
595 // Hide the unused views.
596 sslInformationTitleTextView.visibility = View.GONE
597 sslInformationTextView.visibility = View.GONE
598 ciphersButton.visibility = View.GONE
599 certificateButton.visibility = View.GONE
600 requestHeadersTitleTextView.visibility = View.GONE
601 requestHeadersTextView.visibility = View.GONE
602 responseMessageTitleTextView.visibility = View.GONE
603 responseMessageTextView.visibility = View.GONE
605 // Change the text of the remaining title text views.
606 responseHeadersTitleTextView.setText(R.string.content_metadata)
607 responseBodyTitleTextView.setText(R.string.content_data)
608 } else { // This is not a content URL.
609 // Set the status if the the SSL information views.
610 if (urlString.startsWith("http://")) { // This is an HTTP URL.
611 // Hide the SSL information views.
612 sslInformationTitleTextView.visibility = View.GONE
613 sslInformationTextView.visibility = View.GONE
614 ciphersButton.visibility = View.GONE
615 certificateButton.visibility = View.GONE
616 } else { // This is not an HTTP URL.
617 // Show the SSL information views.
618 sslInformationTitleTextView.visibility = View.VISIBLE
619 sslInformationTextView.visibility = View.VISIBLE
620 ciphersButton.visibility = View.VISIBLE
621 certificateButton.visibility = View.VISIBLE
624 // Show the other views.
625 requestHeadersTitleTextView.visibility = View.VISIBLE
626 requestHeadersTextView.visibility = View.VISIBLE
627 responseMessageTitleTextView.visibility = View.VISIBLE
628 responseMessageTextView.visibility = View.VISIBLE
630 // Restore the text of the other title text views.
631 responseHeadersTitleTextView.setText(R.string.response_headers)
632 responseBodyTitleTextView.setText(R.string.response_body)