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.lifecycle.ViewModelProvider
50 import androidx.preference.PreferenceManager
51 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
53 import com.google.android.material.snackbar.Snackbar
55 import com.stoutner.privacybrowser.R
56 import com.stoutner.privacybrowser.dialogs.AVAILABLE_CIPHERS
57 import com.stoutner.privacybrowser.dialogs.SSL_CERTIFICATE
58 import com.stoutner.privacybrowser.dialogs.AboutViewHeadersDialog
59 import com.stoutner.privacybrowser.dialogs.ViewHeadersDetailDialog
60 import com.stoutner.privacybrowser.dialogs.UntrustedSslCertificateDialog
61 import com.stoutner.privacybrowser.dialogs.UntrustedSslCertificateDialog.UntrustedSslCertificateListener
62 import com.stoutner.privacybrowser.helpers.ProxyHelper
63 import com.stoutner.privacybrowser.helpers.UrlHelper
64 import com.stoutner.privacybrowser.viewmodelfactories.ViewHeadersFactory
65 import com.stoutner.privacybrowser.viewmodels.HeadersViewModel
67 import kotlinx.coroutines.CoroutineScope
68 import kotlinx.coroutines.Dispatchers
69 import kotlinx.coroutines.launch
70 import kotlinx.coroutines.withContext
72 import java.lang.Exception
73 import java.nio.charset.StandardCharsets
75 // Define the public constants.
76 const val USER_AGENT = "user_agent"
78 class ViewHeadersActivity: AppCompatActivity(), UntrustedSslCertificateListener {
79 // Declare the class variables.
80 private lateinit var appliedCipherString: String
81 private lateinit var availableCiphersString: String
82 private lateinit var headersViewModel: HeadersViewModel
83 private lateinit var initialGrayColorSpan: ForegroundColorSpan
84 private lateinit var finalGrayColorSpan: ForegroundColorSpan
85 private lateinit var redColorSpan: ForegroundColorSpan
86 private lateinit var sslCertificateString: String
88 // Declare the class views.
89 private lateinit var urlEditText: EditText
90 private lateinit var sslInformationTitleTextView: TextView
91 private lateinit var sslInformationTextView: TextView
92 private lateinit var sslButtonsConstraintLayout: ConstraintLayout
93 private lateinit var requestHeadersTitleTextView: TextView
94 private lateinit var requestHeadersTextView: TextView
95 private lateinit var responseMessageTitleTextView: TextView
96 private lateinit var responseMessageTextView: TextView
97 private lateinit var responseHeadersTitleTextView: TextView
98 private lateinit var responseHeadersTextView: TextView
99 private lateinit var responseBodyTitleTextView: TextView
100 private lateinit var responseBodyTextView: TextView
102 // Define the save text activity result launcher. It must be defined before `onCreate()` is run or the app will crash.
103 private val saveTextActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { fileUri ->
104 // Only save the file if the URI is not null, which happens if the user exited the file picker by pressing back.
105 if (fileUri != null) {
106 // Get a cursor from the content resolver.
107 val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
109 // Move to the first row.
110 contentResolverCursor.moveToFirst()
112 // Get the file name from the cursor.
113 val fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
116 contentResolverCursor.close()
119 // Get the about version string.
120 val headersString = getHeadersString()
122 // Open an output stream.
123 val outputStream = contentResolver.openOutputStream(fileUri)!!
125 // Save the headers using a coroutine with Dispatchers.IO.
126 CoroutineScope(Dispatchers.Main).launch {
127 withContext(Dispatchers.IO) {
128 // Write the headers string to the output stream.
129 outputStream.write(headersString.toByteArray(StandardCharsets.UTF_8))
131 // Close the output stream.
136 // Display a snackbar with the saved logcat information.
137 Snackbar.make(urlEditText, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show()
138 } catch (exception: Exception) {
139 // Display a snackbar with the error message.
140 Snackbar.make(urlEditText, getString(R.string.error_saving_file, fileNameString, exception.toString()), Snackbar.LENGTH_INDEFINITE).show()
145 override fun onCreate(savedInstanceState: Bundle?) {
146 // Get a handle for the shared preferences.
147 val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
149 // Get the preferences.
150 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
151 val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
153 // Disable screenshots if not allowed.
154 if (!allowScreenshots)
155 window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
157 // Run the default commands.
158 super.onCreate(savedInstanceState)
160 // Get the launching intent
163 // Get the information from the intent.
164 val currentUrl = intent.getStringExtra(CURRENT_URL)!!
165 val userAgent = intent.getStringExtra(USER_AGENT)!!
167 // Set the content view.
169 setContentView(R.layout.view_headers_bottom_appbar)
171 setContentView(R.layout.view_headers_top_appbar)
173 // Get a handle for the toolbar.
174 val toolbar = findViewById<Toolbar>(R.id.toolbar)
176 // Set the support action bar.
177 setSupportActionBar(toolbar)
179 // Get a handle for the action bar.
180 val actionBar = supportActionBar!!
182 // Add the custom layout to the action bar.
183 actionBar.setCustomView(R.layout.view_headers_appbar_custom_view)
185 // Instruct the action bar to display a custom layout.
186 actionBar.displayOptions = ActionBar.DISPLAY_SHOW_CUSTOM or ActionBar.DISPLAY_HOME_AS_UP
188 // Get handles for the views.
189 urlEditText = findViewById(R.id.url_edittext)
190 val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
191 val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swiperefreshlayout)
192 sslInformationTitleTextView = findViewById(R.id.ssl_information_title_textview)
193 sslInformationTextView = findViewById(R.id.ssl_information_textview)
194 sslButtonsConstraintLayout = findViewById(R.id.ssl_buttons_constraintlayout)
195 requestHeadersTitleTextView = findViewById(R.id.request_headers_title_textview)
196 requestHeadersTextView = findViewById(R.id.request_headers_textview)
197 responseMessageTitleTextView = findViewById(R.id.response_message_title_textview)
198 responseMessageTextView = findViewById(R.id.response_message_textview)
199 responseHeadersTitleTextView = findViewById(R.id.response_headers_title_textview)
200 responseHeadersTextView = findViewById(R.id.response_headers_textview)
201 responseBodyTitleTextView = findViewById(R.id.response_body_title_textview)
202 responseBodyTextView = findViewById(R.id.response_body_textview)
204 // Initialize the gray foreground color spans for highlighting the URLs.
205 initialGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
206 finalGrayColorSpan = ForegroundColorSpan(getColor(R.color.gray_500))
207 redColorSpan = ForegroundColorSpan(getColor(R.color.red_text))
209 // Get a handle for the input method manager, which is used to hide the keyboard.
210 val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
212 // Remove the formatting from the URL when the user is editing the text.
213 urlEditText.onFocusChangeListener = OnFocusChangeListener { _: View?, hasFocus: Boolean ->
214 if (hasFocus) { // The user is editing the URL text box.
215 // Get the foreground color spans.
216 val foregroundColorSpans: Array<ForegroundColorSpan> = urlEditText.text.getSpans(0, urlEditText.text.length, ForegroundColorSpan::class.java)
218 // Remove each foreground color span that highlights the text.
219 for (foregroundColorSpan in foregroundColorSpans)
220 urlEditText.text.removeSpan(foregroundColorSpan)
221 } else { // The user has stopped editing the URL text box.
222 // Hide the soft keyboard.
223 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
225 // Move to the beginning of the string.
226 urlEditText.setSelection(0)
228 // Store the URL text in the intent, so update layout uses the new text if the app is restarted.
229 intent.putExtra(CURRENT_URL, urlEditText.text.toString())
231 // Reapply the highlighting.
232 UrlHelper.highlightSyntax(urlEditText, initialGrayColorSpan, finalGrayColorSpan, redColorSpan)
236 // Populate the URL text box.
237 urlEditText.setText(currentUrl)
239 // Apply the initial text highlighting to the URL.
240 UrlHelper.highlightSyntax(urlEditText, initialGrayColorSpan, finalGrayColorSpan, redColorSpan)
242 // Set the refresh color scheme according to the theme.
243 swipeRefreshLayout.setColorSchemeResources(R.color.blue_text)
245 // Initialize a color background typed value.
246 val colorBackgroundTypedValue = TypedValue()
248 // Get the color background from the theme.
249 theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
251 // Get the color background int from the typed value.
252 val colorBackgroundInt = colorBackgroundTypedValue.data
254 // Set the swipe refresh background color.
255 swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
257 // Get the list of locales.
258 val localeList = resources.configuration.locales
260 // Initialize a string builder to extract the locales from the list.
261 val localesStringBuilder = StringBuilder()
263 // Initialize a `q` value, which is used by `WebView` to indicate the order of importance of the languages.
266 // Populate the string builder with the contents of the locales list.
267 for (i in 0 until localeList.size()) {
268 // Append a comma if there is already an item in the string builder.
270 localesStringBuilder.append(",")
273 // Get the locale from the list.
274 val locale = localeList[i]
276 // Add the locale to the string. `locale` by default displays as `en_US`, but WebView uses the `en-US` format.
277 localesStringBuilder.append(locale.language)
278 localesStringBuilder.append("-")
279 localesStringBuilder.append(locale.country)
281 // If not the first locale, append `;q=0.x`, which drops by .1 for each removal from the main locale until q=0.1.
283 localesStringBuilder.append(";q=0.")
284 localesStringBuilder.append(q)
287 // Decrement `q` if it is greater than 1.
292 // Add a second entry for the language only portion of the locale.
293 localesStringBuilder.append(",")
294 localesStringBuilder.append(locale.language)
296 // Append `1;q=0.x`, which drops by .1 for each removal form the main locale until q=0.1.
297 localesStringBuilder.append(";q=0.")
298 localesStringBuilder.append(q)
300 // Decrement `q` if it is greater than 1.
306 // Instantiate the proxy helper.
307 val proxyHelper = ProxyHelper()
309 // Get the current proxy.
310 val proxy = proxyHelper.getCurrentProxy(this)
312 // Make the progress bar visible.
313 progressBar.visibility = View.VISIBLE
315 // Set the progress bar to be indeterminate.
316 progressBar.isIndeterminate = true
318 // Update the layout.
319 updateLayout(currentUrl)
321 // Instantiate the view headers factory.
322 val viewHeadersFactory: ViewModelProvider.Factory = ViewHeadersFactory(application, currentUrl, userAgent, localesStringBuilder.toString(), proxy, contentResolver, MainWebViewActivity.executorService)
324 // Instantiate the headers view model.
325 headersViewModel = ViewModelProvider(this, viewHeadersFactory)[HeadersViewModel::class.java]
327 // Create a headers observer.
328 headersViewModel.observeHeaders().observe(this) { headersStringArray: Array<SpannableStringBuilder> ->
329 // Populate the text views. This can take a long time, and freezes the user interface, if the response body is particularly large.
330 sslInformationTextView.text = headersStringArray[0]
331 requestHeadersTextView.text = headersStringArray[4]
332 responseMessageTextView.text = headersStringArray[5]
333 responseHeadersTextView.text = headersStringArray[6]
334 responseBodyTextView.text = headersStringArray[7]
336 // Populate the dialog strings.
337 appliedCipherString = headersStringArray[1].toString()
338 availableCiphersString = headersStringArray[2].toString()
339 sslCertificateString = headersStringArray[3].toString()
341 // Hide the progress bar.
342 progressBar.isIndeterminate = false
343 progressBar.visibility = View.GONE
345 // Stop the swipe to refresh indicator if it is running
346 swipeRefreshLayout.isRefreshing = false
349 // Create an error observer.
350 headersViewModel.observeErrors().observe(this) { errorString: String ->
351 // Display an error snackbar if the string is not `""`.
352 if (errorString != "") {
353 if (errorString.startsWith("javax.net.ssl.SSLHandshakeException")) {
354 // Instantiate the untrusted SSL certificate dialog.
355 val untrustedSslCertificateDialog = UntrustedSslCertificateDialog()
357 // Show the untrusted SSL certificate dialog.
358 untrustedSslCertificateDialog.show(supportFragmentManager, getString(R.string.invalid_certificate))
360 // Display a snackbar with the error message.
361 Snackbar.make(swipeRefreshLayout, errorString, Snackbar.LENGTH_LONG).show()
366 // Implement swipe to refresh.
367 swipeRefreshLayout.setOnRefreshListener {
368 // Make the progress bar visible.
369 progressBar.visibility = View.VISIBLE
371 // Set the progress bar to be indeterminate.
372 progressBar.isIndeterminate = true
375 val urlString = urlEditText.text.toString()
377 // Update the layout.
378 updateLayout(urlString)
380 // Get the updated headers.
381 headersViewModel.updateHeaders(urlString, false)
384 // Set the go button on the keyboard to request new headers data.
385 urlEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
386 // Request new headers data if the enter key was pressed.
387 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
388 // Hide the soft keyboard.
389 inputMethodManager.hideSoftInputFromWindow(urlEditText.windowToken, 0)
391 // Remove the focus from the URL box.
392 urlEditText.clearFocus()
394 // Make the progress bar visible.
395 progressBar.visibility = View.VISIBLE
397 // Set the progress bar to be indeterminate.
398 progressBar.isIndeterminate = true
401 val urlString = urlEditText.text.toString()
403 // Update the layout.
404 updateLayout(urlString)
406 // Get the updated headers.
407 headersViewModel.updateHeaders(urlString, false)
409 // Consume the key press.
410 return@setOnKeyListener true
412 // Do not consume the key press.
413 return@setOnKeyListener false
418 override fun onCreateOptionsMenu(menu: Menu): Boolean {
420 menuInflater.inflate(R.menu.view_headers_options_menu, menu)
426 override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
427 // Run the commands that correlate to the selected menu item.
428 when (menuItem.itemId) {
429 R.id.copy_headers -> { // Copy the headers.
430 // Get the headers string.
431 val headersString = getHeadersString()
433 // Get a handle for the clipboard manager.
434 val clipboardManager = (getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
436 // Place the headers string in a clip data.
437 val headersClipData = ClipData.newPlainText(getString(R.string.view_headers), headersString)
439 // Place the clip data on the clipboard.
440 clipboardManager.setPrimaryClip(headersClipData)
442 // Display a snackbar if the API <= 32 (Android 12L). Beginning in Android 13 the OS displays a notification that covers up the snackbar.
443 if (Build.VERSION.SDK_INT <= 32)
444 Snackbar.make(urlEditText, R.string.headers_copied, Snackbar.LENGTH_SHORT).show()
446 // Consume the event.
450 R.id.share_headers -> { // Share the headers.
451 // Get the headers string.
452 val headersString = getHeadersString()
454 // Create a share intent.
455 val shareIntent = Intent(Intent.ACTION_SEND)
457 // Add the headers string to the intent.
458 shareIntent.putExtra(Intent.EXTRA_TEXT, headersString)
460 // Set the MIME type.
461 shareIntent.type = "text/plain"
463 // Set the intent to open in a new task.
464 shareIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
467 startActivity(Intent.createChooser(shareIntent, getString(R.string.share)))
469 // Consume the event.
473 R.id.save_headers -> { // Save the headers as a text file.
474 // Get the current URL.
475 val currentUrlString = urlEditText.text.toString()
477 // Get a URI for the current URL.
478 val currentUri = Uri.parse(currentUrlString)
480 // Get the current domain name.
481 val currentDomainName = currentUri.host
483 // Open the file picker.
484 saveTextActivityResultLauncher.launch(getString(R.string.headers_txt, currentDomainName))
486 // Consume the event.
490 R.id.about_view_headers -> { // Display the about dialog.
491 // Instantiate the about dialog fragment.
492 val aboutDialogFragment = AboutViewHeadersDialog()
494 // Show the about alert dialog.
495 aboutDialogFragment.show(supportFragmentManager, getString(R.string.about))
497 // Consume the event.
501 else -> { // The home button was selected.
502 // Do not consume the event. The system will process the home command.
503 return super.onOptionsItemSelected(menuItem)
508 private fun getHeadersString(): String {
509 // Initialize a headers string builder.
510 val headersStringBuilder = StringBuilder()
512 // Populate the SSL information if it is visible (an HTTPS URL is loaded).
513 if (sslInformationTitleTextView.visibility == View.VISIBLE) {
514 headersStringBuilder.append(sslInformationTitleTextView.text)
515 headersStringBuilder.append("\n")
516 headersStringBuilder.append(sslInformationTextView.text)
517 headersStringBuilder.append("\n\n")
518 headersStringBuilder.append(getString(R.string.available_ciphers))
519 headersStringBuilder.append("\n")
520 headersStringBuilder.append(availableCiphersString)
521 headersStringBuilder.append("\n\n")
522 headersStringBuilder.append(getString(R.string.ssl_certificate))
523 headersStringBuilder.append("\n")
524 headersStringBuilder.append(sslCertificateString)
525 headersStringBuilder.append("\n") // Only a single new line is needed after the certificate as it already ends in one.
528 // Populate the request information if it is visible (an HTTP URL is loaded).
529 if (requestHeadersTitleTextView.visibility == View.VISIBLE) {
530 headersStringBuilder.append(requestHeadersTitleTextView.text)
531 headersStringBuilder.append("\n")
532 headersStringBuilder.append(requestHeadersTextView.text)
533 headersStringBuilder.append("\n\n")
534 headersStringBuilder.append(responseMessageTitleTextView.text)
535 headersStringBuilder.append("\n")
536 headersStringBuilder.append(responseMessageTextView.text)
537 headersStringBuilder.append("\n\n")
540 // Populate the response information, which is visible for both HTTP and content URLs.
541 headersStringBuilder.append(responseHeadersTitleTextView.text)
542 headersStringBuilder.append("\n")
543 headersStringBuilder.append(responseHeadersTextView.text)
544 headersStringBuilder.append("\n\n")
545 headersStringBuilder.append(responseBodyTitleTextView.text)
546 headersStringBuilder.append("\n")
547 headersStringBuilder.append(responseBodyTextView.text)
549 // Return the string.
550 return headersStringBuilder.toString()
553 override fun loadAnyway() {
554 // Load the URL anyway.
555 headersViewModel.updateHeaders(urlEditText.text.toString(), true)
558 // The view parameter cannot be removed because it is called from the layout onClick.
559 fun showCertificate(@Suppress("UNUSED_PARAMETER")view: View) {
560 // Instantiate an SSL certificate dialog.
561 val sslCertificateDialogFragment= ViewHeadersDetailDialog.displayDialog(SSL_CERTIFICATE, sslCertificateString)
564 sslCertificateDialogFragment.show(supportFragmentManager, getString(R.string.ssl_certificate))
567 // The view parameter cannot be removed because it is called from the layout onClick.
568 fun showCiphers(@Suppress("UNUSED_PARAMETER")view: View) {
569 // Instantiate an SSL certificate dialog.
570 val ciphersDialogFragment= ViewHeadersDetailDialog.displayDialog(AVAILABLE_CIPHERS, availableCiphersString, appliedCipherString)
573 ciphersDialogFragment.show(supportFragmentManager, getString(R.string.ssl_certificate))
576 private fun updateLayout(urlString: String) {
577 if (urlString.startsWith("content://")) { // This is a content URL.
578 // Hide the unused views.
579 sslInformationTitleTextView.visibility = View.GONE
580 sslInformationTextView.visibility = View.GONE
581 sslButtonsConstraintLayout.visibility = View.GONE
582 requestHeadersTitleTextView.visibility = View.GONE
583 requestHeadersTextView.visibility = View.GONE
584 responseMessageTitleTextView.visibility = View.GONE
585 responseMessageTextView.visibility = View.GONE
587 // Change the text of the remaining title text views.
588 responseHeadersTitleTextView.setText(R.string.content_metadata)
589 responseBodyTitleTextView.setText(R.string.content_data)
590 } else { // This is not a content URL.
591 // Set the status if the the SSL information views.
592 if (urlString.startsWith("http://")) { // This is an HTTP URL.
593 // Hide the SSL information views.
594 sslInformationTitleTextView.visibility = View.GONE
595 sslInformationTextView.visibility = View.GONE
596 sslButtonsConstraintLayout.visibility = View.GONE
597 } else { // This is not an HTTP URL.
598 // Show the SSL information views.
599 sslInformationTitleTextView.visibility = View.VISIBLE
600 sslInformationTextView.visibility = View.VISIBLE
601 sslButtonsConstraintLayout.visibility = View.VISIBLE
604 // Show the other views.
605 requestHeadersTitleTextView.visibility = View.VISIBLE
606 requestHeadersTextView.visibility = View.VISIBLE
607 responseMessageTitleTextView.visibility = View.VISIBLE
608 responseMessageTextView.visibility = View.VISIBLE
610 // Restore the text of the other title text views.
611 responseHeadersTitleTextView.setText(R.string.response_headers)
612 responseBodyTitleTextView.setText(R.string.response_body)