parent
1306fdc8b2
commit
fea31437cd
|
@ -72,12 +72,14 @@ android {
|
|||
"proguard-rules.pro",
|
||||
)
|
||||
signingConfig = signingConfigs.getByName(Constants.RELEASE)
|
||||
resValue("string", "provider", "\"${Constants.APP_NAME}.provider\"")
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
resValue("string", "app_name", "WG Tunnel - Debug")
|
||||
isDebuggable = true
|
||||
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"")
|
||||
}
|
||||
|
||||
create(Constants.PRERELEASE) {
|
||||
|
@ -85,6 +87,7 @@ android {
|
|||
applicationIdSuffix = ".prerelease"
|
||||
versionNameSuffix = "-pre"
|
||||
resValue("string", "app_name", "WG Tunnel - Pre")
|
||||
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"")
|
||||
}
|
||||
|
||||
create(Constants.NIGHTLY) {
|
||||
|
@ -92,6 +95,7 @@ android {
|
|||
applicationIdSuffix = ".nightly"
|
||||
versionNameSuffix = "-nightly"
|
||||
resValue("string", "app_name", "WG Tunnel - Nightly")
|
||||
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"")
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
|
|
|
@ -98,6 +98,16 @@
|
|||
android:launchMode="singleInstance"
|
||||
android:theme="@android:style/Theme.NoDisplay" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="@string/provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".service.tile.TunnelControlTile"
|
||||
android:exported="true"
|
||||
|
|
|
@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel
|
|||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import com.zaneschepke.logcatter.LocalLogCollector
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
|
||||
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
|
||||
|
@ -23,7 +23,7 @@ class WireGuardAutoTunnel : Application() {
|
|||
lateinit var applicationScope: CoroutineScope
|
||||
|
||||
@Inject
|
||||
lateinit var localLogCollector: LocalLogCollector
|
||||
lateinit var logReader: LogReader
|
||||
|
||||
@Inject
|
||||
@IoDispatcher
|
||||
|
@ -47,7 +47,7 @@ class WireGuardAutoTunnel : Application() {
|
|||
}
|
||||
if (!isRunningOnTv()) {
|
||||
applicationScope.launch(ioDispatcher) {
|
||||
localLogCollector.start()
|
||||
logReader.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
package com.zaneschepke.wireguardautotunnel.module
|
||||
|
||||
import android.content.Context
|
||||
import com.zaneschepke.logcatter.LocalLogCollector
|
||||
import com.zaneschepke.logcatter.LogcatUtil
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.logcatter.LogcatCollector
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
@ -24,7 +24,7 @@ class AppModule {
|
|||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideLogCollect(@ApplicationContext context: Context): LocalLogCollector {
|
||||
return LogcatUtil.init(context = context)
|
||||
fun provideLogCollect(@ApplicationContext context: Context): LogReader {
|
||||
return LogcatCollector.init(context = context)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
@ -13,7 +12,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
@ -21,8 +20,12 @@ import androidx.compose.material3.Scaffold
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
@ -35,9 +38,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@Composable
|
||||
|
@ -45,35 +46,52 @@ fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
|
|||
val logs = viewModel.logs
|
||||
|
||||
val context = LocalContext.current
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
|
||||
val lazyColumnListState = rememberLazyListState()
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var isAutoScrolling by remember { mutableStateOf(true) }
|
||||
var lastScrollPosition by remember { mutableIntStateOf(0) }
|
||||
|
||||
LaunchedEffect(logs.size) {
|
||||
scope.launch {
|
||||
LaunchedEffect(isAutoScrolling) {
|
||||
if (isAutoScrolling) {
|
||||
lazyColumnListState.animateScrollToItem(logs.size)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(logs.size) {
|
||||
if (isAutoScrolling) {
|
||||
lazyColumnListState.animateScrollToItem(logs.size)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(lazyColumnListState) {
|
||||
snapshotFlow { lazyColumnListState.firstVisibleItemIndex }
|
||||
.collect { currentScrollPosition ->
|
||||
if (currentScrollPosition < lastScrollPosition && isAutoScrolling) {
|
||||
isAutoScrolling = false
|
||||
}
|
||||
val visible = lazyColumnListState.layoutInfo.visibleItemsInfo
|
||||
if (visible.isNotEmpty()) {
|
||||
if (visible.last().index
|
||||
== lazyColumnListState.layoutInfo.totalItemsCount - 1 && !isAutoScrolling
|
||||
) {
|
||||
isAutoScrolling = true
|
||||
}
|
||||
}
|
||||
lastScrollPosition = currentScrollPosition
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.saveLogsToFile().onSuccess {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.logs_saved),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
viewModel.shareLogs(context)
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
val icon = Icons.Filled.Save
|
||||
val icon = Icons.Filled.Share
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = icon.name,
|
||||
|
|
|
@ -1,19 +1,25 @@
|
|||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.zaneschepke.logcatter.LocalLogCollector
|
||||
import com.zaneschepke.logcatter.LogReader
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.module.MainDispatcher
|
||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.util.FileUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.chunked
|
||||
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
@ -22,8 +28,7 @@ import javax.inject.Inject
|
|||
class LogsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val localLogCollector: LocalLogCollector,
|
||||
private val fileUtils: FileUtils,
|
||||
private val localLogCollector: LogReader,
|
||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
@ -44,13 +49,19 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun saveLogsToFile(): Result<Unit> {
|
||||
val file =
|
||||
localLogCollector.getLogFile().getOrElse {
|
||||
return Result.failure(it)
|
||||
}
|
||||
val fileContent = fileUtils.readBytesFromFile(file)
|
||||
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
|
||||
return fileUtils.saveByteArrayToDownloads(fileContent, fileName)
|
||||
fun shareLogs(context: Context): Job = viewModelScope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
val sharePath = File(context.filesDir, "external_files")
|
||||
if (sharePath.exists()) sharePath.delete()
|
||||
sharePath.mkdir()
|
||||
val file = File("${sharePath.path + "/" + Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
|
||||
if (file.exists()) file.delete()
|
||||
file.createNewFile()
|
||||
localLogCollector.zipLogFiles(file.absolutePath)
|
||||
val uri = FileProvider.getUriForFile(context, context.getString(R.string.provider), file)
|
||||
context.launchShareFile(uri)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,16 @@ fun Context.openWebUrl(url: String): Result<Unit> {
|
|||
}
|
||||
}
|
||||
|
||||
fun Context.launchShareFile(file: Uri) {
|
||||
val shareIntent = Intent().apply {
|
||||
setAction(Intent.ACTION_SEND)
|
||||
setType("*/*")
|
||||
putExtra(Intent.EXTRA_STREAM, file)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
this.startActivity(Intent.createChooser(shareIntent, ""))
|
||||
}
|
||||
|
||||
fun Context.showToast(resId: Int) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path
|
||||
name="share"
|
||||
path="external_files/"/>
|
||||
</paths>
|
|
@ -2,14 +2,12 @@ package com.zaneschepke.logcatter
|
|||
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.io.File
|
||||
|
||||
interface LocalLogCollector {
|
||||
interface LogReader {
|
||||
suspend fun start(onLogMessage: ((message: LogMessage) -> Unit)? = null)
|
||||
|
||||
fun stop()
|
||||
|
||||
suspend fun getLogFile(): Result<File>
|
||||
|
||||
fun zipLogFiles(path: String)
|
||||
suspend fun deleteAndClearLogs()
|
||||
val bufferedLogs: Flow<LogMessage>
|
||||
val liveLogs: Flow<LogMessage>
|
||||
}
|
|
@ -0,0 +1,274 @@
|
|||
package com.zaneschepke.logcatter
|
||||
|
||||
import android.content.Context
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
object LogcatCollector {
|
||||
|
||||
private const val MAX_FILE_SIZE = 2097152L // 2MB
|
||||
private const val MAX_FOLDER_SIZE = 10485760L // 10MB
|
||||
|
||||
private val findKeyRegex = """[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=""".toRegex()
|
||||
private val findIpv6AddressRegex = """^([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}${'$'}""".toRegex()
|
||||
private val findIpv4AddressRegex = """((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}""".toRegex()
|
||||
private val findTunnelNameRegex = """(?<=tunnel ).*?(?= UP| DOWN)""".toRegex()
|
||||
|
||||
private val ioDispatcher = Dispatchers.IO
|
||||
|
||||
private object LogcatHelperInit {
|
||||
var maxFileSize: Long = MAX_FILE_SIZE
|
||||
var maxFolderSize: Long = MAX_FOLDER_SIZE
|
||||
var pID: Int = 0
|
||||
var publicAppDirectory = ""
|
||||
var logcatPath = ""
|
||||
}
|
||||
|
||||
fun init(maxFileSize: Long = MAX_FILE_SIZE, maxFolderSize: Long = MAX_FOLDER_SIZE, context: Context): LogReader {
|
||||
if (maxFileSize > maxFolderSize) {
|
||||
throw IllegalStateException("maxFileSize must be less than maxFolderSize")
|
||||
}
|
||||
synchronized(LogcatHelperInit) {
|
||||
LogcatHelperInit.maxFileSize = maxFileSize
|
||||
LogcatHelperInit.maxFolderSize = maxFolderSize
|
||||
LogcatHelperInit.pID = android.os.Process.myPid()
|
||||
context.getExternalFilesDir(null)?.let {
|
||||
LogcatHelperInit.publicAppDirectory = it.absolutePath
|
||||
LogcatHelperInit.logcatPath = LogcatHelperInit.publicAppDirectory + File.separator + "logs"
|
||||
val logDirectory = File(LogcatHelperInit.logcatPath)
|
||||
if (!logDirectory.exists()) {
|
||||
logDirectory.mkdir()
|
||||
}
|
||||
}
|
||||
return Logcat
|
||||
}
|
||||
}
|
||||
|
||||
internal object Logcat : LogReader {
|
||||
|
||||
private var logcatReader: LogcatReader? = null
|
||||
|
||||
private fun obfuscator(log: String): String {
|
||||
return findKeyRegex.replace(log, "<crypto-key>").let { first ->
|
||||
findIpv6AddressRegex.replace(first, "<ipv6-address>").let { second ->
|
||||
findTunnelNameRegex.replace(second, "<tunnel>")
|
||||
}
|
||||
}.let { last -> findIpv4AddressRegex.replace(last, "<ipv4-address>") }
|
||||
}
|
||||
|
||||
override suspend fun start(onLogMessage: ((message: LogMessage) -> Unit)?) {
|
||||
logcatReader ?: run {
|
||||
logcatReader = LogcatReader(LogcatHelperInit.pID.toString(), LogcatHelperInit.logcatPath, onLogMessage)
|
||||
}
|
||||
logcatReader?.run()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
logcatReader?.stop()
|
||||
logcatReader = null
|
||||
}
|
||||
|
||||
override fun zipLogFiles(path: String) {
|
||||
logcatReader?.pause()
|
||||
zipAll(path)
|
||||
logcatReader?.resume()
|
||||
}
|
||||
|
||||
private fun zipAll(zipFilePath: String) {
|
||||
val sourceFile = File(LogcatHelperInit.logcatPath)
|
||||
val outputZipFile = File(zipFilePath)
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos ->
|
||||
sourceFile.walkTopDown().forEach { file ->
|
||||
val zipFileName = file.absolutePath.removePrefix(sourceFile.absolutePath).removePrefix("/")
|
||||
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
|
||||
zos.putNextEntry(entry)
|
||||
if (file.isFile) {
|
||||
file.inputStream().use {
|
||||
it.copyTo(zos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override suspend fun deleteAndClearLogs() {
|
||||
withContext(ioDispatcher) {
|
||||
logcatReader?.pause()
|
||||
_bufferedLogs.resetReplayCache()
|
||||
logcatReader?.deleteAllFiles()
|
||||
logcatReader?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private val _bufferedLogs = MutableSharedFlow<LogMessage>(
|
||||
replay = 10_000,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
private val _liveLogs = MutableSharedFlow<LogMessage>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val bufferedLogs: Flow<LogMessage> = _bufferedLogs.asSharedFlow()
|
||||
|
||||
override val liveLogs: Flow<LogMessage> = _liveLogs.asSharedFlow()
|
||||
|
||||
private class LogcatReader(
|
||||
pID: String,
|
||||
private val logcatPath: String,
|
||||
private val callback: ((input: LogMessage) -> Unit)?,
|
||||
) {
|
||||
private var logcatProc: Process? = null
|
||||
private var reader: BufferedReader? = null
|
||||
|
||||
@get:Synchronized @set:Synchronized
|
||||
private var paused = false
|
||||
|
||||
@get:Synchronized @set:Synchronized
|
||||
private var stopped = false
|
||||
private var command = ""
|
||||
private var clearLogCommand = ""
|
||||
private var outputStream: FileOutputStream? = null
|
||||
|
||||
init {
|
||||
try {
|
||||
outputStream = FileOutputStream(createLogFile(logcatPath))
|
||||
} catch (e: FileNotFoundException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
|
||||
command = "logcat -v epoch | grep \"($pID)\""
|
||||
clearLogCommand = "logcat -c"
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
paused = true
|
||||
}
|
||||
fun stop() {
|
||||
stopped = true
|
||||
}
|
||||
|
||||
fun resume() {
|
||||
paused = false
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
Runtime.getRuntime().exec(clearLogCommand)
|
||||
}
|
||||
|
||||
suspend fun run() {
|
||||
withContext(ioDispatcher) {
|
||||
paused = false
|
||||
stopped = false
|
||||
if (outputStream == null) return@withContext
|
||||
try {
|
||||
clear()
|
||||
logcatProc = Runtime.getRuntime().exec(command)
|
||||
reader = BufferedReader(InputStreamReader(logcatProc!!.inputStream), 1024)
|
||||
var line: String? = null
|
||||
|
||||
while (!stopped) {
|
||||
if (paused) continue
|
||||
line = reader?.readLine()
|
||||
if (line.isNullOrEmpty()) continue
|
||||
outputStream?.let {
|
||||
if (it.channel.size() >= LogcatHelperInit.maxFileSize) {
|
||||
it.close()
|
||||
outputStream = createNewLogFileStream()
|
||||
}
|
||||
if (getFolderSize(logcatPath) >= LogcatHelperInit.maxFolderSize) {
|
||||
deleteOldestFile()
|
||||
}
|
||||
line.let { text ->
|
||||
val obfuscated = obfuscator(text)
|
||||
it.write((obfuscated + System.lineSeparator()).toByteArray())
|
||||
try {
|
||||
val logMessage = LogMessage.from(text)
|
||||
_bufferedLogs.tryEmit(logMessage)
|
||||
_liveLogs.tryEmit(logMessage)
|
||||
callback?.let {
|
||||
it(logMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
} finally {
|
||||
logcatProc?.destroy()
|
||||
logcatProc = null
|
||||
|
||||
try {
|
||||
reader?.close()
|
||||
outputStream?.close()
|
||||
reader = null
|
||||
outputStream = null
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFolderSize(path: String): Long {
|
||||
File(path).run {
|
||||
var size = 0L
|
||||
if (this.isDirectory && this.listFiles() != null) {
|
||||
for (file in this.listFiles()!!) {
|
||||
size += getFolderSize(file.absolutePath)
|
||||
}
|
||||
} else {
|
||||
size = this.length()
|
||||
}
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLogFile(dir: String): File {
|
||||
return File(dir, "logcat_" + System.currentTimeMillis() + ".txt")
|
||||
}
|
||||
|
||||
fun deleteOldestFile() {
|
||||
val directory = File(logcatPath)
|
||||
if (directory.isDirectory) {
|
||||
directory.listFiles()?.toMutableList()?.run {
|
||||
this.sortBy { it.lastModified() }
|
||||
this.first().delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNewLogFileStream(): FileOutputStream {
|
||||
return FileOutputStream(createLogFile(logcatPath))
|
||||
}
|
||||
|
||||
fun deleteAllFiles() {
|
||||
val directory = File(logcatPath)
|
||||
directory.listFiles()?.toMutableList()?.run {
|
||||
this.forEach { it.delete() }
|
||||
}
|
||||
outputStream = createNewLogFileStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,274 +0,0 @@
|
|||
package com.zaneschepke.logcatter
|
||||
|
||||
import android.content.Context
|
||||
import com.zaneschepke.logcatter.model.LogLevel
|
||||
import com.zaneschepke.logcatter.model.LogMessage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Paths
|
||||
import java.nio.file.StandardOpenOption
|
||||
|
||||
object LogcatUtil {
|
||||
private const val MAX_FILE_SIZE = 2097152L // 2MB
|
||||
private const val MAX_FOLDER_SIZE = 10485760L // 10MB
|
||||
private val findKeyRegex = """[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=""".toRegex()
|
||||
private val findIpv6AddressRegex =
|
||||
"""(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:)
|
||||
|{1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:)
|
||||
|{1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]
|
||||
|{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)
|
||||
|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9])
|
||||
|{0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1
|
||||
|{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))
|
||||
""".trimMargin().toRegex()
|
||||
private val findIpv4AddressRegex = """((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}""".toRegex()
|
||||
private val findTunnelNameRegex = """(?<=tunnel ).*?(?= UP| DOWN)""".toRegex()
|
||||
private const val CHORE = "Choreographer"
|
||||
|
||||
private object LogcatHelperInit {
|
||||
var maxFileSize: Long = MAX_FILE_SIZE
|
||||
var maxFolderSize: Long = MAX_FOLDER_SIZE
|
||||
var pID: Int = 0
|
||||
var publicAppDirectory = ""
|
||||
var logcatPath = ""
|
||||
}
|
||||
|
||||
fun init(maxFileSize: Long = MAX_FILE_SIZE, maxFolderSize: Long = MAX_FOLDER_SIZE, context: Context): LocalLogCollector {
|
||||
if (maxFileSize > maxFolderSize) {
|
||||
throw IllegalStateException("maxFileSize must be less than maxFolderSize")
|
||||
}
|
||||
synchronized(LogcatHelperInit) {
|
||||
LogcatHelperInit.maxFileSize = maxFileSize
|
||||
LogcatHelperInit.maxFolderSize = maxFolderSize
|
||||
LogcatHelperInit.pID = android.os.Process.myPid()
|
||||
context.getExternalFilesDir(null)?.let {
|
||||
LogcatHelperInit.publicAppDirectory = it.absolutePath
|
||||
LogcatHelperInit.logcatPath =
|
||||
LogcatHelperInit.publicAppDirectory + File.separator + "logs"
|
||||
val logDirectory = File(LogcatHelperInit.logcatPath)
|
||||
if (!logDirectory.exists()) {
|
||||
logDirectory.mkdir()
|
||||
}
|
||||
}
|
||||
return Logcat
|
||||
}
|
||||
}
|
||||
|
||||
internal object Logcat : LocalLogCollector {
|
||||
private var logcatReader: LogcatReader? = null
|
||||
|
||||
override suspend fun start(onLogMessage: ((message: LogMessage) -> Unit)?) {
|
||||
logcatReader ?: run {
|
||||
logcatReader =
|
||||
LogcatReader(
|
||||
LogcatHelperInit.pID.toString(),
|
||||
LogcatHelperInit.logcatPath,
|
||||
onLogMessage,
|
||||
)
|
||||
}
|
||||
logcatReader?.run()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
logcatReader?.stopLogs()
|
||||
logcatReader = null
|
||||
}
|
||||
|
||||
private fun mergeLogsApi26(sourceDir: String, outputFile: File) {
|
||||
val outputFilePath = Paths.get(outputFile.absolutePath)
|
||||
val logcatPath = Paths.get(sourceDir)
|
||||
|
||||
Files.list(logcatPath).use {
|
||||
it.sorted { o1, o2 ->
|
||||
Files.getLastModifiedTime(o1).compareTo(Files.getLastModifiedTime(o2))
|
||||
}
|
||||
.flatMap(Files::lines).use { lines ->
|
||||
lines.forEach { line ->
|
||||
Files.write(
|
||||
outputFilePath,
|
||||
(line + System.lineSeparator()).toByteArray(),
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getLogFile(): Result<File> {
|
||||
stop()
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val outputDir =
|
||||
File(LogcatHelperInit.publicAppDirectory + File.separator + "output")
|
||||
val outputFile = File(outputDir.absolutePath + File.separator + "logs.txt")
|
||||
|
||||
if (!outputDir.exists()) outputDir.mkdir()
|
||||
if (outputFile.exists()) outputFile.delete()
|
||||
|
||||
mergeLogsApi26(LogcatHelperInit.logcatPath, outputFile)
|
||||
Result.success(outputFile)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _bufferedLogs =
|
||||
MutableSharedFlow<LogMessage>(
|
||||
replay = 10_000,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
override val bufferedLogs: Flow<LogMessage> = _bufferedLogs.asSharedFlow()
|
||||
|
||||
private class LogcatReader(
|
||||
pID: String,
|
||||
private val logcatPath: String,
|
||||
private val callback: ((input: LogMessage) -> Unit)?,
|
||||
) {
|
||||
private var logcatProc: Process? = null
|
||||
private var reader: BufferedReader? = null
|
||||
private var mRunning = true
|
||||
private var command = ""
|
||||
private var clearLogCommand = ""
|
||||
private var outputStream: FileOutputStream? = null
|
||||
|
||||
init {
|
||||
try {
|
||||
outputStream = FileOutputStream(createLogFile(logcatPath))
|
||||
} catch (e: FileNotFoundException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
|
||||
command = "logcat -v epoch | grep \"($pID)\""
|
||||
clearLogCommand = "logcat -c"
|
||||
}
|
||||
|
||||
fun stopLogs() {
|
||||
mRunning = false
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
Runtime.getRuntime().exec(clearLogCommand)
|
||||
}
|
||||
|
||||
private fun obfuscator(log: String): String {
|
||||
return findKeyRegex.replace(log, "<crypto-key>").let { first ->
|
||||
findIpv6AddressRegex.replace(first, "<ipv6-address>").let { second ->
|
||||
findTunnelNameRegex.replace(second, "<tunnel>")
|
||||
}
|
||||
}.let { last -> findIpv4AddressRegex.replace(last, "<ipv4-address>") }
|
||||
}
|
||||
|
||||
fun run() {
|
||||
if (outputStream == null) return
|
||||
try {
|
||||
clear()
|
||||
logcatProc = Runtime.getRuntime().exec(command)
|
||||
reader = BufferedReader(InputStreamReader(logcatProc!!.inputStream), 1024)
|
||||
var line: String? = null
|
||||
|
||||
while (mRunning && run {
|
||||
line = reader!!.readLine()
|
||||
line
|
||||
} != null
|
||||
) {
|
||||
if (!mRunning) {
|
||||
break
|
||||
}
|
||||
if (line!!.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (outputStream!!.channel.size() >= LogcatHelperInit.maxFileSize) {
|
||||
outputStream!!.close()
|
||||
outputStream = FileOutputStream(createLogFile(logcatPath))
|
||||
}
|
||||
if (getFolderSize(logcatPath) >= LogcatHelperInit.maxFolderSize) {
|
||||
deleteOldestFile(logcatPath)
|
||||
}
|
||||
line?.let { text ->
|
||||
val obfuscated = obfuscator(text)
|
||||
outputStream!!.write(
|
||||
(obfuscated + System.lineSeparator()).toByteArray(),
|
||||
)
|
||||
try {
|
||||
val logMessage = LogMessage.from(obfuscated)
|
||||
when (logMessage.level) {
|
||||
LogLevel.VERBOSE -> Unit
|
||||
else -> {
|
||||
if (!logMessage.tag.contains(CHORE)) {
|
||||
_bufferedLogs.tryEmit(logMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
callback?.let {
|
||||
it(logMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
} finally {
|
||||
logcatProc?.destroy()
|
||||
logcatProc = null
|
||||
|
||||
try {
|
||||
reader?.close()
|
||||
outputStream?.close()
|
||||
reader = null
|
||||
outputStream = null
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFolderSize(path: String): Long {
|
||||
File(path).run {
|
||||
var size = 0L
|
||||
if (this.isDirectory && this.listFiles() != null) {
|
||||
for (file in this.listFiles()!!) {
|
||||
size += getFolderSize(file.absolutePath)
|
||||
}
|
||||
} else {
|
||||
size = this.length()
|
||||
}
|
||||
return size
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLogFile(dir: String): File {
|
||||
return File(dir, "logcat_" + System.currentTimeMillis() + ".txt")
|
||||
}
|
||||
|
||||
private fun deleteOldestFile(path: String) {
|
||||
val directory = File(path)
|
||||
if (directory.isDirectory) {
|
||||
directory.listFiles()?.toMutableList()?.run {
|
||||
this.sortBy { it.lastModified() }
|
||||
this.first().delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue