]> gitweb.stoutner.com Git - PrivacyBrowserAndroid.git/blob - app/src/main/java/com/stoutner/privacybrowser/activities/LogcatActivity.kt
c12c74ecb4302c403067724b84ddcf35ec77c011
[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.util.Base64
28 import android.util.TypedValue
29 import android.view.Menu
30 import android.view.MenuItem
31 import android.view.WindowManager
32 import android.webkit.WebView
33
34 import androidx.activity.result.contract.ActivityResultContracts
35 import androidx.appcompat.app.AppCompatActivity
36 import androidx.appcompat.widget.Toolbar
37 import androidx.preference.PreferenceManager
38 import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
39
40 import com.google.android.material.snackbar.Snackbar
41
42 import com.stoutner.privacybrowser.BuildConfig
43 import com.stoutner.privacybrowser.R
44
45 import kotlinx.coroutines.CoroutineScope
46 import kotlinx.coroutines.Dispatchers
47 import kotlinx.coroutines.launch
48 import kotlinx.coroutines.withContext
49
50 import java.io.BufferedReader
51 import java.io.IOException
52 import java.io.InputStreamReader
53
54 import java.nio.charset.StandardCharsets
55
56 // Define the class constants.
57 private const val SCROLL_Y = "A"
58
59 class LogcatActivity : AppCompatActivity() {
60     // Declare the class variables.
61     private lateinit var logcatPlainTextStringBuilder: StringBuilder
62
63     // Declare the class views.
64     private lateinit var swipeRefreshLayout: SwipeRefreshLayout
65     private lateinit var logcatWebView: WebView
66
67     // Define the save logcat activity result launcher.  It must be defined before `onCreate()` is run or the app will crash.
68     private val saveLogcatActivityResultLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { fileUri ->
69         // Only save the file if the URI is not null, which happens if the user exited the file picker by pressing back.
70         if (fileUri != null) {
71             try {
72                 // Open an output stream.
73                 val outputStream = contentResolver.openOutputStream(fileUri)!!
74
75                 // Save the logcat using a coroutine with Dispatchers.IO.
76                 CoroutineScope(Dispatchers.Main).launch {
77                     withContext(Dispatchers.IO) {
78                         // Write the logcat string to the output stream.
79                         outputStream.write(logcatPlainTextStringBuilder.toString().toByteArray(StandardCharsets.UTF_8))
80
81                         // Close the output stream.
82                         outputStream.close()
83                     }
84                 }
85
86                 // Get a cursor from the content resolver.
87                 val contentResolverCursor = contentResolver.query(fileUri, null, null, null)!!
88
89                 // Move to the fist row.
90                 contentResolverCursor.moveToFirst()
91
92                 // Get the file name from the cursor.
93                 val fileNameString = contentResolverCursor.getString(contentResolverCursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
94
95                 // Close the cursor.
96                 contentResolverCursor.close()
97
98                 // Display a snackbar with the saved logcat information.
99                 Snackbar.make(logcatWebView, getString(R.string.saved, fileNameString), Snackbar.LENGTH_SHORT).show()
100             } catch (exception: Exception) {
101                 // Display a snackbar with the error message.
102                 Snackbar.make(logcatWebView, getString(R.string.error_saving_logcat, exception.toString()), Snackbar.LENGTH_INDEFINITE).show()
103             }
104         }
105     }
106
107     public override fun onCreate(savedInstanceState: Bundle?) {
108         // Get a handle for the shared preferences.
109         val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
110
111         // Get the preferences.
112         val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
113         val bottomAppBar = sharedPreferences.getBoolean(getString(R.string.bottom_app_bar_key), false)
114
115         // Disable screenshots if not allowed.
116         if (!allowScreenshots)
117             window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
118
119         // Run the default commands.
120         super.onCreate(savedInstanceState)
121
122         // Set the content view.
123         if (bottomAppBar)
124             setContentView(R.layout.logcat_bottom_appbar)
125         else
126             setContentView(R.layout.logcat_top_appbar)
127
128         // Get handles for the views.
129         val toolbar = findViewById<Toolbar>(R.id.toolbar)
130         swipeRefreshLayout = findViewById(R.id.swiperefreshlayout)
131         logcatWebView = findViewById(R.id.logcat_webview)
132
133         // Set the toolbar as the action bar.
134         setSupportActionBar(toolbar)
135
136         // Get a handle for the action bar.
137         val actionBar = supportActionBar!!
138
139         // Display the back arrow in the action bar.
140         actionBar.setDisplayHomeAsUpEnabled(true)
141
142         // Implement swipe to refresh.
143         swipeRefreshLayout.setOnRefreshListener {
144             // Populate the current logcat.
145             populateLogcat()
146         }
147
148         // Set the swipe refresh color scheme according to the theme.
149         swipeRefreshLayout.setColorSchemeResources(R.color.blue_text)
150
151         // Initialize a color background typed value.
152         val colorBackgroundTypedValue = TypedValue()
153
154         // Get the color background from the theme.
155         theme.resolveAttribute(android.R.attr.colorBackground, colorBackgroundTypedValue, true)
156
157         // Get the color background int from the typed value.
158         val colorBackgroundInt = colorBackgroundTypedValue.data
159
160         // Set the swipe refresh background color.
161         swipeRefreshLayout.setProgressBackgroundColorSchemeColor(colorBackgroundInt)
162
163         // Restore the WebView scroll position if the activity has been restarted.
164         if (savedInstanceState != null)
165             logcatWebView.scrollY = savedInstanceState.getInt(SCROLL_Y)
166
167         // Allow loading of file:// URLs.
168         logcatWebView.settings.allowFileAccess = true
169
170         // Populate the logcat.
171         populateLogcat()
172     }
173
174     override fun onCreateOptionsMenu(menu: Menu): Boolean {
175         // Inflate the menu.  This adds items to the action bar.
176         menuInflater.inflate(R.menu.logcat_options_menu, menu)
177
178         // Display the menu.
179         return true
180     }
181
182     override fun onOptionsItemSelected(menuItem: MenuItem): Boolean {
183         // Run the commands that correlate to the selected menu item.
184         return when (menuItem.itemId) {
185             R.id.copy -> {  // Copy was selected.
186                 // Get a handle for the clipboard manager.
187                 val clipboardManager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
188
189                 // Save the logcat in a clip data.
190                 val logcatClipData = ClipData.newPlainText(getString(R.string.logcat), logcatPlainTextStringBuilder)
191
192                 // Place the clip data on the clipboard.
193                 clipboardManager.setPrimaryClip(logcatClipData)
194
195                 // Display a snackbar if the API <= 32 (Android 12L).  Beginning in Android 13 the OS displays a notification that covers up the snackbar.
196                 if (Build.VERSION.SDK_INT <= 32)
197                     Snackbar.make(logcatWebView, R.string.logcat_copied, Snackbar.LENGTH_SHORT).show()
198
199                 // Consume the event.
200                 true
201             }
202
203             R.id.save -> {  // Save was selected.
204                 // Open the file picker.
205                 saveLogcatActivityResultLauncher.launch(getString(R.string.privacy_browser_logcat_txt, BuildConfig.VERSION_NAME))
206
207                 // Consume the event.
208                 true
209             }
210
211             R.id.clear -> {  // Clear was selected.
212                 try {
213                     // Clear the logcat.  `-c` clears the logcat.  `-b all` clears all the buffers (instead of just crash, main, and system).
214                     val process = Runtime.getRuntime().exec("logcat -b all -c")
215
216                     // Wait for the process to finish.
217                     process.waitFor()
218
219                     // Reload the logcat.
220                     populateLogcat()
221                 } catch (exception: Exception) {
222                     // Do nothing.
223                 }
224
225                 // Consume the event.
226                 true
227             }
228
229             else -> {  // The home button was pushed.
230                 // Do not consume the event.  The system will process the home command.
231                 super.onOptionsItemSelected(menuItem)
232             }
233         }
234     }
235
236     public override fun onSaveInstanceState(savedInstanceState: Bundle) {
237         // Run the default commands.
238         super.onSaveInstanceState(savedInstanceState)
239
240         // Store the scroll Y position in the bundle.
241         savedInstanceState.putInt(SCROLL_Y, logcatWebView.scrollY)
242     }
243
244     private fun populateLogcat() {
245         try {
246             // 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.
247             val getLogcatProcess = Runtime.getRuntime().exec("logcat -b all -v long -d")
248
249             // Wrap the logcat in a buffered reader.
250             val logcatBufferedReader = BufferedReader(InputStreamReader(getLogcatProcess.inputStream))
251
252             // Reset the logcat plain text string.
253             logcatPlainTextStringBuilder = StringBuilder()
254
255             // Create a logcat HTML string builder.
256             val logcatHtmlStringBuilder = StringBuilder()
257
258             // Populate the initial HTML.
259             logcatHtmlStringBuilder.append("<html>")
260             logcatHtmlStringBuilder.append("<head>")
261             logcatHtmlStringBuilder.append("<style>")
262
263             // Set the word break so that lines never exceed the width of the screen.
264             logcatHtmlStringBuilder.append("body { word-break: break-word; }")
265
266             // Set the colors.
267             logcatHtmlStringBuilder.append("@media (prefers-color-scheme: dark) { body { color: #C1C1C1;  /* Gray 350 */ background-color: #303030;  /* Gray 860 */ } }")
268             logcatHtmlStringBuilder.append("span.header { color: #0D47A1;  /* Blue 900 */ } @media (prefers-color-scheme: dark) { span.header { color: #8AB4F8;  /* Violet 500 */ } }")
269             logcatHtmlStringBuilder.append("strong.crash { color: #B71C1C;  /* Red 900. */ } @media (prefers-color-scheme: dark) { strong.crash { color: #E24B4C;  /* Red Night. */ } }")
270             logcatHtmlStringBuilder.append("span.crash { color: #EF5350;  /* Red 400. */ } @media (prefers-color-scheme: dark) { span.crash { color: #EF9A9A;  /* Red Night. */ } }")
271
272             // Close the style tag.
273             logcatHtmlStringBuilder.append("</style>")
274
275             // Respect dark mode.
276             logcatHtmlStringBuilder.append("<meta name=\"color-scheme\" content=\"light dark\">")
277
278             // Start the HTML body.
279             logcatHtmlStringBuilder.append("</head>")
280             logcatHtmlStringBuilder.append("<body>")
281
282             // Create a logcat line string.
283             var logcatLineString: String?
284
285             while (logcatBufferedReader.readLine().also { logcatLineString = it } != null) {
286                 // Populate the logcat plain text string builder.
287                 logcatPlainTextStringBuilder.append(logcatLineString)
288
289                 // Add a line break.
290                 logcatPlainTextStringBuilder.append("\n")
291
292                 // Trim the string, which is necessary for correct detection of lines that start with `at`.
293                 logcatLineString = logcatLineString!!.trim()
294
295                 // Apply syntax highlighting to the logcat.
296                 if (logcatLineString!!.contains("crash") || logcatLineString!!.contains("Exception") ) {  // Colorize crashes.
297                     logcatHtmlStringBuilder.append("<strong class=\"crash\">")
298                     logcatHtmlStringBuilder.append(logcatLineString)
299                     logcatHtmlStringBuilder.append("</strong>")
300                 } else if (logcatLineString!!.startsWith("at") || logcatLineString!!.startsWith("Process:") || logcatLineString!!.contains("FATAL")) {  // Colorize lines relating to crashes.
301                     logcatHtmlStringBuilder.append("<span class=\"crash\">")
302                     logcatHtmlStringBuilder.append(logcatLineString)
303                     logcatHtmlStringBuilder.append("</span>")
304                 } else if (logcatLineString!!.startsWith("-")) {  // Colorize the headers.
305                     logcatHtmlStringBuilder.append("<span class=\"header\">")
306                     logcatHtmlStringBuilder.append(logcatLineString)
307                     logcatHtmlStringBuilder.append("</span>")
308                 } else if (logcatLineString!!.startsWith("[ ")) {  // Colorize the time stamps.
309                     logcatHtmlStringBuilder.append("<span style=color:gray>")
310                     logcatHtmlStringBuilder.append(logcatLineString)
311                     logcatHtmlStringBuilder.append("</span>")
312                 } else {  // Display the standard lines.
313                     logcatHtmlStringBuilder.append(logcatLineString)
314                 }
315
316                 // Add a line break.
317                 logcatHtmlStringBuilder.append("<br>")
318             }
319
320             // Close the HTML.
321             logcatHtmlStringBuilder.append("</body>")
322             logcatHtmlStringBuilder.append("</html>")
323
324             // Encode the logcat HTML.
325             val base64EncodedLogcatHtml: String = Base64.encodeToString(logcatHtmlStringBuilder.toString().toByteArray(Charsets.UTF_8), Base64.NO_PADDING)
326
327             // Load the encoded logcat.
328             logcatWebView.loadData(base64EncodedLogcatHtml, "text/html", "base64")
329
330             // Close the buffered reader.
331             logcatBufferedReader.close()
332         } catch (exception: IOException) {
333             // Do nothing.
334         }
335
336         // Stop the swipe to refresh animation if it is displayed.
337         swipeRefreshLayout.isRefreshing = false
338     }
339 }