Introduce realtime log viewer
This contains a share button and a save button, the former using a custom content provider. Co-authored-by: Jason A. Donenfeld <Jason@zx2c4.com> Signed-off-by: Harsh Shandilya <me@msfjarvis.dev> Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
This commit is contained in:
committed by
Jason A. Donenfeld
co-authored by
Jason A. Donenfeld
parent
6f973afa36
commit
63a395125a
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
* Copyright © 2020 WireGuard LLC. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.wireguard.android.activity
|
||||
|
||||
import android.content.ClipDescription.compareMimeTypes
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.graphics.Typeface.BOLD
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.textview.MaterialTextView
|
||||
import com.wireguard.android.BuildConfig
|
||||
import com.wireguard.android.R
|
||||
import com.wireguard.android.databinding.LogViewerActivityBinding
|
||||
import com.wireguard.android.util.DownloadsFileSaver
|
||||
import com.wireguard.android.widget.EdgeToEdge.setUpFAB
|
||||
import com.wireguard.android.widget.EdgeToEdge.setUpRoot
|
||||
import com.wireguard.android.widget.EdgeToEdge.setUpScrollingContent
|
||||
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
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.DateFormat
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
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 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) }
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private val debugColor by lazy { resources.getColor(R.color.debug_tag_color) }
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private val errorColor by lazy { resources.getColor(R.color.error_tag_color) }
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private val infoColor by lazy { resources.getColor(R.color.info_tag_color) }
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private val warningColor by lazy { resources.getColor(R.color.warning_tag_color) }
|
||||
|
||||
private var lastUri: Uri? = null
|
||||
|
||||
private fun revokeLastUri() {
|
||||
lastUri?.let {
|
||||
LOGS.remove(it.pathSegments.lastOrNull())
|
||||
revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
lastUri = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
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
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = logAdapter
|
||||
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
|
||||
}
|
||||
|
||||
coroutineScope.launch { streamingLog() }
|
||||
|
||||
binding.shareFab.setOnClickListener {
|
||||
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)
|
||||
.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()
|
||||
}
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.log_viewer, menu)
|
||||
saveButton = menu?.findItem(R.id.save_log)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
R.id.save_log -> {
|
||||
coroutineScope.launch { saveLog() }
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
private suspend fun saveLog() {
|
||||
val context = this
|
||||
withContext(Dispatchers.Main) {
|
||||
saveButton?.isEnabled = false
|
||||
withContext(Dispatchers.IO) {
|
||||
val outputFile = DownloadsFileSaver.save(context, "wireguard-log.txt", "text/plain", true)
|
||||
outputFile.outputStream.use {
|
||||
it.write(rawLogLines.toString().toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Snackbar.make(findViewById(android.R.id.content),
|
||||
getString(R.string.log_export_success, outputFile.fileName),
|
||||
Snackbar.LENGTH_SHORT)
|
||||
.setAnchorView(binding.shareFab)
|
||||
.show()
|
||||
saveButton?.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
while (true) {
|
||||
val line = stdout.readLine() ?: break
|
||||
rawLogLines.append(line)
|
||||
rawLogLines.append('\n')
|
||||
val logLine = parseLine(line)
|
||||
if (logLine != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
recyclerView?.let {
|
||||
val shouldScroll = it.canScrollVertically(1)
|
||||
logLines.add(logLine)
|
||||
logAdapter.notifyDataSetChanged()
|
||||
if (!shouldScroll)
|
||||
it.scrollToPosition(logLines.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTime(timeStr: String): Date? {
|
||||
val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
return try {
|
||||
formatter.parse("$year-$timeStr")
|
||||
} catch (e: ParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLine(line: String): LogLine? {
|
||||
val m: Matcher = THREADTIME_LINE.matcher(line)
|
||||
return if (m.matches()) {
|
||||
LogLine(m.group(2)!!.toInt(), m.group(3)!!.toInt(), parseTime(m.group(1)!!), m.group(4)!!, m.group(5)!!, m.group(6)!!)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private data class LogLine(val pid: Int, val tid: Int, val time: Date?, val level: String, val tag: String, val msg: String)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Match a single line of `logcat -v threadtime`, such as:
|
||||
*
|
||||
* <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 LOGS: MutableMap<String, ByteArray> = ConcurrentHashMap()
|
||||
private var SHARE_ACTIVITY_REQUEST = 49133
|
||||
}
|
||||
|
||||
private inner class LogEntryAdapter : RecyclerView.Adapter<LogEntryAdapter.ViewHolder>() {
|
||||
|
||||
private inner class ViewHolder(val layout: View, var isSingleLine: Boolean = true) : RecyclerView.ViewHolder(layout)
|
||||
|
||||
private fun levelToColor(level: String): Int {
|
||||
return when (level) {
|
||||
"D" -> debugColor
|
||||
"E" -> errorColor
|
||||
"I" -> infoColor
|
||||
"W" -> warningColor
|
||||
else -> defaultColor
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val line = logLines[position]
|
||||
val spannable = if (position > 0 && logLines[position - 1].tag == line.tag)
|
||||
SpannableString(line.msg)
|
||||
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)
|
||||
}
|
||||
holder.layout.apply {
|
||||
findViewById<MaterialTextView>(R.id.log_date).text = line.time.toString()
|
||||
findViewById<MaterialTextView>(R.id.log_msg).apply {
|
||||
setSingleLine()
|
||||
text = spannable
|
||||
setOnClickListener {
|
||||
isSingleLine = !holder.isSingleLine
|
||||
holder.isSingleLine = !holder.isSingleLine
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExportedLogContentProvider : ContentProvider() {
|
||||
private fun logForUri(uri: Uri): ByteArray? = LOGS[uri.pathSegments.lastOrNull()]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
|
||||
|
||||
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 openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
if (mode != "r") return null
|
||||
val log = logForUri(uri) ?: return null
|
||||
return openPipeHelper(uri, "text/plain", null, log) { output, _, _, _, l ->
|
||||
FileOutputStream(output.fileDescriptor).write(l!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
package com.wireguard.android.activity
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -100,6 +101,10 @@ class SettingsActivity : ThemeChangeAwareActivity() {
|
||||
wgQuickOnlyPrefs.forEach { it.parent?.removePreference(it) }
|
||||
}
|
||||
}
|
||||
preferenceManager.findPreference<Preference>("log_viewer")?.setOnPreferenceClickListener {
|
||||
startActivity(Intent(requireContext(), LogViewerActivity::class.java))
|
||||
true
|
||||
}
|
||||
val moduleInstaller = preferenceManager.findPreference<Preference>("module_downloader")
|
||||
val kernelModuleDisabler = preferenceManager.findPreference<Preference>("kernel_module_disabler")
|
||||
moduleInstaller?.isVisible = false
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
package com.wireguard.android.preference
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.AttributeSet
|
||||
import android.util.Log
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.wireguard.android.Application
|
||||
import com.wireguard.android.R
|
||||
import com.wireguard.android.util.DownloadsFileSaver
|
||||
import com.wireguard.android.util.ErrorMessages
|
||||
import com.wireguard.android.util.FragmentUtils
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
/**
|
||||
* Preference implementing a button that asynchronously exports logs.
|
||||
*/
|
||||
class LogExporterPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) {
|
||||
private var exportedFilePath: String? = null
|
||||
private fun exportLog() {
|
||||
Application.getAsyncWorker().supplyAsync {
|
||||
val outputFile = DownloadsFileSaver.save(context, "wireguard-log.txt", "text/plain", true)
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf(
|
||||
"logcat", "-b", "all", "-d", "-v", "threadtime", "*:V"))
|
||||
BufferedReader(InputStreamReader(process.inputStream)).use { stdout ->
|
||||
BufferedReader(InputStreamReader(process.errorStream)).use { stderr ->
|
||||
while (true) {
|
||||
val line = stdout.readLine() ?: break
|
||||
outputFile.outputStream.write(line.toByteArray())
|
||||
outputFile.outputStream.write('\n'.toInt())
|
||||
}
|
||||
outputFile.outputStream.close()
|
||||
if (process.waitFor() != 0) {
|
||||
val errors = StringBuilder()
|
||||
errors.append(R.string.logcat_error)
|
||||
while (true) {
|
||||
val line = stderr.readLine() ?: break
|
||||
errors.append(line)
|
||||
}
|
||||
throw Exception(errors.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
outputFile.delete()
|
||||
throw e
|
||||
}
|
||||
outputFile.fileName
|
||||
}.whenComplete(this::exportLogComplete)
|
||||
}
|
||||
|
||||
private fun exportLogComplete(filePath: String, throwable: Throwable?) {
|
||||
if (throwable != null) {
|
||||
val error = ErrorMessages.get(throwable)
|
||||
val message = context.getString(R.string.log_export_error, error)
|
||||
Log.e(TAG, message, throwable)
|
||||
Snackbar.make(
|
||||
FragmentUtils.getPrefActivity(this).findViewById(android.R.id.content),
|
||||
message, Snackbar.LENGTH_LONG).show()
|
||||
isEnabled = true
|
||||
} else {
|
||||
exportedFilePath = filePath
|
||||
notifyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSummary() = if (exportedFilePath == null)
|
||||
context.getString(R.string.log_export_summary)
|
||||
else
|
||||
context.getString(R.string.log_export_success, exportedFilePath)
|
||||
|
||||
override fun getTitle() = context.getString(R.string.log_export_title)
|
||||
|
||||
override fun onClick() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
FragmentUtils.getPrefActivity(this)
|
||||
.ensurePermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults ->
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
isEnabled = false
|
||||
exportLog()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isEnabled = false
|
||||
exportLog()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = "WireGuard/" + LogExporterPreference::class.java.simpleName
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user