X-Git-Url: https://gitweb.stoutner.com/?a=blobdiff_plain;f=app%2Fsrc%2Fmain%2Fjava%2Fcom%2Fstoutner%2Fprivacybrowser%2Factivities%2FLogcatActivity.kt;h=a0e5ad8ab831acbc7785089bf336b9eaf57cad6d;hb=HEAD;hp=23e6d525d44dfecd441d60a60f325999622df2dd;hpb=322b36f275782a06ed66b950083f28cc37f5690a;p=PrivacyBrowserAndroid.git diff --git a/app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.kt b/app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.kt index 23e6d525..b33fc7fe 100644 --- a/app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.kt +++ b/app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.kt @@ -1,69 +1,125 @@ /* - * Copyright © 2019-2021 Soren Stoutner . + * Copyright 2019-2024 Soren Stoutner . * - * This file is part of Privacy Browser . + * This file is part of 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 . + * along with Privacy Browser Android. If not, see . */ package com.stoutner.privacybrowser.activities import android.content.ClipData import android.content.ClipboardManager -import android.content.Intent -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 import androidx.appcompat.widget.Toolbar -import androidx.fragment.app.DialogFragment 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.R -import com.stoutner.privacybrowser.dialogs.SaveDialog + +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(), SaveDialog.SaveListener { - // Define the class variables. - private var scrollViewYPositionInt = 0 +class LogcatActivity : AppCompatActivity() { + // Declare the class variables. + private lateinit var inputMethodManager: InputMethodManager + private lateinit var logcatPlainTextStringBuilder: StringBuilder - // Define the class views. + // 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("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 { + // Open an output stream. + val outputStream = contentResolver.openOutputStream(fileUri)!! + + // 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() + } + } + + // Get a cursor from the content resolver. + val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!! + + // Move to the fist row. + contentResolverCursor.moveToFirst() + + // Get the file name from the cursor. + val fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + + // Close the cursor. + contentResolverCursor.close() + + // Display a snackbar with the saved logcat information. + Snackbar.make(logcatWebView, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show() + } catch (exception: Exception) { + // Display a snackbar with the error message. + 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) @@ -72,28 +128,22 @@ class LogcatActivity : AppCompatActivity(), SaveDialog.SaveListener { 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(R.id.toolbar) + toolbar = findViewById(R.id.toolbar) + val searchCountTextView = findViewById(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) @@ -106,8 +156,8 @@ class LogcatActivity : AppCompatActivity(), SaveDialog.SaveListener { // 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. @@ -125,14 +175,65 @@ class LogcatActivity : AppCompatActivity(), SaveDialog.SaveListener { // 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 { @@ -145,33 +246,60 @@ class LogcatActivity : AppCompatActivity(), SaveDialog.SaveListener { 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. - // Instantiate the save alert dialog. - val saveDialogFragment: DialogFragment = SaveDialog.save(SaveDialog.SAVE_LOGCAT) - - // Show the save alert dialog. - saveDialogFragment.show(supportFragmentManager, getString(R.string.save_logcat)) + // Open the file picker. + saveLogcatActivityResultLauncher.launch(getString(R.string.privacy_browser_logcat_txt, BuildConfig.VERSION_NAME)) // Consume the event. - true + return true } R.id.clear -> { // Clear was selected. @@ -182,38 +310,50 @@ class LogcatActivity : AppCompatActivity(), SaveDialog.SaveListener { // 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) } } } - public override fun onSaveInstanceState(savedInstanceState: Bundle) { + public override fun onSaveInstanceState(outState: Bundle) { // Run the default commands. - super.onSaveInstanceState(savedInstanceState) + super.onSaveInstanceState(outState) - // Get the scrollview Y position. - val scrollViewYPositionInt = logcatScrollView.scrollY + // Store the scroll Y position in the bundle. + outState.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() - // Store the scrollview Y position in the bundle. - savedInstanceState.putInt(SCROLLVIEW_POSITION, scrollViewYPositionInt) + // Hide the search linear layout. + searchLinearLayout.visibility = View.GONE + + // Show the toolbar. + toolbar.visibility = View.VISIBLE + + // 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") @@ -221,8 +361,83 @@ class LogcatActivity : AppCompatActivity(), SaveDialog.SaveListener { // 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("") + logcatHtmlStringBuilder.append("") + logcatHtmlStringBuilder.append("") + + // Respect dark mode. + logcatHtmlStringBuilder.append("") + + // Start the HTML body. + logcatHtmlStringBuilder.append("") + logcatHtmlStringBuilder.append("") + + // 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("") + logcatHtmlStringBuilder.append(logcatLineString) + logcatHtmlStringBuilder.append("") + } else if (logcatLineString!!.startsWith("at") || logcatLineString!!.startsWith("Process:") || logcatLineString!!.contains("FATAL")) { // Colorize lines relating to crashes. + logcatHtmlStringBuilder.append("") + logcatHtmlStringBuilder.append(logcatLineString) + logcatHtmlStringBuilder.append("") + } else if (logcatLineString!!.startsWith("-")) { // Colorize the headers. + logcatHtmlStringBuilder.append("") + logcatHtmlStringBuilder.append(logcatLineString) + logcatHtmlStringBuilder.append("") + } else if (logcatLineString!!.startsWith("[ ")) { // Colorize the time stamps. + logcatHtmlStringBuilder.append("") + logcatHtmlStringBuilder.append(logcatLineString) + logcatHtmlStringBuilder.append("") + } else { // Display the standard lines. + logcatHtmlStringBuilder.append(logcatLineString) + } + + // Add a line break. + logcatHtmlStringBuilder.append("
") + } + + // Close the HTML. + logcatHtmlStringBuilder.append("") + logcatHtmlStringBuilder.append("") + + // 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() @@ -230,92 +445,19 @@ class LogcatActivity : AppCompatActivity(), SaveDialog.SaveListener { // 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 } - // The activity result is called after browsing for a file in the save alert dialog. - public override fun onActivityResult(requestCode: Int, resultCode: Int, returnedIntent: Intent?) { - // Run the default commands. - super.onActivityResult(requestCode, resultCode, returnedIntent) - - // Only do something if the user didn't press back from the file picker. - if (resultCode == RESULT_OK) { - // Get a handle for the save dialog fragment. - val saveDialogFragment = supportFragmentManager.findFragmentByTag(getString(R.string.save_logcat)) as DialogFragment? - - // Only update the file name if the dialog still exists. - if (saveDialogFragment != null) { - // Get a handle for the save dialog. - val saveDialog = saveDialogFragment.dialog!! - - // Get a handle for the file name edit text. - val fileNameEditText = saveDialog.findViewById(R.id.file_name_edittext) - - // Get the file name URI from the intent. - val fileNameUri = returnedIntent!!.data - - // Get the file name string from the URI. - val fileNameString = fileNameUri.toString() - - // Set the file name text. - fileNameEditText.setText(fileNameString) - - // Move the cursor to the end of the file name edit text. - fileNameEditText.setSelection(fileNameString.length) - } - } + // 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) } - override fun onSave(saveType: Int, dialogFragment: DialogFragment) { - // Get a handle for the dialog. - val dialog = dialogFragment.dialog!! - - // Get a handle for the file name edit text. - val fileNameEditText = dialog.findViewById(R.id.file_name_edittext) - - // Get the file path string. - var fileNameString = fileNameEditText.text.toString() - - try { - // Get the logcat as a string. - val logcatString = logcatTextView.text.toString() - - // Open an output stream. - val outputStream = contentResolver.openOutputStream(Uri.parse(fileNameString))!! - - // Write the logcat string to the output stream. - outputStream.write(logcatString.toByteArray(StandardCharsets.UTF_8)) - - // Close the output stream. - outputStream.close() - - // Get the actual file name if the API >= 26. - if (Build.VERSION.SDK_INT >= 26) { - // Get a cursor from the content resolver. - val contentResolverCursor = contentResolver.query(Uri.parse(fileNameString), null, null, null)!! - - // Move to the first row. - contentResolverCursor.moveToFirst() - - // Get the file name from the cursor. - fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) - - // Close the cursor. - contentResolverCursor.close() - } - - // Display a snackbar with the saved logcat information. - Snackbar.make(logcatTextView, getString(R.string.file_saved) + " " + fileNameString, Snackbar.LENGTH_SHORT).show() - } catch (exception: Exception) { - // Display a snackbar with the error message. - Snackbar.make(logcatTextView, getString(R.string.error_saving_file) + " " + exception.toString(), Snackbar.LENGTH_INDEFINITE).show() - } + // 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) } -} \ No newline at end of file +}