2 * Copyright 2019-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.os.Build
25 import android.os.Bundle
26 import android.provider.OpenableColumns
27 import android.text.Editable
28 import android.text.TextWatcher
29 import android.util.Base64
30 import android.util.TypedValue
31 import android.view.KeyEvent
32 import android.view.Menu
33 import android.view.MenuItem
34 import android.view.View
35 import android.view.WindowManager
36 import android.view.inputmethod.InputMethodManager
37 import android.webkit.WebView
38 import android.widget.EditText
39 import android.widget.LinearLayout
40 import android.widget.TextView
42 import androidx.activity.result.contract.ActivityResultContracts
43 import androidx.appcompat.app.AppCompatActivity
44 import androidx.appcompat.widget.Toolbar
45 import androidx.preference.PreferenceManager
46 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
48 import com.google.android.material.snackbar.Snackbar
50 import com.stoutner.privacybrowser.BuildConfig
51 import com.stoutner.privacybrowser.R
53 import kotlinx.coroutines.CoroutineScope
54 import kotlinx.coroutines.Dispatchers
55 import kotlinx.coroutines.launch
56 import kotlinx.coroutines.withContext
58 import java.io.BufferedReader
59 import java.io.IOException
60 import java.io.InputStreamReader
62 import java.nio.charset.StandardCharsets
64 // Define the class constants.
65 private const val SCROLL_Y = "A"
67 class LogcatActivity : AppCompatActivity() {
68 // Declare the class variables.
69 private lateinit var inputMethodManager: InputMethodManager
70 private lateinit var logcatPlainTextStringBuilder: StringBuilder
72 // Declare the class views.
73 private lateinit var logcatWebView: WebView
74 private lateinit var searchEditText: EditText
75 private lateinit var searchLinearLayout: LinearLayout
76 private lateinit var swipeRefreshLayout: SwipeRefreshLayout
77 private lateinit var toolbar: Toolbar
79 // Define the save logcat activity result launcher. It must be defined before `onCreate()` is run or the app will crash.
80 private val saveLogcatActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { fileUri ->
81 // Only save the file if the URI is not null, which happens if the user exited the file picker by pressing back.
82 if (fileUri != null) {
84 // Open an output stream.
85 val outputStream = contentResolver.openOutputStream(fileUri)!!
87 // Save the logcat using a coroutine with Dispatchers.IO.
88 CoroutineScope(Dispatchers.Main).launch {
89 withContext(Dispatchers.IO) {
90 // Write the logcat string to the output stream.
91 outputStream.write(logcatPlainTextStringBuilder.toString().toByteArray(StandardCharsets.UTF_8))
93 // Close the output stream.
98 // Get a cursor from the content resolver.
99 val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
101 // Move to the fist row.
102 contentResolverCursor.moveToFirst()
104 // Get the file name from the cursor.
105 val fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
108 contentResolverCursor.close()
110 // Display a snackbar with the saved logcat information.
111 Snackbar.make(logcatWebView, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show()
112 } catch (exception: Exception) {
113 // Display a snackbar with the error message.
114 Snackbar.make(logcatWebView, getString(R.string.error_saving_logcat, exception.toString()), Snackbar.LENGTH_INDEFINITE).show()
119 public override fun onCreate(savedInstanceState: Bundle?) {
120 // Run the default commands.
121 super.onCreate(savedInstanceState)
123 // Get a handle for the shared preferences.
124 val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
126 // Get the preferences.
127 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
128 val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
130 // Disable screenshots if not allowed.
131 if (!allowScreenshots)
132 window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
134 // Set the content view.
136 setContentView(R.layout.logcat_bottom_appbar)
138 setContentView(R.layout.logcat_top_appbar)
140 // Get handles for the views.
141 toolbar = findViewById(R.id.toolbar)
142 val searchCountTextView = findViewById<TextView>(R.id.search_count_textview)
143 searchLinearLayout = findViewById(R.id.search_linearlayout)
144 searchEditText = findViewById(R.id.search_edittext)
145 swipeRefreshLayout = findViewById(R.id.swiperefreshlayout)
146 logcatWebView = findViewById(R.id.logcat_webview)
148 // Set the toolbar as the action bar.
149 setSupportActionBar(toolbar)
151 // Get a handle for the action bar.
152 val actionBar = supportActionBar!!
154 // Display the back arrow in the action bar.
155 actionBar.setDisplayHomeAsUpEnabled(true)
157 // Implement swipe to refresh.
158 swipeRefreshLayout.setOnRefreshListener {
159 // Populate the current logcat.
163 // Set the swipe refresh color scheme according to the theme.
164 swipeRefreshLayout.setColorSchemeResources(R.color.blue_text)
166 // Initialize a color background typed value.
167 val colorBackgroundTypedValue = TypedValue()
169 // Get the color background from the theme.
170 theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
172 // Get the color background int from the typed value.
173 val colorBackgroundInt = colorBackgroundTypedValue.data
175 // Set the swipe refresh background color.
176 swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
178 // Get a handle for the input method manager.
179 inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
181 // Search for the string on the page whenever a character changes in the search edit text.
182 searchEditText.addTextChangedListener(object : TextWatcher {
183 override fun beforeTextChanged(charSequence: CharSequence, start: Int, count: Int, after: Int) {
187 override fun onTextChanged(charSequence: CharSequence, start: Int, before: Int, count: Int) {
191 override fun afterTextChanged(editable: Editable) {
192 // Search for the text in the WebView.
193 logcatWebView.findAllAsync(searchEditText.text.toString())
197 // Set the `check mark` button for the search edit text keyboard to close the soft keyboard.
198 searchEditText.setOnKeyListener { _: View?, keyCode: Int, keyEvent: KeyEvent ->
199 if ((keyEvent.action == KeyEvent.ACTION_DOWN) && (keyCode == KeyEvent.KEYCODE_ENTER)) { // The `enter` key was pressed.
200 // Search for the text in the WebView.
201 logcatWebView.findAllAsync(searchEditText.text.toString())
203 // Hide the soft keyboard.
204 inputMethodManager.hideSoftInputFromWindow(logcatWebView.windowToken, 0)
206 // Consume the event.
207 return@setOnKeyListener true
208 } else { // A different key was pressed.
209 // Do not consume the event.
210 return@setOnKeyListener false
214 // Update the find on page count.
215 logcatWebView.setFindListener { activeMatchOrdinal, numberOfMatches, isDoneCounting ->
216 if (isDoneCounting && (numberOfMatches == 0)) { // There are no matches.
217 // Set the search count text view to be `0/0`.
218 searchCountTextView.setText(R.string.zero_of_zero)
219 } else if (isDoneCounting) { // There are matches.
220 // The active match ordinal is zero-based.
221 val activeMatch = activeMatchOrdinal + 1
223 // Build the match string.
224 val matchString = "$activeMatch/$numberOfMatches"
226 // Update the search count text view.
227 searchCountTextView.text = matchString
231 // Restore the WebView scroll position if the activity has been restarted.
232 if (savedInstanceState != null)
233 logcatWebView.scrollY = savedInstanceState.getInt(SCROLL_Y)
235 // Populate the logcat.
239 override fun onCreateOptionsMenu(menu: Menu): Boolean {
240 // Inflate the menu. This adds items to the action bar.
241 menuInflater.inflate(R.menu.logcat_options_menu, menu)
247 override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
248 // Run the commands that correlate to the selected menu item.
249 when (menuItem.itemId) {
250 R.id.search -> { // Search was selected.
251 // Set the minimum height of the search linear layout to match the toolbar.
252 searchLinearLayout.minimumHeight = toolbar.height
255 toolbar.visibility = View.GONE
257 // Show the search linear layout.
258 searchLinearLayout.visibility = View.VISIBLE
260 // Display the keyboard once the UI has quiesced.
261 searchLinearLayout.post {
262 // Set the focus on the find on page edit text.
263 searchEditText.requestFocus()
265 // Get a handle for the input method manager.
266 val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
268 // Display the keyboard. `0` sets no input flags.
269 inputMethodManager.showSoftInput(searchEditText, 0)
272 // Resume the WebView timers. For some reason they get automatically paused, which prevents searching.
273 logcatWebView.resumeTimers()
275 // Consume the event.
279 R.id.copy -> { // Copy was selected.
280 // Get a handle for the clipboard manager.
281 val clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
283 // Save the logcat in a clip data.
284 val logcatClipData = ClipData.newPlainText(getString(R.string.logcat), logcatPlainTextStringBuilder)
286 // Place the clip data on the clipboard.
287 clipboardManager.setPrimaryClip(logcatClipData)
289 // Display a snackbar if the API <= 32 (Android 12L). Beginning in Android 13 the OS displays a notification that covers up the snackbar.
290 if (Build.VERSION.SDK_INT <= 32)
291 Snackbar.make(logcatWebView, R.string.logcat_copied, Snackbar.LENGTH_SHORT).show()
293 // Consume the event.
297 R.id.save -> { // Save was selected.
298 // Open the file picker.
299 saveLogcatActivityResultLauncher.launch(getString(R.string.privacy_browser_logcat_txt, BuildConfig.VERSION_NAME))
301 // Consume the event.
305 R.id.clear -> { // Clear was selected.
307 // Clear the logcat. `-c` clears the logcat. `-b all` clears all the buffers (instead of just crash, main, and system).
308 val process = Runtime.getRuntime().exec("logcat -b all -c")
310 // Wait for the process to finish.
313 // Reload the logcat.
315 } catch (exception: Exception) {
319 // Consume the event.
323 else -> { // The home button was selected.
324 // Do not consume the event. The system will process the home command.
325 return super.onOptionsItemSelected(menuItem)
330 public override fun onSaveInstanceState(outState: Bundle) {
331 // Run the default commands.
332 super.onSaveInstanceState(outState)
334 // Store the scroll Y position in the bundle.
335 outState.putInt(SCROLL_Y, logcatWebView.scrollY)
338 // The view parameter cannot be removed because it is called from the layout onClick.
339 fun closeSearch(@Suppress("UNUSED_PARAMETER")view: View?) {
340 // Delete the contents of the search edit text.
341 searchEditText.text = null
343 // Clear the highlighted phrases in the logcat WebView.
344 logcatWebView.clearMatches()
346 // Hide the search linear layout.
347 searchLinearLayout.visibility = View.GONE
350 toolbar.visibility = View.VISIBLE
352 // Hide the keyboard.
353 inputMethodManager.hideSoftInputFromWindow(toolbar.windowToken, 0)
356 private fun populateLogcat() {
358 // Get the logcat. `-b all` gets all the buffers (instead of just crash, main, and system). `-v long` produces more complete information. `-d` dumps the logcat and exits.
359 val getLogcatProcess = Runtime.getRuntime().exec("logcat -b all -v long -d")
361 // Wrap the logcat in a buffered reader.
362 val logcatBufferedReader = BufferedReader(InputStreamReader(getLogcatProcess.inputStream))
364 // Reset the logcat plain text string.
365 logcatPlainTextStringBuilder = StringBuilder()
367 // Create a logcat HTML string builder.
368 val logcatHtmlStringBuilder = StringBuilder()
370 // Populate the initial HTML.
371 logcatHtmlStringBuilder.append("<html>")
372 logcatHtmlStringBuilder.append("<head>")
373 logcatHtmlStringBuilder.append("<style>")
375 // Set the word break so that lines never exceed the width of the screen.
376 logcatHtmlStringBuilder.append("body { word-break: break-word; }")
379 logcatHtmlStringBuilder.append("@media (prefers-color-scheme: dark) { body { color: #C1C1C1; /* Gray 350 */ background-color: #303030; /* Gray 860 */ } }")
380 logcatHtmlStringBuilder.append("span.header { color: #0D47A1; /* Blue 900 */ } @media (prefers-color-scheme: dark) { span.header { color: #8AB4F8; /* Violet 500 */ } }")
381 logcatHtmlStringBuilder.append("strong.crash { color: #B71C1C; /* Red 900. */ } @media (prefers-color-scheme: dark) { strong.crash { color: #E24B4C; /* Red Night. */ } }")
382 logcatHtmlStringBuilder.append("span.crash { color: #EF5350; /* Red 400. */ } @media (prefers-color-scheme: dark) { span.crash { color: #EF9A9A; /* Red Night. */ } }")
384 // Close the style tag.
385 logcatHtmlStringBuilder.append("</style>")
387 // Respect dark mode.
388 logcatHtmlStringBuilder.append("<meta name=\"color-scheme\" content=\"light dark\">")
390 // Start the HTML body.
391 logcatHtmlStringBuilder.append("</head>")
392 logcatHtmlStringBuilder.append("<body>")
394 // Create a logcat line string.
395 var logcatLineString: String?
397 while (logcatBufferedReader.readLine().also { logcatLineString = it } != null) {
398 // Populate the logcat plain text string builder.
399 logcatPlainTextStringBuilder.append(logcatLineString)
402 logcatPlainTextStringBuilder.append("\n")
404 // Trim the string, which is necessary for correct detection of lines that start with `at`.
405 logcatLineString = logcatLineString!!.trim()
407 // Apply syntax highlighting to the logcat.
408 if (logcatLineString!!.contains("crash") || logcatLineString!!.contains("Exception") ) { // Colorize crashes.
409 logcatHtmlStringBuilder.append("<strong class=\"crash\">")
410 logcatHtmlStringBuilder.append(logcatLineString)
411 logcatHtmlStringBuilder.append("</strong>")
412 } else if (logcatLineString!!.startsWith("at") || logcatLineString!!.startsWith("Process:") || logcatLineString!!.contains("FATAL")) { // Colorize lines relating to crashes.
413 logcatHtmlStringBuilder.append("<span class=\"crash\">")
414 logcatHtmlStringBuilder.append(logcatLineString)
415 logcatHtmlStringBuilder.append("</span>")
416 } else if (logcatLineString!!.startsWith("-")) { // Colorize the headers.
417 logcatHtmlStringBuilder.append("<span class=\"header\">")
418 logcatHtmlStringBuilder.append(logcatLineString)
419 logcatHtmlStringBuilder.append("</span>")
420 } else if (logcatLineString!!.startsWith("[ ")) { // Colorize the time stamps.
421 logcatHtmlStringBuilder.append("<span style=color:gray>")
422 logcatHtmlStringBuilder.append(logcatLineString)
423 logcatHtmlStringBuilder.append("</span>")
424 } else { // Display the standard lines.
425 logcatHtmlStringBuilder.append(logcatLineString)
429 logcatHtmlStringBuilder.append("<br>")
433 logcatHtmlStringBuilder.append("</body>")
434 logcatHtmlStringBuilder.append("</html>")
436 // Encode the logcat HTML.
437 val base64EncodedLogcatHtml: String = Base64.encodeToString(logcatHtmlStringBuilder.toString().toByteArray(Charsets.UTF_8), Base64.NO_PADDING)
439 // Load the encoded logcat.
440 logcatWebView.loadData(base64EncodedLogcatHtml, "text/html", "base64")
442 // Close the buffered reader.
443 logcatBufferedReader.close()
444 } catch (exception: IOException) {
448 // Stop the swipe to refresh animation if it is displayed.
449 swipeRefreshLayout.isRefreshing = false
452 // The view parameter cannot be removed because it is called from the layout onClick.
453 fun searchNext(@Suppress("UNUSED_PARAMETER")view: View?) {
454 // Go to the next highlighted phrase on the page. `true` goes forwards instead of backwards.
455 logcatWebView.findNext(true)
458 // The view parameter cannot be removed because it is called from the layout onClick.
459 fun searchPrevious(@Suppress("UNUSED_PARAMETER")view: View?) {
460 // Go to the previous highlighted phrase on the page. `false` goes backwards instead of forwards.
461 logcatWebView.findNext(false)