]> gitweb.stoutner.com Git - PrivacyBrowserAndroid.git/blobdiff - 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
index 9c6e5de181b27d8e15b8960948d7f2b17cc23af1..a0e5ad8ab831acbc7785089bf336b9eaf57cad6d 100644 (file)
@@ -1,36 +1,43 @@
 /*
- * Copyright © 2019-2021 Soren Stoutner <soren@stoutner.com>.
+ * Copyright 2019-2024 Soren Stoutner <soren@stoutner.com>.
  *
- * This file is part of Privacy Browser <https://www.stoutner.com/privacy-browser>.
+ * This file is part of Privacy Browser Android <https://www.stoutner.com/privacy-browser-android/>.
  *
- * Privacy Browser is free software: you can redistribute it and/or modify
+ * Privacy Browser Android is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
  * the Free Software Foundation, either version 3 of the License, or
  * (at your option) any later version.
  *
- * Privacy Browser is distributed in the hope that it will be useful,
+ * Privacy Browser Android is distributed in the hope that it will be useful,
  * but WITHOUT ANY WARRANTY; without even the implied warranty of
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  * GNU General Public License for more details.
  *
  * You should have received a copy of the GNU General Public License
- * along with Privacy Browser.  If not, see <http://www.gnu.org/licenses/>.
+ * along with Privacy Browser Android.  If not, see <http://www.gnu.org/licenses/>.
  */
 
 package com.stoutner.privacybrowser.activities
 
 import android.content.ClipData
 import android.content.ClipboardManager
-import android.net.Uri
 import android.os.Build
 import android.os.Bundle
 import android.provider.OpenableColumns
+import android.text.Editable
+import android.text.TextWatcher
+import android.util.Base64
 import android.util.TypedValue
+import android.view.KeyEvent
 import android.view.Menu
 import android.view.MenuItem
+import android.view.View
 import android.view.WindowManager
+import android.view.inputmethod.InputMethodManager
+import android.webkit.WebView
+import android.widget.EditText
+import android.widget.LinearLayout
 import android.widget.TextView
-import android.widget.ScrollView
 
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
@@ -39,73 +46,80 @@ import androidx.preference.PreferenceManager
 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
 
 import com.google.android.material.snackbar.Snackbar
-import com.stoutner.privacybrowser.BuildConfig
 
+import com.stoutner.privacybrowser.BuildConfig
 import com.stoutner.privacybrowser.R
 
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
 import java.io.BufferedReader
 import java.io.IOException
 import java.io.InputStreamReader
-import java.lang.Exception
+
 import java.nio.charset.StandardCharsets
 
 // Define the class constants.
-private const val SCROLLVIEW_POSITION = "scrollview_position"
+private const val SCROLL_Y = "A"
 
 class LogcatActivity : AppCompatActivity() {
-    // Define the class variables.
-    private var scrollViewYPositionInt = 0
-
-    // Define the class views.
+    // Declare the class variables.
+    private lateinit var inputMethodManager: InputMethodManager
+    private lateinit var logcatPlainTextStringBuilder: StringBuilder
+
+    // Declare the class views.
+    private lateinit var logcatWebView: WebView
+    private lateinit var searchEditText: EditText
+    private lateinit var searchLinearLayout: LinearLayout
     private lateinit var swipeRefreshLayout: SwipeRefreshLayout
-    private lateinit var logcatScrollView: ScrollView
-    private lateinit var logcatTextView: TextView
+    private lateinit var toolbar: Toolbar
 
     // Define the save logcat activity result launcher.  It must be defined before `onCreate()` is run or the app will crash.
-    private val saveLogcatActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument()) { fileUri: Uri? ->
+    private val saveLogcatActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { fileUri ->
         // Only save the file if the URI is not null, which happens if the user exited the file picker by pressing back.
         if (fileUri != null) {
             try {
-                // Get the logcat string.
-                val logcatString = logcatTextView.text.toString()
-
                 // Open an output stream.
                 val outputStream = contentResolver.openOutputStream(fileUri)!!
 
-                // Write the logcat string to the output stream.
-                outputStream.write(logcatString.toByteArray(StandardCharsets.UTF_8))
+                // Save the logcat using a coroutine with Dispatchers.IO.
+                CoroutineScope(Dispatchers.Main).launch {
+                    withContext(Dispatchers.IO) {
+                        // Write the logcat string to the output stream.
+                        outputStream.write(logcatPlainTextStringBuilder.toString().toByteArray(StandardCharsets.UTF_8))
 
-                // Close the output stream.
-                outputStream.close()
-
-                // Initialize the file name string from the file URI last path segment.
-                var fileNameString = fileUri.lastPathSegment
+                        // Close the output stream.
+                        outputStream.close()
+                    }
+                }
 
-                // Query the exact file name if the API >= 26.
-                if (Build.VERSION.SDK_INT >= 26) {
-                    // Get a cursor from the content resolver.
-                    val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
+                // Get a cursor from the content resolver.
+                val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
 
-                    // Move to the fist row.
-                    contentResolverCursor.moveToFirst()
+                // Move to the fist row.
+                contentResolverCursor.moveToFirst()
 
-                    // Get the file name from the cursor.
-                    fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
+                // Get the file name from the cursor.
+                val fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
 
-                    // Close the cursor.
-                    contentResolverCursor.close()
-                }
+                // Close the cursor.
+                contentResolverCursor.close()
 
                 // Display a snackbar with the saved logcat information.
-                Snackbar.make(logcatTextView, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show()
+                Snackbar.make(logcatWebView, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show()
             } catch (exception: Exception) {
                 // Display a snackbar with the error message.
-                Snackbar.make(logcatTextView, getString(R.string.error_saving_logcat, exception.toString()), Snackbar.LENGTH_INDEFINITE).show()
+                Snackbar.make(logcatWebView, getString(R.string.error_saving_logcat, exception.toString()), Snackbar.LENGTH_INDEFINITE).show()
             }
         }
     }
 
     public override fun onCreate(savedInstanceState: Bundle?) {
+        // Run the default commands.
+        super.onCreate(savedInstanceState)
+
         // Get a handle for the shared preferences.
         val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
 
@@ -114,28 +128,22 @@ class LogcatActivity : AppCompatActivity() {
         val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
 
         // Disable screenshots if not allowed.
-        if (!allowScreenshots) {
+        if (!allowScreenshots)
             window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
-        }
-
-        // Set the theme.
-        setTheme(R.style.PrivacyBrowser)
-
-        // Run the default commands.
-        super.onCreate(savedInstanceState)
 
         // Set the content view.
-        if (bottomAppBar) {
+        if (bottomAppBar)
             setContentView(R.layout.logcat_bottom_appbar)
-        } else {
+        else
             setContentView(R.layout.logcat_top_appbar)
-        }
 
         // Get handles for the views.
-        val toolbar = findViewById<Toolbar>(R.id.toolbar)
+        toolbar = findViewById(R.id.toolbar)
+        val searchCountTextView = findViewById<TextView>(R.id.search_count_textview)
+        searchLinearLayout = findViewById(R.id.search_linearlayout)
+        searchEditText = findViewById(R.id.search_edittext)
         swipeRefreshLayout = findViewById(R.id.swiperefreshlayout)
-        logcatScrollView = findViewById(R.id.scrollview)
-        logcatTextView = findViewById(R.id.logcat_textview)
+        logcatWebView = findViewById(R.id.logcat_webview)
 
         // Set the toolbar as the action bar.
         setSupportActionBar(toolbar)
@@ -148,8 +156,8 @@ class LogcatActivity : AppCompatActivity() {
 
         // Implement swipe to refresh.
         swipeRefreshLayout.setOnRefreshListener {
-            // Get the current logcat.
-            getLogcat()
+            // Populate the current logcat.
+            populateLogcat()
         }
 
         // Set the swipe refresh color scheme according to the theme.
@@ -167,14 +175,65 @@ class LogcatActivity : AppCompatActivity() {
         // Set the swipe refresh background color.
         swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
 
-        // Check to see if the activity has been restarted.
-        if (savedInstanceState != null) {
-            // Get the saved scrollview position.
-            scrollViewYPositionInt = savedInstanceState.getInt(SCROLLVIEW_POSITION)
+        // Get a handle for the input method manager.
+        inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
+
+        // Search for the string on the page whenever a character changes in the search edit text.
+        searchEditText.addTextChangedListener(object : TextWatcher {
+            override fun beforeTextChanged(charSequence: CharSequence, start: Int, count: Int, after: Int) {
+                // Do nothing.
+            }
+
+            override fun onTextChanged(charSequence: CharSequence, start: Int, before: Int, count: Int) {
+                // Do nothing.
+            }
+
+            override fun afterTextChanged(editable: Editable) {
+                // Search for the text in the WebView.
+                logcatWebView.findAllAsync(searchEditText.text.toString())
+            }
+        })
+
+        // Set the `check mark` button for the search edit text keyboard to close the soft keyboard.
+        searchEditText.setOnKeyListener { _: View?, keyCode: Int, keyEvent: KeyEvent ->
+            if ((keyEvent.action == KeyEvent.ACTION_DOWN) && (keyCode == KeyEvent.KEYCODE_ENTER)) {  // The `enter` key was pressed.
+                // Search for the text in the WebView.
+                logcatWebView.findAllAsync(searchEditText.text.toString())
+
+                // Hide the soft keyboard.
+                inputMethodManager.hideSoftInputFromWindow(logcatWebView.windowToken, 0)
+
+                // Consume the event.
+                return@setOnKeyListener true
+            } else {  // A different key was pressed.
+                // Do not consume the event.
+                return@setOnKeyListener false
+            }
+        }
+
+        // Update the find on page count.
+        logcatWebView.setFindListener { activeMatchOrdinal, numberOfMatches, isDoneCounting ->
+            if (isDoneCounting && (numberOfMatches == 0)) {  // There are no matches.
+                // Set the search count text view to be `0/0`.
+                searchCountTextView.setText(R.string.zero_of_zero)
+            } else if (isDoneCounting) {  // There are matches.
+                // The active match ordinal is zero-based.
+                val activeMatch = activeMatchOrdinal + 1
+
+                // Build the match string.
+                val matchString = "$activeMatch/$numberOfMatches"
+
+                // Update the search count text view.
+                searchCountTextView.text = matchString
+            }
         }
 
-        // Get the logcat.
-        getLogcat()
+        // Restore the WebView scroll position if the activity has been restarted.
+        if (savedInstanceState != null)
+            logcatWebView.scrollY = savedInstanceState.getInt(SCROLL_Y)
+
+        // Populate the logcat.
+        populateLogcat()
     }
 
     override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -187,22 +246,52 @@ class LogcatActivity : AppCompatActivity() {
 
     override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
         // Run the commands that correlate to the selected menu item.
-        return when (menuItem.itemId) {
+        when (menuItem.itemId) {
+            R.id.search -> {  // Search was selected.
+                // Set the minimum height of the search linear layout to match the toolbar.
+                searchLinearLayout.minimumHeight = toolbar.height
+
+                // Hide the toolbar.
+                toolbar.visibility = View.GONE
+
+                // Show the search linear layout.
+                searchLinearLayout.visibility = View.VISIBLE
+
+                // Display the keyboard once the UI has quiesced.
+                searchLinearLayout.post {
+                    // Set the focus on the find on page edit text.
+                    searchEditText.requestFocus()
+
+                    // Get a handle for the input method manager.
+                    val inputMethodManager = (getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
+
+                    // Display the keyboard.  `0` sets no input flags.
+                    inputMethodManager.showSoftInput(searchEditText, 0)
+                }
+
+                // Resume the WebView timers.  For some reason they get automatically paused, which prevents searching.
+                logcatWebView.resumeTimers()
+
+                // Consume the event.
+                return true
+            }
+
             R.id.copy -> {  // Copy was selected.
                 // Get a handle for the clipboard manager.
                 val clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
 
                 // Save the logcat in a clip data.
-                val logcatClipData = ClipData.newPlainText(getString(R.string.logcat), logcatTextView.text)
+                val logcatClipData = ClipData.newPlainText(getString(R.string.logcat), logcatPlainTextStringBuilder)
 
                 // Place the clip data on the clipboard.
                 clipboardManager.setPrimaryClip(logcatClipData)
 
-                // Display a snackbar.
-                Snackbar.make(logcatTextView, R.string.logcat_copied, Snackbar.LENGTH_SHORT).show()
+                // Display a snackbar if the API <= 32 (Android 12L).  Beginning in Android 13 the OS displays a notification that covers up the snackbar.
+                if (Build.VERSION.SDK_INT <= 32)
+                    Snackbar.make(logcatWebView, R.string.logcat_copied, Snackbar.LENGTH_SHORT).show()
 
                 // Consume the event.
-                true
+                return true
             }
 
             R.id.save -> {  // Save was selected.
@@ -210,7 +299,7 @@ class LogcatActivity : AppCompatActivity() {
                 saveLogcatActivityResultLauncher.launch(getString(R.string.privacy_browser_logcat_txt, BuildConfig.VERSION_NAME))
 
                 // Consume the event.
-                true
+                return true
             }
 
             R.id.clear -> {  // Clear was selected.
@@ -221,22 +310,19 @@ class LogcatActivity : AppCompatActivity() {
                     // Wait for the process to finish.
                     process.waitFor()
 
-                    // Reset the scroll view Y position int.
-                    scrollViewYPositionInt = 0
-
                     // Reload the logcat.
-                    getLogcat()
+                    populateLogcat()
                 } catch (exception: Exception) {
                     // Do nothing.
                 }
 
                 // Consume the event.
-                true
+                return true
             }
 
-            else -> {  // The home button was pushed.
+            else -> {  // The home button was selected.
                 // Do not consume the event.  The system will process the home command.
-                super.onOptionsItemSelected(menuItem)
+                return super.onOptionsItemSelected(menuItem)
             }
         }
     }
@@ -245,14 +331,29 @@ class LogcatActivity : AppCompatActivity() {
         // Run the default commands.
         super.onSaveInstanceState(savedInstanceState)
 
-        // Get the scrollview Y position.
-        val scrollViewYPositionInt = logcatScrollView.scrollY
+        // Store the scroll Y position in the bundle.
+        savedInstanceState.putInt(SCROLL_Y, logcatWebView.scrollY)
+    }
+
+    // The view parameter cannot be removed because it is called from the layout onClick.
+    fun closeSearch(@Suppress("UNUSED_PARAMETER")view: View?) {
+        // Delete the contents of the search edit text.
+        searchEditText.text = null
+
+        // Clear the highlighted phrases in the logcat WebView.
+        logcatWebView.clearMatches()
+
+        // Hide the search linear layout.
+        searchLinearLayout.visibility = View.GONE
+
+        // Show the toolbar.
+        toolbar.visibility = View.VISIBLE
 
-        // Store the scrollview Y position in the bundle.
-        savedInstanceState.putInt(SCROLLVIEW_POSITION, scrollViewYPositionInt)
+        // Hide the keyboard.
+        inputMethodManager.hideSoftInputFromWindow(toolbar.windowToken, 0)
     }
 
-    private fun getLogcat() {
+    private fun populateLogcat() {
         try {
             // 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.
             val getLogcatProcess = Runtime.getRuntime().exec("logcat -b all -v long -d")
@@ -260,8 +361,83 @@ class LogcatActivity : AppCompatActivity() {
             // Wrap the logcat in a buffered reader.
             val logcatBufferedReader = BufferedReader(InputStreamReader(getLogcatProcess.inputStream))
 
-            // Display the logcat.
-            logcatTextView.text = logcatBufferedReader.readText()
+            // Reset the logcat plain text string.
+            logcatPlainTextStringBuilder = StringBuilder()
+
+            // Create a logcat HTML string builder.
+            val logcatHtmlStringBuilder = StringBuilder()
+
+            // Populate the initial HTML.
+            logcatHtmlStringBuilder.append("<html>")
+            logcatHtmlStringBuilder.append("<head>")
+            logcatHtmlStringBuilder.append("<style>")
+
+            // Set the word break so that lines never exceed the width of the screen.
+            logcatHtmlStringBuilder.append("body { word-break: break-word; }")
+
+            // Set the colors.
+            logcatHtmlStringBuilder.append("@media (prefers-color-scheme: dark) { body { color: #C1C1C1;  /* Gray 350 */ background-color: #303030;  /* Gray 860 */ } }")
+            logcatHtmlStringBuilder.append("span.header { color: #0D47A1;  /* Blue 900 */ } @media (prefers-color-scheme: dark) { span.header { color: #8AB4F8;  /* Violet 500 */ } }")
+            logcatHtmlStringBuilder.append("strong.crash { color: #B71C1C;  /* Red 900. */ } @media (prefers-color-scheme: dark) { strong.crash { color: #E24B4C;  /* Red Night. */ } }")
+            logcatHtmlStringBuilder.append("span.crash { color: #EF5350;  /* Red 400. */ } @media (prefers-color-scheme: dark) { span.crash { color: #EF9A9A;  /* Red Night. */ } }")
+
+            // Close the style tag.
+            logcatHtmlStringBuilder.append("</style>")
+
+            // Respect dark mode.
+            logcatHtmlStringBuilder.append("<meta name=\"color-scheme\" content=\"light dark\">")
+
+            // Start the HTML body.
+            logcatHtmlStringBuilder.append("</head>")
+            logcatHtmlStringBuilder.append("<body>")
+
+            // Create a logcat line string.
+            var logcatLineString: String?
+
+            while (logcatBufferedReader.readLine().also { logcatLineString = it } != null) {
+                // Populate the logcat plain text string builder.
+                logcatPlainTextStringBuilder.append(logcatLineString)
+
+                // Add a line break.
+                logcatPlainTextStringBuilder.append("\n")
+
+                // Trim the string, which is necessary for correct detection of lines that start with `at`.
+                logcatLineString = logcatLineString!!.trim()
+
+                // Apply syntax highlighting to the logcat.
+                if (logcatLineString!!.contains("crash") || logcatLineString!!.contains("Exception") ) {  // Colorize crashes.
+                    logcatHtmlStringBuilder.append("<strong class=\"crash\">")
+                    logcatHtmlStringBuilder.append(logcatLineString)
+                    logcatHtmlStringBuilder.append("</strong>")
+                } else if (logcatLineString!!.startsWith("at") || logcatLineString!!.startsWith("Process:") || logcatLineString!!.contains("FATAL")) {  // Colorize lines relating to crashes.
+                    logcatHtmlStringBuilder.append("<span class=\"crash\">")
+                    logcatHtmlStringBuilder.append(logcatLineString)
+                    logcatHtmlStringBuilder.append("</span>")
+                } else if (logcatLineString!!.startsWith("-")) {  // Colorize the headers.
+                    logcatHtmlStringBuilder.append("<span class=\"header\">")
+                    logcatHtmlStringBuilder.append(logcatLineString)
+                    logcatHtmlStringBuilder.append("</span>")
+                } else if (logcatLineString!!.startsWith("[ ")) {  // Colorize the time stamps.
+                    logcatHtmlStringBuilder.append("<span style=color:gray>")
+                    logcatHtmlStringBuilder.append(logcatLineString)
+                    logcatHtmlStringBuilder.append("</span>")
+                } else {  // Display the standard lines.
+                    logcatHtmlStringBuilder.append(logcatLineString)
+                }
+
+                // Add a line break.
+                logcatHtmlStringBuilder.append("<br>")
+            }
+
+            // Close the HTML.
+            logcatHtmlStringBuilder.append("</body>")
+            logcatHtmlStringBuilder.append("</html>")
+
+            // Encode the logcat HTML.
+            val base64EncodedLogcatHtml: String = Base64.encodeToString(logcatHtmlStringBuilder.toString().toByteArray(Charsets.UTF_8), Base64.NO_PADDING)
+
+            // Load the encoded logcat.
+            logcatWebView.loadData(base64EncodedLogcatHtml, "text/html", "base64")
 
             // Close the buffered reader.
             logcatBufferedReader.close()
@@ -269,13 +445,19 @@ class LogcatActivity : AppCompatActivity() {
             // Do nothing.
         }
 
-        // Update the scroll position after the text is populated.
-        logcatTextView.post {
-            // Set the scroll position.
-            logcatScrollView.scrollY = scrollViewYPositionInt
-        }
-
         // Stop the swipe to refresh animation if it is displayed.
         swipeRefreshLayout.isRefreshing = false
     }
-}
\ No newline at end of file
+
+    // The view parameter cannot be removed because it is called from the layout onClick.
+    fun searchNext(@Suppress("UNUSED_PARAMETER")view: View?) {
+        // Go to the next highlighted phrase on the page. `true` goes forwards instead of backwards.
+        logcatWebView.findNext(true)
+    }
+
+    // The view parameter cannot be removed because it is called from the layout onClick.
+    fun searchPrevious(@Suppress("UNUSED_PARAMETER")view: View?) {
+        // Go to the previous highlighted phrase on the page.  `false` goes backwards instead of forwards.
+        logcatWebView.findNext(false)
+    }
+}