2 * Copyright 2017-2024 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 // Get a cursor from the content resolver.
108 val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
110 // Move to the first row.
111 contentResolverCursor.moveToFirst()
113 // Get the file name from the cursor.
114 val fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
117 contentResolverCursor.close()
120 // Get the about version string.
121 val headersString = getHeadersString()
123 // Open an output stream.
124 val outputStream = contentResolver.openOutputStream(fileUri)!!
126 // Save the headers using a coroutine with Dispatchers.IO.
127 CoroutineScope(Dispatchers.Main).launch {
128 withContext(Dispatchers.IO) {
129 // Write the headers string to the output stream.
130 outputStream.write(headersString.toByteArray(StandardCharsets.UTF_8))
132 // Close the output stream.
137 // Display a snackbar with the saved logcat information.
138 Snackbar.make(urlEditText, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show()
139 } catch (exception: Exception) {
140 // Display a snackbar with the error message.
141 Snackbar.make(urlEditText, getString(R.string.error_saving_file, fileNameString, exception.toString()), Snackbar.LENGTH_INDEFINITE).show()
146 override fun onCreate(savedInstanceState: Bundle?) {
147 // Get a handle for the shared preferences.
148 val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
150 // Get the preferences.
151 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
152 val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
154 // Disable screenshots if not allowed.
155 if (!allowScreenshots) {
156 window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
159 // Run the default commands.
160 super.onCreate(savedInstanceState)
162 // Get the launching intent
165 // Get the information from the intent.
166 val currentUrl = intent.getStringExtra(CURRENT_URL)!!
167 val userAgent = intent.getStringExtra(USER_AGENT)!!
169 // Set the content view.
171 setContentView(R.layout.view_headers_bottom_appbar)
173 setContentView(R.layout.view_headers_top_appbar)
176 // Get a handle for the toolbar.
177 val toolbar = findViewById<Toolbar>(R.id.toolbar)
179 // Set the support action bar.
180 setSupportActionBar(toolbar)
182 // Get a handle for the action bar.
183 val actionBar = supportActionBar!!
185 // Add the custom layout to the action bar.
186 actionBar.setCustomView(R.layout.view_headers_appbar_custom_view)
188 // Instruct the action bar to display a custom layout.
189 actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM
191 // Get handles for the views.
192 urlEditText = findViewById(R.id.url_edittext)
193 val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
194 val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swiperefreshlayout)
195 sslInformationTitleTextView = findViewById(R.id.ssl_information_title_textview)
196 sslInformationTextView = findViewById(R.id.ssl_information_textview)
197 sslButtonsConstraintLayout = findViewById(R.id.ssl_buttons_constraintlayout)
198 requestHeadersTitleTextView = findViewById(R.id.request_headers_title_textview)
199 requestHeadersTextView = findViewById(R.id.request_headers_textview)
200 responseMessageTitleTextView = findViewById(R.id.response_message_title_textview)
201 responseMessageTextView = findViewById(R.id.response_message_textview)
202 responseHeadersTitleTextView = findViewById(R.id.response_headers_title_textview)
203 responseHeadersTextView = findViewById(R.id.response_headers_textview)
204 responseBodyTitleTextView = findViewById(R.id.response_body_title_textview)
205 responseBodyTextView = findViewById(R.id.response_body_textview)
207 // Initialize the gray foreground color spans for highlighting the URLs.
208 initialGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
209 finalGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
210 redColorSpan = ForegroundColorSpan(getColor(R.color.red_text))
212 // Get a handle for the input method manager, which is used to hide the keyboard.
213 val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
215 // Remove the formatting from the URL when the user is editing the text.
216 urlEditText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
217 if (hasFocus) { // The user is editing the URL text box.
218 // Get the foreground color spans.
219 val foregroundColorSpans: Array<ForegroundColorSpan> = urlEditText.text.getSpans(0, urlEditText.text.length, ForegroundColorSpan::class.java)
221 // Remove each foreground color span that highlights the text.
222 for (foregroundColorSpan in foregroundColorSpans)
223 urlEditText.text.removeSpan(foregroundColorSpan)
224 } else { // The user has stopped editing the URL text box.
225 // Hide the soft keyboard.
226 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
228 // Move to the beginning of the string.
229 urlEditText.setSelection(0)
231 // Store the URL text in the intent, so update layout uses the new text if the app is restarted.
232 intent.putExtra(CURRENT_URL, urlEditText.text.toString())
234 // Reapply the highlighting.
235 UrlHelper.highlightSyntax(urlEditText, initialGrayColorSpan, finalGrayColorSpan, redColorSpan)
239 // Populate the URL text box.
240 urlEditText.setText(currentUrl)
242 // Apply the initial text highlighting to the URL.
243 UrlHelper.highlightSyntax(urlEditText, initialGrayColorSpan, finalGrayColorSpan, redColorSpan)
245 // Set the refresh color scheme according to the theme.
246 swipeRefreshLayout.setColorSchemeResources(R.color.blue_text)
248 // Initialize a color background typed value.
249 val colorBackgroundTypedValue = TypedValue()
251 // Get the color background from the theme.
252 theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
254 // Get the color background int from the typed value.
255 val colorBackgroundInt = colorBackgroundTypedValue.data
257 // Set the swipe refresh background color.
258 swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
260 // Get the list of locales.
261 val localeList = resources.configuration.locales
263 // Initialize a string builder to extract the locales from the list.
264 val localesStringBuilder = StringBuilder()
266 // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
269 // Populate the string builder with the contents of the locales list.
270 for (i in 0 until localeList.size()) {
271 // Append a comma if there is already an item in the string builder.
273 localesStringBuilder.append(",")
276 // Get the locale from the list.
277 val locale = localeList[i]
279 // Add the locale to the string. `locale` by default displays as `en_US`, but WebView uses the `en-US` format.
280 localesStringBuilder.append(locale.language)
281 localesStringBuilder.append("-")
282 localesStringBuilder.append(locale.country)
284 // If not the first locale, append `;q=0.x`, which drops by .1 for each removal from the main locale until q=0.1.
286 localesStringBuilder.append(";q=0.")
287 localesStringBuilder.append(q)
290 // Decrement `q` if it is greater than 1.
295 // Add a second entry for the language only portion of the locale.
296 localesStringBuilder.append(",")
297 localesStringBuilder.append(locale.language)
299 // Append `1;q=0.x`, which drops by .1 for each removal form the main locale until q=0.1.
300 localesStringBuilder.append(";q=0.")
301 localesStringBuilder.append(q)
303 // Decrement `q` if it is greater than 1.
309 // Instantiate the proxy helper.
310 val proxyHelper = ProxyHelper()
312 // Get the current proxy.
313 val proxy = proxyHelper.getCurrentProxy(this)
315 // Make the progress bar visible.
316 progressBar.visibility = View.VISIBLE
318 // Set the progress bar to be indeterminate.
319 progressBar.isIndeterminate = true
321 // Update the layout.
322 updateLayout(currentUrl)
324 // Instantiate the view headers factory.
325 val viewHeadersFactory: ViewModelProvider.Factory = ViewHeadersFactory(application, currentUrl, userAgent, localesStringBuilder.toString(), proxy, contentResolver, MainWebViewActivity.executorService)
327 // Instantiate the headers view model.
328 headersViewModel = ViewModelProvider(this, viewHeadersFactory)[HeadersViewModel::class.java]
330 // Create a headers observer.
331 headersViewModel.observeHeaders().observe(this) { headersStringArray: Array<SpannableStringBuilder> ->
332 // Populate the text views. This can take a long time, and freezes the user interface, if the response body is particularly large.
333 sslInformationTextView.text = headersStringArray[0]
334 requestHeadersTextView.text = headersStringArray[4]
335 responseMessageTextView.text = headersStringArray[5]
336 responseHeadersTextView.text = headersStringArray[6]
337 responseBodyTextView.text = headersStringArray[7]
339 // Populate the dialog strings.
340 appliedCipherString = headersStringArray[1].toString()
341 availableCiphersString = headersStringArray[2].toString()
342 sslCertificateString = headersStringArray[3].toString()
344 // Hide the progress bar.
345 progressBar.isIndeterminate = false
346 progressBar.visibility = View.GONE
348 // Stop the swipe to refresh indicator if it is running
349 swipeRefreshLayout.isRefreshing = false
352 // Create an error observer.
353 headersViewModel.observeErrors().observe(this) { errorString: String ->
354 // Display an error snackbar if the string is not `""`.
355 if (errorString != "") {
356 if (errorString.startsWith("javax.net.ssl.SSLHandshakeException")) {
357 // Instantiate the untrusted SSL certificate dialog.
358 val untrustedSslCertificateDialog = UntrustedSslCertificateDialog()
360 // Show the untrusted SSL certificate dialog.
361 untrustedSslCertificateDialog.show(supportFragmentManager, getString(R.string.invalid_certificate))
363 // Display a snackbar with the error message.
364 Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
369 // Implement swipe to refresh.
370 swipeRefreshLayout.setOnRefreshListener {
371 // Make the progress bar visible.
372 progressBar.visibility = View.VISIBLE
374 // Set the progress bar to be indeterminate.
375 progressBar.isIndeterminate = true
378 val urlString = urlEditText.text.toString()
380 // Update the layout.
381 updateLayout(urlString)
383 // Get the updated headers.
384 headersViewModel.updateHeaders(urlString, false)
387 // Set the go button on the keyboard to request new headers data.
388 urlEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
389 // Request new headers data if the enter key was pressed.
390 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
391 // Hide the soft keyboard.
392 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
394 // Remove the focus from the URL box.
395 urlEditText.clearFocus()
397 // Make the progress bar visible.
398 progressBar.visibility = View.VISIBLE
400 // Set the progress bar to be indeterminate.
401 progressBar.isIndeterminate = true
404 val urlString = urlEditText.text.toString()
406 // Update the layout.
407 updateLayout(urlString)
409 // Get the updated headers.
410 headersViewModel.updateHeaders(urlString, false)
412 // Consume the key press.
413 return@setOnKeyListener true
415 // Do not consume the key press.
416 return@setOnKeyListener false
421 override fun onCreateOptionsMenu(menu: Menu): Boolean {
423 menuInflater.inflate(R.menu.view_headers_options_menu, menu)
429 override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
430 // Run the appropriate commands.
431 when (menuItem.itemId) {
432 R.id.copy_headers -> { // Copy the headers.
433 // Get the headers string.
434 val headersString = getHeadersString()
436 // Get a handle for the clipboard manager.
437 val clipboardManager = (getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
439 // Place the headers string in a clip data.
440 val headersClipData = ClipData.newPlainText(getString(R.string.view_headers), headersString)
442 // Place the clip data on the clipboard.
443 clipboardManager.setPrimaryClip(headersClipData)
445 // Display a snackbar if the API <= 32 (Android 12L). Beginning in Android 13 the OS displays a notification that covers up the snackbar.
446 if (Build.VERSION.SDK_INT <= 32)
447 Snackbar.make(urlEditText, R.string.headers_copied, Snackbar.LENGTH_SHORT).show()
449 // Consume the event.
453 R.id.share_headers -> { // Share the headers.
454 // Get the headers string.
455 val headersString = getHeadersString()
457 // Create a share intent.
458 val shareIntent = Intent(Intent.ACTION_SEND)
460 // Add the headers string to the intent.
461 shareIntent.putExtra(Intent.EXTRA_TEXT, headersString)
463 // Set the MIME type.
464 shareIntent.type = "text/plain"
466 // Set the intent to open in a new task.
467 shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
470 startActivity(Intent.createChooser(shareIntent, getString(R.string.share)))
472 // Consume the event.
476 R.id.save_headers -> { // Save the headers as a text file.
477 // Get the current URL.
478 val currentUrlString = urlEditText.text.toString()
480 // Get a URI for the current URL.
481 val currentUri = Uri.parse(currentUrlString)
483 // Get the current domain name.
484 val currentDomainName = currentUri.host
486 // Open the file picker.
487 saveTextActivityResultLauncher.launch(getString(R.string.headers_txt, currentDomainName))
489 // Consume the event.
493 R.id.about_view_headers -> { // Display the about dialog.
494 // Instantiate the about dialog fragment.
495 val aboutDialogFragment = AboutViewHeadersDialog()
497 // Show the about alert dialog.
498 aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
500 // Consume the event.
504 else -> { // The home button was selected.
505 // Run the parents class on return.
506 return super.onOptionsItemSelected(menuItem)
511 // 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.
512 fun goBack(@Suppress("UNUSED_PARAMETER") view: View) {
514 NavUtils.navigateUpFromSameTask(this)
517 private fun getHeadersString(): String {
518 // Initialize a headers string builder.
519 val headersStringBuilder = StringBuilder()
521 // Populate the SSL information if it is visible (an HTTPS URL is loaded).
522 if (sslInformationTitleTextView.visibility == View.VISIBLE) {
523 headersStringBuilder.append(sslInformationTitleTextView.text)
524 headersStringBuilder.append("\n")
525 headersStringBuilder.append(sslInformationTextView.text)
526 headersStringBuilder.append("\n\n")
527 headersStringBuilder.append(getString(R.string.available_ciphers))
528 headersStringBuilder.append("\n")
529 headersStringBuilder.append(availableCiphersString)
530 headersStringBuilder.append("\n\n")
531 headersStringBuilder.append(getString(R.string.ssl_certificate))
532 headersStringBuilder.append("\n")
533 headersStringBuilder.append(sslCertificateString)
534 headersStringBuilder.append("\n") // Only a single new line is needed after the certificate as it already ends in one.
537 // Populate the request information if it is visible (an HTTP URL is loaded).
538 if (requestHeadersTitleTextView.visibility == View.VISIBLE) {
539 headersStringBuilder.append(requestHeadersTitleTextView.text)
540 headersStringBuilder.append("\n")
541 headersStringBuilder.append(requestHeadersTextView.text)
542 headersStringBuilder.append("\n\n")
543 headersStringBuilder.append(responseMessageTitleTextView.text)
544 headersStringBuilder.append("\n")
545 headersStringBuilder.append(responseMessageTextView.text)
546 headersStringBuilder.append("\n\n")
549 // Populate the response information, which is visible for both HTTP and content URLs.
550 headersStringBuilder.append(responseHeadersTitleTextView.text)
551 headersStringBuilder.append("\n")
552 headersStringBuilder.append(responseHeadersTextView.text)
553 headersStringBuilder.append("\n\n")
554 headersStringBuilder.append(responseBodyTitleTextView.text)
555 headersStringBuilder.append("\n")
556 headersStringBuilder.append(responseBodyTextView.text)
558 // Return the string.
559 return headersStringBuilder.toString()
562 override fun loadAnyway() {
563 // Load the URL anyway.
564 headersViewModel.updateHeaders(urlEditText.text.toString(), true)
567 // The view parameter cannot be removed because it is called from the layout onClick.
568 fun showCertificate(@Suppress("UNUSED_PARAMETER")view: View) {
569 // Instantiate an SSL certificate dialog.
570 val sslCertificateDialogFragment= ViewHeadersDetailDialog.displayDialog(SSL_CERTIFICATE, sslCertificateString)
573 sslCertificateDialogFragment.show(supportFragmentManager, getString(R.string.ssl_certificate))
576 // The view parameter cannot be removed because it is called from the layout onClick.
577 fun showCiphers(@Suppress("UNUSED_PARAMETER")view: View) {
578 // Instantiate an SSL certificate dialog.
579 val ciphersDialogFragment= ViewHeadersDetailDialog.displayDialog(AVAILABLE_CIPHERS, availableCiphersString, appliedCipherString)
582 ciphersDialogFragment.show(supportFragmentManager, getString(R.string.ssl_certificate))
585 private fun updateLayout(urlString: String) {
586 if (urlString.startsWith("content://")) { // This is a content URL.
587 // Hide the unused views.
588 sslInformationTitleTextView.visibility = View.GONE
589 sslInformationTextView.visibility = View.GONE
590 sslButtonsConstraintLayout.visibility = View.GONE
591 requestHeadersTitleTextView.visibility = View.GONE
592 requestHeadersTextView.visibility = View.GONE
593 responseMessageTitleTextView.visibility = View.GONE
594 responseMessageTextView.visibility = View.GONE
596 // Change the text of the remaining title text views.
597 responseHeadersTitleTextView.setText(R.string.content_metadata)
598 responseBodyTitleTextView.setText(R.string.content_data)
599 } else { // This is not a content URL.
600 // Set the status if the the SSL information views.
601 if (urlString.startsWith("http://")) { // This is an HTTP URL.
602 // Hide the SSL information views.
603 sslInformationTitleTextView.visibility = View.GONE
604 sslInformationTextView.visibility = View.GONE
605 sslButtonsConstraintLayout.visibility = View.GONE
606 } else { // This is not an HTTP URL.
607 // Show the SSL information views.
608 sslInformationTitleTextView.visibility = View.VISIBLE
609 sslInformationTextView.visibility = View.VISIBLE
610 sslButtonsConstraintLayout.visibility = View.VISIBLE
613 // Show the other views.
614 requestHeadersTitleTextView.visibility = View.VISIBLE
615 requestHeadersTextView.visibility = View.VISIBLE
616 responseMessageTitleTextView.visibility = View.VISIBLE
617 responseMessageTextView.visibility = View.VISIBLE
619 // Restore the text of the other title text views.
620 responseHeadersTitleTextView.setText(R.string.response_headers)
621 responseBodyTitleTextView.setText(R.string.response_body)