diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt index 0bfc352d..8b8b1b89 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -96,7 +96,7 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider { applicationScope.launch { withContext(mainDispatcher) { - if (appDataRepository.appState.isLocalLogsEnabled() && !isRunningOnTv()) logReader.initialize() + if (appDataRepository.appState.isLocalLogsEnabled() && !isRunningOnTv()) logReader.start() } if (!appDataRepository.settings.get().isKernelEnabled) { tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptyList()) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt index f788a3bd..f36dee3b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt @@ -18,8 +18,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,7 +34,6 @@ import com.zaneschepke.wireguardautotunnel.ui.Route import com.zaneschepke.wireguardautotunnel.ui.common.button.ScaledSwitch import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton -import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog import com.zaneschepke.wireguardautotunnel.ui.common.label.GroupLabel import com.zaneschepke.wireguardautotunnel.ui.common.label.VersionLabel import com.zaneschepke.wireguardautotunnel.ui.common.navigation.LocalNavController @@ -51,20 +48,6 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.scaledWidth fun SupportScreen(appUiState: AppUiState, appViewModel: AppViewModel) { val context = LocalContext.current val navController = LocalNavController.current - - var showDialog by remember { mutableStateOf(false) } - - if (showDialog) { - InfoDialog(onAttest = { - showDialog = false - appViewModel.onToggleLocalLogging() - }, onDismiss = { - showDialog = false - }, title = { - Text(stringResource(R.string.configuration_change)) - }, body = { Text(stringResource(R.string.requires_app_relaunch)) }, confirmText = { Text(stringResource(R.string.yes)) }) - } - Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(24.dp.scaledHeight(), Alignment.Top), @@ -115,12 +98,12 @@ fun SupportScreen(appUiState: AppUiState, appViewModel: AppViewModel) { ScaledSwitch( appUiState.generalState.isLocalLogsEnabled, onClick = { - showDialog = true + appViewModel.onToggleLocalLogging() }, ) }, onClick = { - showDialog = true + appViewModel.onToggleLocalLogging() }, ), ) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt index 4a350412..023a5a1c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/viewmodel/AppViewModel.kt @@ -145,17 +145,12 @@ constructor( with(uiState.value.generalState) { val toggledOn = !isLocalLogsEnabled appDataRepository.appState.setLocalLogsEnabled(toggledOn) - if (!toggledOn) onLoggerStop() - _configurationChange.update { - true + if (!toggledOn) { + logReader.stop() } } } - private suspend fun onLoggerStop() { - logReader.deleteAndClearLogs() - } - fun onToggleAlwaysOnVPN() = viewModelScope.launch { with(uiState.value.appSettings) { appDataRepository.settings.save( diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/LogFileManager.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/LogFileManager.kt new file mode 100644 index 00000000..95aeb2e7 --- /dev/null +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/LogFileManager.kt @@ -0,0 +1,90 @@ +package com.zaneschepke.logcatter + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class LogFileManager( + private val logDir: String, + private val maxFileSize: Long, + private val maxFolderSize: Long, +) { + private var currentFile: File? = null + private var outputStream: FileOutputStream? = null + + val ioDispatcher = Dispatchers.IO + + init { + rotateIfNeeded() + } + + suspend fun writeLog(line: String) = withContext(ioDispatcher) { + rotateIfNeeded() + outputStream?.write((line + System.lineSeparator()).toByteArray()) + outputStream?.flush() + } + + suspend fun zipLogs(zipFilePath: String) = withContext(ioDispatcher) { + outputStream?.close() + val sourceDir = File(logDir) + if (!sourceDir.exists() || !sourceDir.isDirectory) return@withContext + val outputZipFile = File(zipFilePath) + ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos -> + sourceDir.walkTopDown().forEach { file -> + val zipFileName = file.absolutePath.removePrefix(sourceDir.absolutePath).removePrefix("/") + val entry = ZipEntry("$zipFileName${if (file.isDirectory) "/" else ""}") + zos.putNextEntry(entry) + if (file.isFile) { + file.inputStream().use { it.copyTo(zos) } + } + } + } + rotateIfNeeded() + } + + suspend fun deleteAllLogs() = withContext(ioDispatcher) { + outputStream?.close() + File(logDir).listFiles()?.forEach { it.delete() } + rotateIfNeeded() + } + + fun close() { + outputStream?.close() + outputStream = null + currentFile = null + } + + private fun rotateIfNeeded() { + val folderSize = getFolderSize(File(logDir)) + if (folderSize >= maxFolderSize) { + deleteOldestFile() + } + val fileSize = currentFile?.length() ?: 0L + if (currentFile == null || fileSize >= maxFileSize) { + outputStream?.close() + currentFile = File(logDir, "logcat_${System.currentTimeMillis()}.txt") + outputStream = FileOutputStream(currentFile!!) + } + } + + private fun getFolderSize(dir: File): Long { + var size = 0L + if (dir.isDirectory && dir.listFiles() != null) { + dir.listFiles()!!.forEach { file -> + size += if (file.isDirectory) getFolderSize(file) else file.length() + } + } + return size + } + + private fun deleteOldestFile() { + File(logDir).listFiles() + ?.toList() + ?.minByOrNull { it.lastModified() } + ?.delete() + } +} diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/LogReader.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/LogReader.kt index 50224df0..8e908aff 100644 --- a/logcatter/src/main/java/com/zaneschepke/logcatter/LogReader.kt +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/LogReader.kt @@ -4,7 +4,8 @@ import com.zaneschepke.logcatter.model.LogMessage import kotlinx.coroutines.flow.Flow interface LogReader { - fun initialize(onLogMessage: ((message: LogMessage) -> Unit)? = null) + fun start() + fun stop() fun zipLogFiles(path: String) suspend fun deleteAndClearLogs() val bufferedLogs: Flow diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt new file mode 100644 index 00000000..8fe666ae --- /dev/null +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatManager.kt @@ -0,0 +1,92 @@ +package com.zaneschepke.logcatter + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.zaneschepke.logcatter.model.LogMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +class LogcatManager( + pid: Int, + logDir: String, + maxFileSize: Long, + maxFolderSize: Long, +) : LogReader, DefaultLifecycleObserver { + private val logScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val fileManager = LogFileManager(logDir, maxFileSize, maxFolderSize) + private val logcatReader = LogcatStreamReader(pid, fileManager) + private var logJob: Job? = null + private var isStarted = false + + private val _bufferedLogs = MutableSharedFlow( + replay = 10_000, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val _liveLogs = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val bufferedLogs: Flow = _bufferedLogs.asSharedFlow() + override val liveLogs: Flow = _liveLogs.asSharedFlow() + + override fun onCreate(owner: LifecycleOwner) { + // for auto start + // start() + } + + override fun onDestroy(owner: LifecycleOwner) { + stop() + logScope.cancel() + } + + override fun start() { + if (isStarted) return + stop() + logJob = logScope.launch { + logcatReader.readLogs().collect { logMessage -> + _bufferedLogs.emit(logMessage) + _liveLogs.emit(logMessage) + } + } + isStarted = true + } + + override fun stop() { + if (!isStarted) return + logJob?.cancel() + logcatReader.stop() + fileManager.close() + isStarted = false + } + + override fun zipLogFiles(path: String) { + logScope.launch { + val wasStarted = isStarted + stop() + fileManager.zipLogs(path) + if (wasStarted) { + logcatReader.clearLogs() + start() + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + override suspend fun deleteAndClearLogs() { + val wasStarted = isStarted + stop() + _bufferedLogs.resetReplayCache() + fileManager.deleteAllLogs() + if (wasStarted) start() + } +} diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatReader.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatReader.kt index b369e553..31b8fb7b 100644 --- a/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatReader.kt +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatReader.kt @@ -1,250 +1,32 @@ package com.zaneschepke.logcatter -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.lifecycleScope -import com.zaneschepke.logcatter.model.LogMessage -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.io.BufferedOutputStream -import java.io.BufferedReader import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.InputStreamReader -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream object LogcatReader { - 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 = "" - } + private lateinit var logcatManager: LogcatManager + private var isInitialized = false fun init(maxFileSize: Long = MAX_FILE_SIZE, maxFolderSize: Long = MAX_FOLDER_SIZE, storageDir: String): 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() - LogcatHelperInit.publicAppDirectory = storageDir - LogcatHelperInit.logcatPath = LogcatHelperInit.publicAppDirectory + File.separator + "logs" - val logDirectory = File(LogcatHelperInit.logcatPath) - if (!logDirectory.exists()) { - logDirectory.mkdir() - } - return Logcat - } - } - - internal object Logcat : LogReader { - - private lateinit var logcatReader: LogcatReader - - override fun initialize(onLogMessage: ((message: LogMessage) -> Unit)?) { - logcatReader = LogcatReader(LogcatHelperInit.pID.toString(), LogcatHelperInit.logcatPath, onLogMessage) - ProcessLifecycleOwner.get().lifecycle.addObserver(logcatReader) - } - - private fun obfuscator(log: String): String { - return findKeyRegex.replace(log, "").let { first -> - findIpv6AddressRegex.replace(first, "").let { second -> - findTunnelNameRegex.replace(second, "") - } - }.let { last -> findIpv4AddressRegex.replace(last, "") } - } - - override fun zipLogFiles(path: String) { - logcatReader.cancel() - zipAll(path) - logcatReader.onCreate(ProcessLifecycleOwner.get()) - } - - 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.cancel() - _bufferedLogs.resetReplayCache() - logcatReader.deleteAllFiles() - logcatReader.onCreate(ProcessLifecycleOwner.get()) - } - } - - private val _bufferedLogs = MutableSharedFlow( - replay = 10_000, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private val _liveLogs = MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - - override val bufferedLogs: Flow = _bufferedLogs.asSharedFlow() - - override val liveLogs: Flow = _liveLogs.asSharedFlow() - - private class LogcatReader( - pID: String, - private val logcatPath: String, - private val callback: ((input: LogMessage) -> Unit)?, - ) : DefaultLifecycleObserver { - private var logcatProc: Process? = null - private var reader: BufferedReader? = null - - private val command = "logcat -v epoch | grep \"($pID)\"" - private val clearLogCommand = "logcat -c" - private var logJob: Job? = null - private var outputStream: FileOutputStream? = null - - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) - logJob = owner.lifecycleScope.launch(ioDispatcher) { - try { - if (outputStream == null) outputStream = createNewLogFileStream() - clear() - logcatProc = Runtime.getRuntime().exec(command) - reader = BufferedReader(InputStreamReader(logcatProc!!.inputStream), 1024) - var line: String? = null - while (true) { - 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 sanitized = obfuscator(text) - it.write((sanitized + 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 { - reset() - } - } - logJob?.invokeOnCompletion { - reset() - } - } - - override fun onDestroy(owner: LifecycleOwner) { - super.onDestroy(owner) - logJob?.cancel() - } - - fun cancel() { - logJob?.cancel() - } - - private fun reset() { - logcatProc?.destroy() - logcatProc = null - reader?.close() - outputStream?.close() - reader = null - outputStream = null - } - - fun clear() { - Runtime.getRuntime().exec(clearLogCommand) - } - - 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() } - } - } + synchronized(this) { + if (isInitialized) return logcatManager + val logDir = "$storageDir${File.separator}logs" + File(logDir).mkdirs() + logcatManager = LogcatManager( + pid = android.os.Process.myPid(), + logDir = logDir, + maxFileSize = maxFileSize, + maxFolderSize = maxFolderSize, + ) + ProcessLifecycleOwner.get().lifecycle.addObserver(logcatManager) + isInitialized = true + return logcatManager } } } diff --git a/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatStreamReader.kt b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatStreamReader.kt new file mode 100644 index 00000000..8297b835 --- /dev/null +++ b/logcatter/src/main/java/com/zaneschepke/logcatter/LogcatStreamReader.kt @@ -0,0 +1,63 @@ +package com.zaneschepke.logcatter + +import com.zaneschepke.logcatter.model.LogMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader + +class LogcatStreamReader( + private val pid: Int, + private val fileManager: LogFileManager, +) { + private val bufferSize = 1024 + private var process: Process? = null + private var reader: BufferedReader? = null + private val command = "logcat -v epoch | grep \"($pid)\"" + private val clearCommand = "logcat -c" + + private val ioDispatcher = Dispatchers.IO + + fun readLogs(): Flow = flow { + try { + clearLogs() + process = Runtime.getRuntime().exec(command) + reader = BufferedReader(InputStreamReader(process!!.inputStream), bufferSize) + reader!!.lineSequence().forEach { line -> + if (line.isNotEmpty()) { + fileManager.writeLog(line) + emit(LogMessage.from(line)) + } + } + } catch (e: IOException) { + // do nothing + } finally { + stop() + } + }.flowOn(ioDispatcher) + + fun start() { + if (process == null) { + try { + process = Runtime.getRuntime().exec(command) + reader = BufferedReader(InputStreamReader(process!!.inputStream), bufferSize) + } catch (e: IOException) { + // do nothing + } + } + } + + fun stop() { + process?.destroy() + reader?.close() + process = null + reader = null + } + + fun clearLogs() { + Runtime.getRuntime().exec(clearCommand) + } +}