parent
1306fdc8b2
commit
fea31437cd
|
@ -72,12 +72,14 @@ android {
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
signingConfig = signingConfigs.getByName(Constants.RELEASE)
|
signingConfig = signingConfigs.getByName(Constants.RELEASE)
|
||||||
|
resValue("string", "provider", "\"${Constants.APP_NAME}.provider\"")
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
versionNameSuffix = "-debug"
|
versionNameSuffix = "-debug"
|
||||||
resValue("string", "app_name", "WG Tunnel - Debug")
|
resValue("string", "app_name", "WG Tunnel - Debug")
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
|
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
create(Constants.PRERELEASE) {
|
create(Constants.PRERELEASE) {
|
||||||
|
@ -85,6 +87,7 @@ android {
|
||||||
applicationIdSuffix = ".prerelease"
|
applicationIdSuffix = ".prerelease"
|
||||||
versionNameSuffix = "-pre"
|
versionNameSuffix = "-pre"
|
||||||
resValue("string", "app_name", "WG Tunnel - Pre")
|
resValue("string", "app_name", "WG Tunnel - Pre")
|
||||||
|
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
create(Constants.NIGHTLY) {
|
create(Constants.NIGHTLY) {
|
||||||
|
@ -92,6 +95,7 @@ android {
|
||||||
applicationIdSuffix = ".nightly"
|
applicationIdSuffix = ".nightly"
|
||||||
versionNameSuffix = "-nightly"
|
versionNameSuffix = "-nightly"
|
||||||
resValue("string", "app_name", "WG Tunnel - Nightly")
|
resValue("string", "app_name", "WG Tunnel - Nightly")
|
||||||
|
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationVariants.all {
|
applicationVariants.all {
|
||||||
|
|
|
@ -98,6 +98,16 @@
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
android:theme="@android:style/Theme.NoDisplay" />
|
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
|
<service
|
||||||
android:name=".service.tile.TunnelControlTile"
|
android:name=".service.tile.TunnelControlTile"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|
|
@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import android.os.StrictMode.ThreadPolicy
|
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.ApplicationScope
|
||||||
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
|
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
|
||||||
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
|
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
|
||||||
|
@ -23,7 +23,7 @@ class WireGuardAutoTunnel : Application() {
|
||||||
lateinit var applicationScope: CoroutineScope
|
lateinit var applicationScope: CoroutineScope
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var localLogCollector: LocalLogCollector
|
lateinit var logReader: LogReader
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@IoDispatcher
|
@IoDispatcher
|
||||||
|
@ -47,7 +47,7 @@ class WireGuardAutoTunnel : Application() {
|
||||||
}
|
}
|
||||||
if (!isRunningOnTv()) {
|
if (!isRunningOnTv()) {
|
||||||
applicationScope.launch(ioDispatcher) {
|
applicationScope.launch(ioDispatcher) {
|
||||||
localLogCollector.start()
|
logReader.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package com.zaneschepke.wireguardautotunnel.module
|
package com.zaneschepke.wireguardautotunnel.module
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.zaneschepke.logcatter.LocalLogCollector
|
import com.zaneschepke.logcatter.LogReader
|
||||||
import com.zaneschepke.logcatter.LogcatUtil
|
import com.zaneschepke.logcatter.LogcatCollector
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
@ -24,7 +24,7 @@ class AppModule {
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@Provides
|
@Provides
|
||||||
fun provideLogCollect(@ApplicationContext context: Context): LocalLogCollector {
|
fun provideLogCollect(@ApplicationContext context: Context): LogReader {
|
||||||
return LogcatUtil.init(context = context)
|
return LogcatCollector.init(context = context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
@ -21,8 +20,12 @@ import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
@ -35,9 +38,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.zaneschepke.logcatter.model.LogMessage
|
import com.zaneschepke.logcatter.model.LogMessage
|
||||||
import com.zaneschepke.wireguardautotunnel.R
|
|
||||||
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
|
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -45,35 +46,52 @@ fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
|
||||||
val logs = viewModel.logs
|
val logs = viewModel.logs
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
val lazyColumnListState = rememberLazyListState()
|
val lazyColumnListState = rememberLazyListState()
|
||||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
var isAutoScrolling by remember { mutableStateOf(true) }
|
||||||
val scope = rememberCoroutineScope()
|
var lastScrollPosition by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
LaunchedEffect(logs.size) {
|
LaunchedEffect(isAutoScrolling) {
|
||||||
scope.launch {
|
if (isAutoScrolling) {
|
||||||
lazyColumnListState.animateScrollToItem(logs.size)
|
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(
|
Scaffold(
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
viewModel.shareLogs(context)
|
||||||
viewModel.saveLogsToFile().onSuccess {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.logs_saved),
|
|
||||||
Toast.LENGTH_SHORT,
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
) {
|
) {
|
||||||
val icon = Icons.Filled.Save
|
val icon = Icons.Filled.Share
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = icon.name,
|
contentDescription = icon.name,
|
||||||
|
|
|
@ -1,19 +1,25 @@
|
||||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.zaneschepke.logcatter.LocalLogCollector
|
import com.zaneschepke.logcatter.LogReader
|
||||||
import com.zaneschepke.logcatter.model.LogMessage
|
import com.zaneschepke.logcatter.model.LogMessage
|
||||||
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
|
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
|
||||||
import com.zaneschepke.wireguardautotunnel.module.MainDispatcher
|
import com.zaneschepke.wireguardautotunnel.module.MainDispatcher
|
||||||
import com.zaneschepke.wireguardautotunnel.util.Constants
|
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.chunked
|
||||||
|
import com.zaneschepke.wireguardautotunnel.util.extensions.launchShareFile
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import com.zaneschepke.wireguardautotunnel.R
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.io.File
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -22,8 +28,7 @@ import javax.inject.Inject
|
||||||
class LogsViewModel
|
class LogsViewModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val localLogCollector: LocalLogCollector,
|
private val localLogCollector: LogReader,
|
||||||
private val fileUtils: FileUtils,
|
|
||||||
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
@ -44,13 +49,19 @@ constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveLogsToFile(): Result<Unit> {
|
fun shareLogs(context: Context): Job = viewModelScope.launch(ioDispatcher) {
|
||||||
val file =
|
runCatching {
|
||||||
localLogCollector.getLogFile().getOrElse {
|
val sharePath = File(context.filesDir, "external_files")
|
||||||
return Result.failure(it)
|
if (sharePath.exists()) sharePath.delete()
|
||||||
}
|
sharePath.mkdir()
|
||||||
val fileContent = fileUtils.readBytesFromFile(file)
|
val file = File("${sharePath.path + "/" + Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.zip")
|
||||||
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
|
if (file.exists()) file.delete()
|
||||||
return fileUtils.saveByteArrayToDownloads(fileContent, fileName)
|
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) {
|
fun Context.showToast(resId: Int) {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
this,
|
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 com.zaneschepke.logcatter.model.LogMessage
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
interface LocalLogCollector {
|
interface LogReader {
|
||||||
suspend fun start(onLogMessage: ((message: LogMessage) -> Unit)? = null)
|
suspend fun start(onLogMessage: ((message: LogMessage) -> Unit)? = null)
|
||||||
|
|
||||||
fun stop()
|
fun stop()
|
||||||
|
fun zipLogFiles(path: String)
|
||||||
suspend fun getLogFile(): Result<File>
|
suspend fun deleteAndClearLogs()
|
||||||
|
|
||||||
val bufferedLogs: Flow<LogMessage>
|
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