diff options
Diffstat (limited to 'ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt')
-rw-r--r-- | ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt | 264 |
1 files changed, 145 insertions, 119 deletions
diff --git a/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt index 87fdc236..195e9592 100644 --- a/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt +++ b/ui/src/main/java/com/wireguard/android/activity/LogViewerActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2020 WireGuard LLC. All Rights Reserved. + * Copyright © 2017-2025 WireGuard LLC. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ @@ -19,13 +19,18 @@ import android.text.Spannable import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan +import android.util.Log import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.collection.CircularArray import androidx.core.app.ShareCompat +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -36,13 +41,9 @@ import com.wireguard.android.R import com.wireguard.android.databinding.LogViewerActivityBinding import com.wireguard.android.util.DownloadsFileSaver import com.wireguard.android.util.ErrorMessages -import com.wireguard.android.widget.EdgeToEdge.setUpFAB -import com.wireguard.android.widget.EdgeToEdge.setUpRoot -import com.wireguard.android.widget.EdgeToEdge.setUpScrollingContent +import com.wireguard.android.util.resolveAttribute import com.wireguard.crypto.KeyPair -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedReader @@ -60,33 +61,26 @@ import java.util.regex.Matcher import java.util.regex.Pattern class LogViewerActivity : AppCompatActivity() { - private lateinit var binding: LogViewerActivityBinding private lateinit var logAdapter: LogEntryAdapter - private var logLines = arrayListOf<LogLine>() - private var rawLogLines = StringBuffer() + private var logLines = CircularArray<LogLine>() + private var rawLogLines = CircularArray<String>() private var recyclerView: RecyclerView? = null private var saveButton: MenuItem? = null - private val coroutineScope = CoroutineScope(Dispatchers.Default) private val year by lazy { val yearFormatter: DateFormat = SimpleDateFormat("yyyy", Locale.US) yearFormatter.format(Date()) } - @Suppress("Deprecation") - private val defaultColor by lazy { resources.getColor(R.color.primary_text_color) } + private val defaultColor by lazy { resolveAttribute(com.google.android.material.R.attr.colorOnSurface) } - @Suppress("Deprecation") - private val debugColor by lazy { resources.getColor(R.color.debug_tag_color) } + private val debugColor by lazy { ResourcesCompat.getColor(resources, R.color.debug_tag_color, theme) } - @Suppress("Deprecation") - private val errorColor by lazy { resources.getColor(R.color.error_tag_color) } + private val errorColor by lazy { ResourcesCompat.getColor(resources, R.color.error_tag_color, theme) } - @Suppress("Deprecation") - private val infoColor by lazy { resources.getColor(R.color.info_tag_color) } + private val infoColor by lazy { ResourcesCompat.getColor(resources, R.color.info_tag_color, theme) } - @Suppress("Deprecation") - private val warningColor by lazy { resources.getColor(R.color.warning_tag_color) } + private val warningColor by lazy { ResourcesCompat.getColor(resources, R.color.warning_tag_color, theme) } private var lastUri: Uri? = null @@ -103,9 +97,6 @@ class LogViewerActivity : AppCompatActivity() { binding = LogViewerActivityBinding.inflate(layoutInflater) setContentView(binding.root) supportActionBar?.setDisplayHomeAsUpEnabled(true) - setUpFAB(binding.shareFab) - setUpRoot(binding.root) - setUpScrollingContent(binding.recyclerView, binding.shareFab) logAdapter = LogEntryAdapter() binding.recyclerView.apply { recyclerView = this @@ -114,35 +105,34 @@ class LogViewerActivity : AppCompatActivity() { addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) } - coroutineScope.launch { streamingLog() } + lifecycleScope.launch(Dispatchers.IO) { streamingLog() } - binding.shareFab.setOnClickListener { + val revokeLastActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { revokeLastUri() - val key = KeyPair().privateKey.toHex() - LOGS[key] = rawLogLines.toString().toByteArray(Charsets.UTF_8) - lastUri = Uri.parse("content://${BuildConfig.APPLICATION_ID}.exported-log/$key") - val shareIntent = ShareCompat.IntentBuilder.from(this) + } + + binding.shareFab.setOnClickListener { + lifecycleScope.launch { + revokeLastUri() + val key = KeyPair().privateKey.toHex() + LOGS[key] = rawLogBytes() + lastUri = Uri.parse("content://${BuildConfig.APPLICATION_ID}.exported-log/$key") + val shareIntent = ShareCompat.IntentBuilder(this@LogViewerActivity) .setType("text/plain") .setSubject(getString(R.string.log_export_subject)) .setStream(lastUri) .setChooserTitle(R.string.log_export_title) .createChooserIntent() .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - grantUriPermission("android", lastUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - startActivityForResult(shareIntent, SHARE_ACTIVITY_REQUEST) - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == SHARE_ACTIVITY_REQUEST) { - revokeLastUri() + grantUriPermission("android", lastUri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + revokeLastActivityResultLauncher.launch(shareIntent) + } } - super.onActivityResult(requestCode, resultCode, data) } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { + override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.log_viewer, menu) - saveButton = menu?.findItem(R.id.save_log) + saveButton = menu.findItem(R.id.save_log) return true } @@ -152,94 +142,126 @@ class LogViewerActivity : AppCompatActivity() { finish() true } + R.id.save_log -> { - coroutineScope.launch { saveLog() } + saveButton?.isEnabled = false + lifecycleScope.launch { saveLog() } true } + else -> super.onOptionsItemSelected(item) } } - override fun onDestroy() { - super.onDestroy() - coroutineScope.cancel() + private val downloadsFileSaver = DownloadsFileSaver(this) + + private suspend fun rawLogBytes(): ByteArray { + val builder = StringBuilder() + withContext(Dispatchers.IO) { + for (i in 0 until rawLogLines.size()) { + builder.append(rawLogLines[i]) + builder.append('\n') + } + } + return builder.toString().toByteArray(Charsets.UTF_8) } private suspend fun saveLog() { - val context = this - withContext(Dispatchers.Main) { - saveButton?.isEnabled = false - withContext(Dispatchers.IO) { - var exception: Throwable? = null - var outputFile: DownloadsFileSaver.DownloadsFile? = null - try { - outputFile = DownloadsFileSaver.save(context, "wireguard-log.txt", "text/plain", true) - outputFile.outputStream.use { - it.write(rawLogLines.toString().toByteArray(Charsets.UTF_8)) - } - } catch (e: Throwable) { - outputFile?.delete() - exception = e - } - withContext(Dispatchers.Main) { - saveButton?.isEnabled = true - Snackbar.make(findViewById(android.R.id.content), - if (exception == null) getString(R.string.log_export_success, outputFile?.fileName) - else getString(R.string.log_export_error, ErrorMessages[exception]), - if (exception == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG) - .setAnchorView(binding.shareFab) - .show() - } + var exception: Throwable? = null + var outputFile: DownloadsFileSaver.DownloadsFile? = null + withContext(Dispatchers.IO) { + try { + outputFile = downloadsFileSaver.save("wireguard-log.txt", "text/plain", true) + outputFile?.outputStream?.write(rawLogBytes()) + } catch (e: Throwable) { + outputFile?.delete() + exception = e } } + saveButton?.isEnabled = true + if (outputFile == null) + return + Snackbar.make( + findViewById(android.R.id.content), + if (exception == null) getString(R.string.log_export_success, outputFile?.fileName) + else getString(R.string.log_export_error, ErrorMessages[exception]), + if (exception == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG + ) + .setAnchorView(binding.shareFab) + .show() } private suspend fun streamingLog() = withContext(Dispatchers.IO) { val builder = ProcessBuilder().command("logcat", "-b", "all", "-v", "threadtime", "*:V") builder.environment()["LC_ALL"] = "C" - val process = try { - builder.start() - } catch (e: IOException) { - e.printStackTrace() - return@withContext - } - val stdout = BufferedReader(InputStreamReader(process!!.inputStream, StandardCharsets.UTF_8)) - var haveScrolled = false - val start = System.nanoTime() - var startPeriod = start - while (true) { - val line = stdout.readLine() ?: break - rawLogLines.append(line) - rawLogLines.append('\n') - val logLine = parseLine(line) - withContext(Dispatchers.Main) { + var process: Process? = null + try { + process = try { + builder.start() + } catch (e: IOException) { + Log.e(TAG, Log.getStackTraceString(e)) + return@withContext + } + val stdout = BufferedReader(InputStreamReader(process!!.inputStream, StandardCharsets.UTF_8)) + + var posStart = 0 + var timeLastNotify = System.nanoTime() + var priorModified = false + val bufferedLogLines = arrayListOf<LogLine>() + var timeout = 1000000000L / 2 // The timeout is initially small so that the view gets populated immediately. + val MAX_LINES = (1 shl 16) - 1 + val MAX_BUFFERED_LINES = (1 shl 14) - 1 + + while (true) { + val line = stdout.readLine() ?: break + if (rawLogLines.size() >= MAX_LINES) + rawLogLines.popFirst() + rawLogLines.addLast(line) + val logLine = parseLine(line) if (logLine != null) { - recyclerView?.let { - val shouldScroll = haveScrolled && !it.canScrollVertically(1) - logLines.add(logLine) - if (haveScrolled) logAdapter.notifyDataSetChanged() - if (shouldScroll) - it.scrollToPosition(logLines.size - 1) - } + bufferedLogLines.add(logLine) } else { - /* I'd prefer for the next line to be: - * logLines.lastOrNull()?.msg += "\n$line" - * However, as of writing, that causes the kotlin compiler to freak out and crash, spewing bytecode. - */ - logLines.lastOrNull()?.apply { msg += "\n$line" } - if (haveScrolled) logAdapter.notifyDataSetChanged() + if (bufferedLogLines.isNotEmpty()) { + bufferedLogLines.last().msg += "\n$line" + } else if (!logLines.isEmpty()) { + logLines[logLines.size() - 1].msg += "\n$line" + priorModified = true + } } - if (!haveScrolled) { - val end = System.nanoTime() - val scroll = (end - start) > 1000000000L * 2.5 || !stdout.ready() - if (logLines.isNotEmpty() && (scroll || (end - startPeriod) > 1000000000L / 4)) { - logAdapter.notifyDataSetChanged() - recyclerView?.scrollToPosition(logLines.size - 1) - startPeriod = end + val timeNow = System.nanoTime() + if (bufferedLogLines.size < MAX_BUFFERED_LINES && (timeNow - timeLastNotify) < timeout && stdout.ready()) + continue + timeout = 1000000000L * 5 / 2 // Increase the timeout after the initial view has something in it. + timeLastNotify = timeNow + + withContext(Dispatchers.Main.immediate) { + val isScrolledToBottomAlready = recyclerView?.canScrollVertically(1) == false + if (priorModified) { + logAdapter.notifyItemChanged(posStart - 1) + priorModified = false + } + val fullLen = logLines.size() + bufferedLogLines.size + if (fullLen >= MAX_LINES) { + val numToRemove = fullLen - MAX_LINES + 1 + logLines.removeFromStart(numToRemove) + logAdapter.notifyItemRangeRemoved(0, numToRemove) + posStart -= numToRemove + + } + for (bufferedLine in bufferedLogLines) { + logLines.addLast(bufferedLine) + } + bufferedLogLines.clear() + logAdapter.notifyItemRangeInserted(posStart, logLines.size() - posStart) + posStart = logLines.size() + + if (isScrolledToBottomAlready) { + recyclerView?.scrollToPosition(logLines.size() - 1) } - if (scroll) haveScrolled = true } } + } finally { + process?.destroy() } } @@ -269,9 +291,10 @@ class LogViewerActivity : AppCompatActivity() { * * <pre>05-26 11:02:36.886 5689 5689 D AndroidRuntime: CheckJNI is OFF.</pre> */ - private val THREADTIME_LINE: Pattern = Pattern.compile("^(\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3})(?:\\s+[0-9A-Za-z]+)?\\s+(\\d+)\\s+(\\d+)\\s+([A-Z])\\s+(.+?)\\s*: (.*)$") + private val THREADTIME_LINE: Pattern = + Pattern.compile("^(\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}.\\d{3})(?:\\s+[0-9A-Za-z]+)?\\s+(\\d+)\\s+(\\d+)\\s+([A-Z])\\s+(.+?)\\s*: (.*)$") private val LOGS: MutableMap<String, ByteArray> = ConcurrentHashMap() - private const val SHARE_ACTIVITY_REQUEST = 49133 + private const val TAG = "WireGuard/LogViewerActivity" } private inner class LogEntryAdapter : RecyclerView.Adapter<LogEntryAdapter.ViewHolder>() { @@ -280,7 +303,7 @@ class LogViewerActivity : AppCompatActivity() { private fun levelToColor(level: String): Int { return when (level) { - "D" -> debugColor + "V", "D" -> debugColor "E" -> errorColor "I" -> infoColor "W" -> warningColor @@ -288,11 +311,11 @@ class LogViewerActivity : AppCompatActivity() { } } - override fun getItemCount() = logLines.size + override fun getItemCount() = logLines.size() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val view = LayoutInflater.from(parent.context) - .inflate(R.layout.log_viewer_entry, parent, false) + .inflate(R.layout.log_viewer_entry, parent, false) return ViewHolder(view) } @@ -303,8 +326,10 @@ class LogViewerActivity : AppCompatActivity() { else SpannableString("${line.tag}: ${line.msg}").apply { setSpan(StyleSpan(BOLD), 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - setSpan(ForegroundColorSpan(levelToColor(line.level)), - 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + setSpan( + ForegroundColorSpan(levelToColor(line.level)), + 0, "${line.tag}:".length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) } holder.layout.apply { findViewById<MaterialTextView>(R.id.log_date).text = line.time.toString() @@ -326,11 +351,11 @@ class LogViewerActivity : AppCompatActivity() { override fun insert(uri: Uri, values: ContentValues?): Uri? = null override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): Cursor? = - logForUri(uri)?.let { - val m = MatrixCursor(arrayOf(android.provider.OpenableColumns.DISPLAY_NAME, android.provider.OpenableColumns.SIZE), 1) - m.addRow(arrayOf("wireguard-log.txt", it.size.toLong())) - m - } + logForUri(uri)?.let { + val m = MatrixCursor(arrayOf(android.provider.OpenableColumns.DISPLAY_NAME, android.provider.OpenableColumns.SIZE), 1) + m.addRow(arrayOf<Any>("wireguard-log.txt", it.size.toLong())) + m + } override fun onCreate(): Boolean = true @@ -340,7 +365,8 @@ class LogViewerActivity : AppCompatActivity() { override fun getType(uri: Uri): String? = logForUri(uri)?.let { "text/plain" } - override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>? = getType(uri)?.let { if (compareMimeTypes(it, mimeTypeFilter)) arrayOf(it) else null } + override fun getStreamTypes(uri: Uri, mimeTypeFilter: String): Array<String>? = + getType(uri)?.let { if (compareMimeTypes(it, mimeTypeFilter)) arrayOf(it) else null } override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { if (mode != "r") return null @@ -348,7 +374,7 @@ class LogViewerActivity : AppCompatActivity() { return openPipeHelper(uri, "text/plain", null, log) { output, _, _, _, l -> try { FileOutputStream(output.fileDescriptor).write(l!!) - } catch (_: Exception) { + } catch (_: Throwable) { } } } |