]> gitweb.stoutner.com Git - PrivacyBrowserAndroid.git/blob - app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.kt
First wrong button text in View Headers in night theme. https://redmine.stoutner...
[PrivacyBrowserAndroid.git] / app / src / main / java / com / stoutner / privacybrowser / activities / LogcatActivity.kt
1 /*
2  * Copyright 2019-2024 Soren Stoutner <soren@stoutner.com>.
3  *
4  * This file is part of Privacy Browser Android <https://www.stoutner.com/privacy-browser-android/>.
5  *
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.
10  *
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.
15  *
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/>.
18  */
19
20 package com.stoutner.privacybrowser.activities
21
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
41
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
47
48 import com.google.android.material.snackbar.Snackbar
49
50 import com.stoutner.privacybrowser.BuildConfig
51 import com.stoutner.privacybrowser.R
52
53 import kotlinx.coroutines.CoroutineScope
54 import kotlinx.coroutines.Dispatchers
55 import kotlinx.coroutines.launch
56 import kotlinx.coroutines.withContext
57
58 import java.io.BufferedReader
59 import java.io.IOException
60 import java.io.InputStreamReader
61
62 import java.nio.charset.StandardCharsets
63
64 // Define the class constants.
65 private const val SCROLL_Y = "A"
66
67 class LogcatActivity : AppCompatActivity() {
68     // Declare the class variables.
69     private lateinit var inputMethodManager: InputMethodManager
70     private lateinit var logcatPlainTextStringBuilder: StringBuilder
71
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
78
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) {
83             try {
84                 // Open an output stream.
85                 val outputStream = contentResolver.openOutputStream(fileUri)!!
86
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))
92
93                         // Close the output stream.
94                         outputStream.close()
95                     }
96                 }
97
98                 // Get a cursor from the content resolver.
99                 val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
100
101                 // Move to the fist row.
102                 contentResolverCursor.moveToFirst()
103
104                 // Get the file name from the cursor.
105                 val fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
106
107                 // Close the cursor.
108                 contentResolverCursor.close()
109
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()
115             }
116         }
117     }
118
119     public override fun onCreate(savedInstanceState: Bundle?) {
120         // Run the default commands.
121         super.onCreate(savedInstanceState)
122
123         // Get a handle for the shared preferences.
124         val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
125
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)
129
130         // Disable screenshots if not allowed.
131         if (!allowScreenshots)
132             window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
133
134         // Set the content view.
135         if (bottomAppBar)
136             setContentView(R.layout.logcat_bottom_appbar)
137         else
138             setContentView(R.layout.logcat_top_appbar)
139
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)
147
148         // Set the toolbar as the action bar.
149         setSupportActionBar(toolbar)
150
151         // Get a handle for the action bar.
152         val actionBar = supportActionBar!!
153
154         // Display the back arrow in the action bar.
155         actionBar.setDisplayHomeAsUpEnabled(true)
156
157         // Implement swipe to refresh.
158         swipeRefreshLayout.setOnRefreshListener {
159             // Populate the current logcat.
160             populateLogcat()
161         }
162
163         // Set the swipe refresh color scheme according to the theme.
164         swipeRefreshLayout.setColorSchemeResources(R.color.blue_text)
165
166         // Initialize a color background typed value.
167         val colorBackgroundTypedValue = TypedValue()
168
169         // Get the color background from the theme.
170         theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
171
172         // Get the color background int from the typed value.
173         val colorBackgroundInt = colorBackgroundTypedValue.data
174
175         // Set the swipe refresh background color.
176         swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
177
178         // Get a handle for the input method manager.
179         inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
180
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) {
184                 // Do nothing.
185             }
186
187             override fun onTextChanged(charSequence: CharSequence, start: Int, before: Int, count: Int) {
188                 // Do nothing.
189             }
190
191             override fun afterTextChanged(editable: Editable) {
192                 // Search for the text in the WebView.
193                 logcatWebView.findAllAsync(searchEditText.text.toString())
194             }
195         })
196
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())
202
203                 // Hide the soft keyboard.
204                 inputMethodManager.hideSoftInputFromWindow(logcatWebView.windowToken, 0)
205
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
211             }
212         }
213
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
222
223                 // Build the match string.
224                 val matchString = "$activeMatch/$numberOfMatches"
225
226                 // Update the search count text view.
227                 searchCountTextView.text = matchString
228             }
229         }
230
231         // Restore the WebView scroll position if the activity has been restarted.
232         if (savedInstanceState != null)
233             logcatWebView.scrollY = savedInstanceState.getInt(SCROLL_Y)
234
235         // Populate the logcat.
236         populateLogcat()
237     }
238
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)
242
243         // Display the menu.
244         return true
245     }
246
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
253
254                 // Hide the toolbar.
255                 toolbar.visibility = View.GONE
256
257                 // Show the search linear layout.
258                 searchLinearLayout.visibility = View.VISIBLE
259
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()
264
265                     // Get a handle for the input method manager.
266                     val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
267
268                     // Display the keyboard.  `0` sets no input flags.
269                     inputMethodManager.showSoftInput(searchEditText, 0)
270                 }
271
272                 // Resume the WebView timers.  For some reason they get automatically paused, which prevents searching.
273                 logcatWebView.resumeTimers()
274
275                 // Consume the event.
276                 return true
277             }
278
279             R.id.copy -> {  // Copy was selected.
280                 // Get a handle for the clipboard manager.
281                 val clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
282
283                 // Save the logcat in a clip data.
284                 val logcatClipData = ClipData.newPlainText(getString(R.string.logcat), logcatPlainTextStringBuilder)
285
286                 // Place the clip data on the clipboard.
287                 clipboardManager.setPrimaryClip(logcatClipData)
288
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()
292
293                 // Consume the event.
294                 return true
295             }
296
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))
300
301                 // Consume the event.
302                 return true
303             }
304
305             R.id.clear -> {  // Clear was selected.
306                 try {
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")
309
310                     // Wait for the process to finish.
311                     process.waitFor()
312
313                     // Reload the logcat.
314                     populateLogcat()
315                 } catch (exception: Exception) {
316                     // Do nothing.
317                 }
318
319                 // Consume the event.
320                 return true
321             }
322
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)
326             }
327         }
328     }
329
330     public override fun onSaveInstanceState(savedInstanceState: Bundle) {
331         // Run the default commands.
332         super.onSaveInstanceState(savedInstanceState)
333
334         // Store the scroll Y position in the bundle.
335         savedInstanceState.putInt(SCROLL_Y, logcatWebView.scrollY)
336     }
337
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
342
343         // Clear the highlighted phrases in the logcat WebView.
344         logcatWebView.clearMatches()
345
346         // Hide the search linear layout.
347         searchLinearLayout.visibility = View.GONE
348
349         // Show the toolbar.
350         toolbar.visibility = View.VISIBLE
351
352         // Hide the keyboard.
353         inputMethodManager.hideSoftInputFromWindow(toolbar.windowToken, 0)
354     }
355
356     private fun populateLogcat() {
357         try {
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")
360
361             // Wrap the logcat in a buffered reader.
362             val logcatBufferedReader = BufferedReader(InputStreamReader(getLogcatProcess.inputStream))
363
364             // Reset the logcat plain text string.
365             logcatPlainTextStringBuilder = StringBuilder()
366
367             // Create a logcat HTML string builder.
368             val logcatHtmlStringBuilder = StringBuilder()
369
370             // Populate the initial HTML.
371             logcatHtmlStringBuilder.append("<html>")
372             logcatHtmlStringBuilder.append("<head>")
373             logcatHtmlStringBuilder.append("<style>")
374
375             // Set the word break so that lines never exceed the width of the screen.
376             logcatHtmlStringBuilder.append("body { word-break: break-word; }")
377
378             // Set the colors.
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. */ } }")
383
384             // Close the style tag.
385             logcatHtmlStringBuilder.append("</style>")
386
387             // Respect dark mode.
388             logcatHtmlStringBuilder.append("<meta name=\"color-scheme\" content=\"light dark\">")
389
390             // Start the HTML body.
391             logcatHtmlStringBuilder.append("</head>")
392             logcatHtmlStringBuilder.append("<body>")
393
394             // Create a logcat line string.
395             var logcatLineString: String?
396
397             while (logcatBufferedReader.readLine().also { logcatLineString = it } != null) {
398                 // Populate the logcat plain text string builder.
399                 logcatPlainTextStringBuilder.append(logcatLineString)
400
401                 // Add a line break.
402                 logcatPlainTextStringBuilder.append("\n")
403
404                 // Trim the string, which is necessary for correct detection of lines that start with `at`.
405                 logcatLineString = logcatLineString!!.trim()
406
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)
426                 }
427
428                 // Add a line break.
429                 logcatHtmlStringBuilder.append("<br>")
430             }
431
432             // Close the HTML.
433             logcatHtmlStringBuilder.append("</body>")
434             logcatHtmlStringBuilder.append("</html>")
435
436             // Encode the logcat HTML.
437             val base64EncodedLogcatHtml: String = Base64.encodeToString(logcatHtmlStringBuilder.toString().toByteArray(Charsets.UTF_8), Base64.NO_PADDING)
438
439             // Load the encoded logcat.
440             logcatWebView.loadData(base64EncodedLogcatHtml, "text/html", "base64")
441
442             // Close the buffered reader.
443             logcatBufferedReader.close()
444         } catch (exception: IOException) {
445             // Do nothing.
446         }
447
448         // Stop the swipe to refresh animation if it is displayed.
449         swipeRefreshLayout.isRefreshing = false
450     }
451
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)
456     }
457
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)
462     }
463 }