feat: add runtime logcat recording

This commit is contained in:
tunbmoon
2026-04-03 17:23:25 +08:00
parent 9c0ef33dcd
commit 5ec78cd176
11 changed files with 316 additions and 34 deletions
@@ -318,7 +318,15 @@ fun MainScreen(initialNavigation: String = "", viewModel: MainViewModel = viewMo
animatedComposable(Route.DARK_THEME) { DarkThemePage(onNavigateBack = onNavigateBack) }
animatedComposable(Route.LANGUAGE) { LanguagePage(onNavigateBack = onNavigateBack) }
animatedComposable(Route.DATA_MANAGEMENT) {
DataManagementPage(onNavigateBack = onNavigateBack)
DataManagementPage(
onNavigateBack = onNavigateBack,
onNavigateToLogcat = {
navController.navigate(Route.LOGCAT) { launchSingleTop = true }
}
)
}
animatedComposable(Route.LOGCAT) {
LogcatPage(onNavigateBack = onNavigateBack)
}
animatedComposable(Route.NETWORK) { NetworkPage(onNavigateBack = onNavigateBack) }
animatedComposable(Route.ABOUT) {
@@ -12,6 +12,7 @@ object Route {
const val DARK_THEME = "dark_theme"
const val LANGUAGE = "language"
const val DATA_MANAGEMENT = "data_management"
const val LOGCAT = "logcat"
const val NETWORK = "network"
const val ABOUT = "about"
const val LICENSE = "license"
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
import androidx.compose.material.icons.automirrored.outlined.Assignment
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -15,7 +16,6 @@ import androidx.compose.ui.res.stringResource
import com.antoniegil.astronia.R
import com.antoniegil.astronia.ui.component.BackButton
import com.antoniegil.astronia.ui.component.PreferenceItem
import com.antoniegil.astronia.ui.component.PreferenceSubtitle
import com.antoniegil.astronia.util.manager.DataManager
import com.antoniegil.astronia.util.manager.rememberBackupExportLauncher
import com.antoniegil.astronia.util.manager.rememberHistoryRestoreLauncher
@@ -25,7 +25,10 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DataManagementPage(onNavigateBack: () -> Unit) {
fun DataManagementPage(
onNavigateBack: () -> Unit,
onNavigateToLogcat: () -> Unit = {}
) {
val context = LocalContext.current
val resources = androidx.compose.ui.platform.LocalResources.current
val scope = rememberCoroutineScope()
@@ -56,13 +59,18 @@ fun DataManagementPage(onNavigateBack: () -> Unit) {
contentPadding = paddingValues
) {
item {
PreferenceSubtitle(text = stringResource(R.string.data_backup))
PreferenceItem(
title = stringResource(R.string.logcat),
description = stringResource(R.string.logcat_desc),
icon = Icons.AutoMirrored.Outlined.Assignment,
onClick = onNavigateToLogcat
)
}
item {
PreferenceItem(
title = stringResource(R.string.export_data),
description = stringResource(R.string.export_data_desc),
title = stringResource(R.string.export_history),
description = stringResource(R.string.export_history_desc),
icon = Icons.AutoMirrored.Outlined.DriveFileMove,
onClick = {
if (!isBackingUp) {
@@ -90,8 +98,8 @@ fun DataManagementPage(onNavigateBack: () -> Unit) {
item {
PreferenceItem(
title = stringResource(R.string.restore_data),
description = stringResource(R.string.restore_data_desc),
title = stringResource(R.string.restore_history),
description = stringResource(R.string.restore_history_desc),
icon = Icons.Outlined.Restore,
onClick = {
restoreLauncher.launch(restoreMimeType)
@@ -0,0 +1,248 @@
package com.antoniegil.astronia.ui.page.settings.data
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.automirrored.outlined.DriveFileMove
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.antoniegil.astronia.R
import com.antoniegil.astronia.ui.component.BackButton
import com.antoniegil.astronia.ui.component.ChannelCard
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import androidx.core.content.edit
import android.os.Process
data class LogcatRecord(
val fileName: String,
val timestamp: Long,
val duration: Long,
val filePath: String
)
class LogcatViewModel : ViewModel() {
var isRecording by mutableStateOf(false)
var recordStartTime by mutableLongStateOf(0L)
var logcatRecords by mutableStateOf(listOf<LogcatRecord>())
private fun getPrefs(context: Context) = context.getSharedPreferences("logcat_data", Context.MODE_PRIVATE)
fun loadData(context: Context) {
val prefs = getPrefs(context)
isRecording = prefs.getBoolean("is_recording", false)
recordStartTime = prefs.getLong("start_time", 0L)
val savedRecords = mutableListOf<LogcatRecord>()
val recordCount = prefs.getInt("record_count", 0)
for (i in 0 until recordCount) {
val fileName = prefs.getString("record_${i}_fileName", "") ?: ""
val timestamp = prefs.getLong("record_${i}_timestamp", 0L)
val duration = prefs.getLong("record_${i}_duration", 0L)
val filePath = prefs.getString("record_${i}_filePath", "") ?: ""
if (fileName.isNotEmpty() && File(filePath).exists()) {
savedRecords.add(LogcatRecord(fileName, timestamp, duration, filePath))
}
}
logcatRecords = savedRecords
}
fun saveRecords(context: Context) {
getPrefs(context).edit {
putInt("record_count", logcatRecords.size)
logcatRecords.forEachIndexed { index, record ->
putString("record_${index}_fileName", record.fileName)
putLong("record_${index}_timestamp", record.timestamp)
putLong("record_${index}_duration", record.duration)
putString("record_${index}_filePath", record.filePath)
}
}
}
fun startRecording(context: Context) {
recordStartTime = System.currentTimeMillis()
isRecording = true
getPrefs(context).edit {
putBoolean("is_recording", true)
putLong("start_time", recordStartTime)
}
}
fun stopRecording(context: Context, locale: Locale, onComplete: () -> Unit) {
if (!isRecording) return
viewModelScope.launch {
val duration = System.currentTimeMillis() - recordStartTime
val fileName = "logcat_${SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", locale).format(Date(recordStartTime))}.txt"
val logcatContent = withContext(Dispatchers.IO) {
Runtime.getRuntime().exec("logcat -d -v time").inputStream.bufferedReader().readText()
.let { if (it.length > 20 * 1024 * 1024) it.takeLast(20 * 1024 * 1024) else it }
}
val file = File(context.getExternalFilesDir(null), fileName)
withContext(Dispatchers.IO) { file.writeText(logcatContent) }
logcatRecords = listOf(LogcatRecord(fileName, recordStartTime, duration, file.absolutePath)) + logcatRecords
isRecording = false
getPrefs(context).edit {
putBoolean("is_recording", false)
putLong("start_time", 0L)
}
saveRecords(context)
onComplete()
}
}
}
@SuppressLint("LocalContextGetResourceValueCall")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LogcatPage(onNavigateBack: () -> Unit) {
val viewModel: LogcatViewModel = viewModel()
val context = LocalContext.current
val configuration = LocalConfiguration.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.loadData(context)
if (viewModel.isRecording && System.currentTimeMillis() - viewModel.recordStartTime > 30 * 60 * 1000) {
viewModel.stopRecording(context, configuration.locales[0]) {}
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.logcat)) },
navigationIcon = { BackButton(onClick = onNavigateBack) }
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable { if (!viewModel.isRecording) viewModel.startRecording(context) }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (viewModel.isRecording) stringResource(R.string.recording) else stringResource(R.string.logcat_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = { viewModel.stopRecording(context, configuration.locales[0]) {} },
enabled = viewModel.isRecording
) {
if (viewModel.isRecording) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.stop_recording),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
thickness = 1.dp,
color = MaterialTheme.colorScheme.surfaceVariant
)
if (viewModel.logcatRecords.isNotEmpty()) {
Text(
text = stringResource(R.string.history),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(viewModel.logcatRecords) { record ->
val dateFormat = SimpleDateFormat("yy-MM-dd-HH-mm-ss", configuration.locales[0])
ChannelCard(
name = dateFormat.format(Date(record.timestamp)),
url = "${record.duration / 1000}s",
onDelete = {
val deletedRecord = record
val deletedIndex = viewModel.logcatRecords.indexOf(record)
viewModel.logcatRecords = viewModel.logcatRecords.filter { it != record }
File(record.filePath).delete()
viewModel.saveRecords(context)
scope.launch {
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.item_deleted),
actionLabel = context.getString(R.string.undo),
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
viewModel.logcatRecords = viewModel.logcatRecords.toMutableList().apply {
add(deletedIndex.coerceAtMost(size), deletedRecord)
}
viewModel.saveRecords(context)
}
}
},
onClick = {
context.startActivity(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, record.fileName)
})
},
trailingIcon = {
IconButton(onClick = {
context.startActivity(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "text/plain"
putExtra(Intent.EXTRA_TITLE, record.fileName)
})
}) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.DriveFileMove,
contentDescription = stringResource(R.string.save),
tint = MaterialTheme.colorScheme.primary
)
}
}
)
}
}
}
}
}
}
@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -320,12 +320,10 @@ internal class PreferenceManagerImpl(context: Context) {
private const val PREF_SETTINGS = "astronia_settings"
private const val PREF_HISTORY = "astronia_history"
private const val KEY_AUTO_PLAY = "auto_play"
private const val KEY_REMEMBER_POSITION = "remember_position"
private const val KEY_AUTO_HIDE_CONTROLS = "auto_hide_controls"
private const val KEY_EPG_MARKERS_COUNT = "epg_markers_count"
private const val KEY_ENABLE_PIP = "enable_pip"
private const val KEY_BACKGROUND_PLAY = "background_play"
private const val KEY_HARDWARE_ACCELERATION = "hardware_acceleration"
private const val KEY_ASPECT_RATIO = "aspect_ratio"
private const val KEY_DECODER_TYPE = "decoder_type"
private const val KEY_MIRROR_FLIP = "mirror_flip"
@@ -45,7 +45,7 @@ object M3UParser {
val infoContent = currentLine.drop(M3U_INFO_MARK.length).trim()
val commaIndex = infoContent.lastIndexOf(',')
if (commaIndex != -1) {
val metadataText = infoContent.substring(0, commaIndex)
val metadataText = infoContent.take(commaIndex)
title = infoContent.substring(commaIndex + 1).trim()
metadata = parseMetadata(metadataText)
}
+10 -5
View File
@@ -126,15 +126,20 @@
<string name="follow_system">跟随系统</string>
<string name="system_settings">系统设置</string>
<string name="data_backup">数据备份</string>
<string name="export_data">导出资料</string>
<string name="export_data_desc">导出播放历史记录</string>
<string name="logcat">运行日志</string>
<string name="logcat_desc">应用运行日志</string>
<string name="logcat_subtitle">轻触以启动</string>
<string name="recording">录制中…</string>
<string name="stop_recording">停止录制</string>
<string name="no_logs_available">暂无日志</string>
<string name="export_history">导出记录</string>
<string name="export_history_desc">导出历史记录到文件进行备份</string>
<string name="export_success">导出成功</string>
<string name="export_success_at">导出成功,保存在 %s</string>
<string name="export_failed">导出失败</string>
<string name="no_history_to_export">没有历史记录可导出</string>
<string name="restore_data">导入资料</string>
<string name="restore_data_desc">从备份文件导入历史记录</string>
<string name="restore_history">导入记录</string>
<string name="restore_history_desc">从备份文件导入历史记录</string>
<plurals name="restore_success">
<item quantity="other">成功恢复 %d 条历史记录</item>
</plurals>
+11 -6
View File
@@ -126,15 +126,20 @@
<string name="follow_system">跟隨系統</string>
<string name="system_settings">系統設定</string>
<string name="data_backup">數據備份</string>
<string name="export_data">匯出資料</string>
<string name="export_data_desc">匯出播放紀錄</string>
<string name="logcat">運行日誌</string>
<string name="logcat_desc">應用程式運行日誌</string>
<string name="logcat_subtitle">輕觸以啟動</string>
<string name="recording">錄製緊…</string>
<string name="stop_recording">停止錄製</string>
<string name="no_logs_available">暫無日誌</string>
<string name="export_history">匯出記錄</string>
<string name="export_history_desc">匯出歷史記錄到檔案進行備份</string>
<string name="export_success">匯出成功</string>
<string name="export_success_at">匯出成功,儲存喺 %s</string>
<string name="export_failed">匯出失敗</string>
<string name="no_history_to_export">錄可以匯出</string>
<string name="restore_data">匯入資料</string>
<string name="restore_data_desc">從備份檔案匯入播放紀</string>
<string name="no_history_to_export">錄可以匯出</string>
<string name="restore_history">匯入記錄</string>
<string name="restore_history_desc">從備份檔案匯入歷史記</string>
<plurals name="restore_success">
<item quantity="other">成功恢復 %d 條紀錄</item>
</plurals>
+11 -6
View File
@@ -126,15 +126,20 @@
<string name="follow_system">跟隨系統</string>
<string name="system_settings">系統設定</string>
<string name="data_backup">資料備份</string>
<string name="export_data">匯出資料</string>
<string name="export_data_desc">匯出播放紀錄</string>
<string name="logcat">執行日誌</string>
<string name="logcat_desc">應用程式執行日誌</string>
<string name="logcat_subtitle">輕觸以啟動</string>
<string name="recording">錄製中…</string>
<string name="stop_recording">停止錄製</string>
<string name="no_logs_available">暫無日誌</string>
<string name="export_history">匯出記錄</string>
<string name="export_history_desc">匯出歷史記錄到檔案進行備份</string>
<string name="export_success">匯出成功</string>
<string name="export_success_at">匯出成功,儲存於 %s</string>
<string name="export_failed">匯出失敗</string>
<string name="no_history_to_export">沒有錄可供匯出</string>
<string name="restore_data">匯入資料</string>
<string name="restore_data_desc">從備份檔案匯入</string>
<string name="no_history_to_export">沒有錄可供匯出</string>
<string name="restore_history">匯入記錄</string>
<string name="restore_history_desc">從備份檔案匯入歷史記</string>
<plurals name="restore_success">
<item quantity="other">成功還原 %d 條紀錄</item>
</plurals>
+10 -5
View File
@@ -129,15 +129,20 @@
<string name="follow_system">Follow System</string>
<string name="system_settings">System Settings</string>
<string name="data_backup">Data Backup</string>
<string name="export_data">Export Data</string>
<string name="export_data_desc">Export play history</string>
<string name="logcat">Logcat</string>
<string name="logcat_desc">Application runtime logs</string>
<string name="logcat_subtitle">Tap to start</string>
<string name="recording">Recording…</string>
<string name="stop_recording">Stop Recording</string>
<string name="no_logs_available">No logs available</string>
<string name="export_history">Export History</string>
<string name="export_history_desc">Export history to file for backup</string>
<string name="export_success">Export successful</string>
<string name="export_success_at">Export successful, saved at: %s</string>
<string name="export_failed">Export failed</string>
<string name="no_history_to_export">No history to export</string>
<string name="restore_data">Import Data</string>
<string name="restore_data_desc">Import history from backup file</string>
<string name="restore_history">Import History</string>
<string name="restore_history_desc">Import history from backup file</string>
<plurals name="restore_success">
<item quantity="one">Successfully restored %d history item</item>
<item quantity="other">Successfully restored %d history items</item>