From ae0e23acc931542d142f05ecaf4ed8277267cf7a Mon Sep 17 00:00:00 2001 From: Hardcore Sushi Date: Sat, 30 Nov 2024 13:22:51 +0100 Subject: [PATCH] Ring buffer logcat viewer --- .../sushi/hardcore/droidfs/LogcatActivity.kt | 22 ++-- .../sushi/hardcore/droidfs/util/RingBuffer.kt | 45 +++++++ .../droidfs/widgets/RingBufferTextView.kt | 111 ++++++++++++++++++ app/src/main/res/layout/activity_logcat.xml | 5 +- app/src/main/res/values/attrs.xml | 4 + 5 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/sushi/hardcore/droidfs/util/RingBuffer.kt create mode 100644 app/src/main/java/sushi/hardcore/droidfs/widgets/RingBufferTextView.kt diff --git a/app/src/main/java/sushi/hardcore/droidfs/LogcatActivity.kt b/app/src/main/java/sushi/hardcore/droidfs/LogcatActivity.kt index 3eac7e2..95a5dbe 100644 --- a/app/src/main/java/sushi/hardcore/droidfs/LogcatActivity.kt +++ b/app/src/main/java/sushi/hardcore/droidfs/LogcatActivity.kt @@ -11,10 +11,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import sushi.hardcore.droidfs.databinding.ActivityLogcatBinding import java.io.BufferedReader -import java.io.BufferedWriter import java.io.InputStreamReader import java.io.InterruptedIOException -import java.io.OutputStreamWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -40,12 +38,10 @@ class LogcatActivity: BaseActivity() { lifecycleScope.launch(Dispatchers.IO) { try { - BufferedReader(InputStreamReader(Runtime.getRuntime().exec("logcat").also { + BufferedReader(InputStreamReader(ProcessBuilder("logcat", "-T", "500").start().also { process = it - }.inputStream)).forEachLine { - binding.content.post { - binding.content.append("$it\n") - } + }.inputStream)).lineSequence().forEach { + binding.content.append(it) } } catch (_: InterruptedIOException) {} } @@ -77,11 +73,13 @@ class LogcatActivity: BaseActivity() { private fun saveTo(uri: Uri) { lifecycleScope.launch(Dispatchers.IO) { - BufferedWriter(OutputStreamWriter(contentResolver.openOutputStream(uri))).use { - it.write(binding.content.text.toString()) - } - launch(Dispatchers.Main) { - Toast.makeText(this@LogcatActivity, R.string.logcat_saved, Toast.LENGTH_SHORT).show() + contentResolver.openOutputStream(uri)?.use { output -> + Runtime.getRuntime().exec("logcat -d").inputStream.use { input -> + input.copyTo(output) + } + launch(Dispatchers.Main) { + Toast.makeText(this@LogcatActivity, R.string.logcat_saved, Toast.LENGTH_SHORT).show() + } } } } diff --git a/app/src/main/java/sushi/hardcore/droidfs/util/RingBuffer.kt b/app/src/main/java/sushi/hardcore/droidfs/util/RingBuffer.kt new file mode 100644 index 0000000..de79574 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/util/RingBuffer.kt @@ -0,0 +1,45 @@ +package sushi.hardcore.droidfs.util + +/** + * Minimal ring buffer implementation. + */ +class RingBuffer(private val capacity: Int) { + private val buffer = arrayOfNulls(capacity) + + /** + * Position of the first cell. + */ + private var head = 0 + + /** + * Position of the next free (or to be overwritten) cell. + */ + private var tail = 0 + var size = 0 + private set + + fun addLast(e: T) { + buffer[tail] = e + tail = (tail + 1) % capacity + if (size < capacity) { + size += 1 + } else { + head = (head + 1) % capacity + } + } + + @Suppress("UNCHECKED_CAST") + fun popFirst() = buffer[head].also { + head = (head + 1) % capacity + size -= 1 + } as T + + /** + * Empty the buffer and call [f] for each element. + */ + fun drain(f: (T) -> Unit) { + repeat(size) { + f(popFirst()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/sushi/hardcore/droidfs/widgets/RingBufferTextView.kt b/app/src/main/java/sushi/hardcore/droidfs/widgets/RingBufferTextView.kt new file mode 100644 index 0000000..b6938f4 --- /dev/null +++ b/app/src/main/java/sushi/hardcore/droidfs/widgets/RingBufferTextView.kt @@ -0,0 +1,111 @@ +package sushi.hardcore.droidfs.widgets + +import android.content.Context +import android.util.AttributeSet +import android.widget.TextView +import androidx.appcompat.widget.AppCompatTextView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import sushi.hardcore.droidfs.R +import sushi.hardcore.droidfs.util.RingBuffer + +/** + * A [TextView] dropping first lines when appended too fast. + * + * The dropping rate depends on the size of the ring buffer (set by the + * [R.styleable.RingBufferTextView_updateMaxLines] attribute) and the UI update + * time. If the buffer becomes full before the UI finished to update, the first + * (oldest) lines are dropped such as only the latest appended lines in the buffer + * are going to be displayed. + * + * If the ring buffer never fills up completely, the content of the [TextView] can + * grow indefinitely. + */ +class RingBufferTextView: AppCompatTextView { + private var updateMaxLines = -1 + private var averageLineLength = -1 + + /** + * Lines ring buffer of capacity [updateMaxLines]. + * + * Must never be used without acquiring the [bufferLock] mutex. + */ + private val buffer by lazy { + RingBuffer(updateMaxLines) + } + private val bufferLock = Mutex() + + /** + * Channel used to notify the worker coroutine that a new line has + * been appended to the ring buffer. No data is sent through it. + * + * We use a buffered channel with a capacity of 1 to ensure that the worker + * can be notified that at least one update occurred while allowing the + * sender to never block. + * + * A greater capacity is not desired because the worker empties the buffer each time. + */ + private val channel = Channel(1) + private val scope = CoroutineScope(Dispatchers.Default) + + constructor(context: Context, attrs: AttributeSet): super(context, attrs) { init(context, attrs) } + constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int): super(context, attrs, defStyleAttr) { init(context, attrs) } + private fun init(context: Context, attrs: AttributeSet) { + with (context.obtainStyledAttributes(attrs, R.styleable.RingBufferTextView)) { + updateMaxLines = getInt(R.styleable.RingBufferTextView_updateMaxLines, -1) + averageLineLength = getInt(R.styleable.RingBufferTextView_averageLineLength, -1) + recycle() + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + scope.launch { + val text = StringBuilder(updateMaxLines*averageLineLength) + while (isActive) { + channel.receive() + val size: Int + bufferLock.withLock { + size = buffer.size + buffer.drain { + text.appendLine(it) + } + } + withContext(Dispatchers.Main) { + if (size >= updateMaxLines) { + // Buffer full. Lines could have been dropped so we replace the content. + setText(text.toString()) + } else { + super.append(text.toString()) + } + } + text.clear() + } + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + scope.cancel() + } + + /** + * Append a line to the ring buffer and update the UI. + * + * If the buffer is full (when adding [R.styleable.RingBufferTextView_updateMaxLines] + * lines before the UI had time to update), the oldest line is overwritten. + */ + suspend fun append(line: String) { + bufferLock.withLock { + buffer.addLast(line) + } + channel.trySend(Unit) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_logcat.xml b/app/src/main/res/layout/activity_logcat.xml index c2174b8..da442a8 100644 --- a/app/src/main/res/layout/activity_logcat.xml +++ b/app/src/main/res/layout/activity_logcat.xml @@ -1,5 +1,6 @@ @@ -8,10 +9,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 74e7e3a..61e1708 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -2,4 +2,8 @@ + + + + \ No newline at end of file