/*
- * 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
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()) { fileNameUri: 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 (fileNameUri != null) {
+ if (fileUri != null) {
try {
- // Get the logcat string.
- val logcatString = logcatTextView.text.toString()
-
// Open an output stream.
- val outputStream = contentResolver.openOutputStream(fileNameUri)!!
+ 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 name URI last path segment.
- var fileNameString = fileNameUri.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(fileNameUri, 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.getColumnIndex(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)
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)
// 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.
// 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 {
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.
saveLogcatActivityResultLauncher.launch(getString(R.string.privacy_browser_logcat_txt, BuildConfig.VERSION_NAME))
// Consume the event.
- true
+ return true
}
R.id.clear -> { // Clear was selected.
// 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)
+
+ // 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()
+
+ // Hide the search linear layout.
+ searchLinearLayout.visibility = View.GONE
- // Get the scrollview Y position.
- val scrollViewYPositionInt = logcatScrollView.scrollY
+ // 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")
// 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()
// 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)
+ }
+}