feat: logs list screen

This commit is contained in:
rushiiMachine 2025-05-18 10:23:19 -07:00
parent 3107a68986
commit 269fd531b3
No known key found for this signature in database
GPG key ID: DCBE5952BB3B6420
17 changed files with 496 additions and 30 deletions

View file

@ -11,6 +11,7 @@ import com.aliucord.manager.ui.screens.about.AboutModel
import com.aliucord.manager.ui.screens.home.HomeModel
import com.aliucord.manager.ui.screens.iconopts.IconOptionsModel
import com.aliucord.manager.ui.screens.log.LogScreenModel
import com.aliucord.manager.ui.screens.logs.LogsListScreenModel
import com.aliucord.manager.ui.screens.patching.PatchingScreenModel
import com.aliucord.manager.ui.screens.patchopts.PatchOptionsModel
import com.aliucord.manager.ui.screens.plugins.PluginsModel
@ -56,6 +57,7 @@ class ManagerApplication : Application() {
factoryOf(::PatchOptionsModel)
factoryOf(::IconOptionsModel)
factoryOf(::LogScreenModel)
factoryOf(::LogsListScreenModel)
viewModelOf(::UpdaterViewModel)
})

View file

@ -30,6 +30,19 @@ class InstallLogManager(
) {
val logsDir = application.filesDir.resolve("install-logs").apply { mkdir() }
/**
* Lists all the install data entries that exist on disk, sorted decreasing by
* the file creation date.
* @return List of installation ids, most recent installation first.
*/
fun fetchInstallDataEntries(): List<String> {
val files = logsDir.listFiles { it.extension == "json" } ?: emptyArray()
return files
.sortedByDescending { it.lastModified() }
.map { it.nameWithoutExtension }
}
/**
* Loads the install log from disk, if it exists.
*/
@ -46,6 +59,11 @@ class InstallLogManager(
}
}
fun deleteAllEntries() {
logsDir.deleteRecursively()
logsDir.mkdir()
}
/**
* Writes an install log entry to disk.
*/

View file

@ -27,4 +27,3 @@ private fun PluginsScreenNonePreview() {
)
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.navigator.LocalNavigator
import com.aliucord.manager.R
import com.aliucord.manager.ui.screens.about.AboutScreen
import com.aliucord.manager.ui.screens.logs.LogsListScreen
import com.aliucord.manager.ui.screens.settings.SettingsScreen
@Composable
@ -19,14 +20,21 @@ fun HomeAppBar() {
IconButton(onClick = { navigator?.push(AboutScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_info),
contentDescription = stringResource(R.string.navigation_about)
contentDescription = stringResource(R.string.navigation_about),
)
}
IconButton(onClick = { navigator?.push(LogsListScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_receipt),
contentDescription = stringResource(R.string.navigation_about),
)
}
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings)
contentDescription = stringResource(R.string.navigation_settings),
)
}
}

View file

@ -18,7 +18,7 @@ import com.aliucord.manager.manager.InstallLogData
import com.aliucord.manager.ui.components.Label
import com.aliucord.manager.ui.components.back
import com.aliucord.manager.ui.screens.log.components.LogAppBar
import com.aliucord.manager.ui.screens.log.components.SelectableTextArea
import com.aliucord.manager.ui.screens.log.components.LogTextArea
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.core.parameter.parametersOf
@ -73,7 +73,7 @@ fun LogScreenContent(
name = stringResource(R.string.log_section_install_info),
description = null,
) {
SelectableTextArea(
LogTextArea(
text = """
Installation ID: ${data.id}
Installation Date: ${data.installDate}
@ -88,7 +88,7 @@ fun LogScreenContent(
name = stringResource(R.string.log_section_env_info),
description = null,
) {
SelectableTextArea(
LogTextArea(
text = data.environmentInfo,
modifier = Modifier.fillMaxWidth(),
)
@ -101,7 +101,7 @@ fun LogScreenContent(
name = stringResource(R.string.log_section_error),
description = null,
) {
SelectableTextArea(
LogTextArea(
text = data.errorStacktrace,
modifier = Modifier.fillMaxWidth(),
)
@ -114,7 +114,7 @@ fun LogScreenContent(
name = stringResource(R.string.log_section_log),
description = null,
) {
SelectableTextArea(
LogTextArea(
text = data.installationLog,
modifier = Modifier.fillMaxWidth(),
)

View file

@ -11,11 +11,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aliucord.manager.ui.util.horizontalScrollbar
import com.aliucord.manager.ui.util.thenIf
@Composable
fun SelectableTextArea(
fun LogTextArea(
text: String,
modifier: Modifier = Modifier.Companion,
) {
@ -26,11 +27,12 @@ fun SelectableTextArea(
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
lineHeight = 18.sp,
fontFamily = FontFamily.Companion.Monospace,
softWrap = false,
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 14.dp)
.horizontalScroll(scrollState)
.horizontalScrollbar(scrollState)

View file

@ -0,0 +1,99 @@
package com.aliucord.manager.ui.screens.logs
import android.os.Parcelable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.screen.ScreenKey
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.aliucord.manager.ui.screens.log.LogScreen
import com.aliucord.manager.ui.screens.logs.components.*
import com.aliucord.manager.ui.screens.logs.components.dialogs.DeleteLogsDialog
import com.aliucord.manager.ui.util.paddings.PaddingValuesSides
import com.aliucord.manager.ui.util.paddings.exclude
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Parcelize
class LogsListScreen : Screen, Parcelable {
@IgnoredOnParcel
override val key: ScreenKey
get() = "LogsScreen"
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val model = getScreenModel<LogsListScreenModel>()
LogsScreenContent(
logs = model.logEntries,
onOpenLog = { navigator.push(LogScreen(installId = it)) },
onDeleteLogs = model::deleteLogs, // TODO: show warning dialog first
)
}
}
@Composable
fun LogsScreenContent(
logs: SnapshotStateList<LogEntry>,
onOpenLog: (id: String) -> Unit,
onDeleteLogs: () -> Unit,
) {
var showWipeConfirmDialog by remember { mutableStateOf(false) }
if (showWipeConfirmDialog) {
DeleteLogsDialog(
onConfirm = {
showWipeConfirmDialog = false
onDeleteLogs()
},
onDismiss = {
showWipeConfirmDialog = false
},
)
}
Scaffold(
topBar = {
LogsListAppBar(
onDeleteLogs = { showWipeConfirmDialog = true },
)
},
) { paddingValues ->
LazyColumn(
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = paddingValues.exclude(PaddingValuesSides.Horizontal + PaddingValuesSides.Top),
modifier = Modifier
.padding(paddingValues.exclude(PaddingValuesSides.Bottom))
.padding(vertical = 12.dp, horizontal = 22.dp)
) {
if (logs.isEmpty()) {
item(key = "EMPTY") {
LogsNone(
modifier = Modifier.fillParentMaxSize(),
)
}
}
items(
items = logs,
contentType = { "LOG" },
key = { it.id },
) { data ->
LogEntryCard(
data = data,
onClick = remember(onOpenLog, data.id) { { onOpenLog(data.id) } },
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}

View file

@ -0,0 +1,67 @@
package com.aliucord.manager.ui.screens.logs
import android.app.Application
import android.text.format.DateUtils
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.mutableStateListOf
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.aliucord.manager.R
import com.aliucord.manager.manager.InstallLogManager
import com.aliucord.manager.util.launchBlock
import com.aliucord.manager.util.showToast
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
class LogsListScreenModel(
private val logsManager: InstallLogManager,
private val application: Application,
) : ScreenModel {
/**
* All the loaded log entries sorted descending by creation date.
*/
val logEntries = mutableStateListOf<LogEntry>()
init {
loadLogsList()
}
fun deleteLogs() = screenModelScope.launchBlock {
logsManager.deleteAllEntries()
logEntries.clear()
application.showToast(R.string.logs_status_delete_success)
}
private fun loadLogsList() = screenModelScope.launchBlock {
for (installId in logsManager.fetchInstallDataEntries()) {
val data = logsManager.fetchInstallData(id = installId)
?: continue
logEntries += LogEntry(
id = data.id,
isError = data.isError,
installDate = DateUtils.getRelativeDateTimeString(
/* c = */ application,
/* time = */ data.installDate.toEpochMilliseconds(),
/* minResolution = */ DateUtils.SECOND_IN_MILLIS,
/* transitionResolution = */ DateUtils.WEEK_IN_MILLIS,
/* flags = */ DateUtils.FORMAT_ABBREV_ALL,
).toString(),
durationSecs = data.installDuration.inWholeMilliseconds / 1000f,
stacktracePreview = data.errorStacktrace
?.splitToSequence('\n')
?.take(3)
?.toImmutableList(),
)
}
}
}
@Immutable
data class LogEntry(
val id: String,
val isError: Boolean,
val installDate: String,
val durationSecs: Float,
val stacktracePreview: ImmutableList<String>?,
)

View file

@ -0,0 +1,122 @@
package com.aliucord.manager.ui.screens.logs.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aliucord.manager.R
import com.aliucord.manager.patcher.steps.base.StepState
import com.aliucord.manager.ui.screens.logs.LogEntry
import com.aliucord.manager.ui.screens.patching.components.StepStateIcon
import com.aliucord.manager.ui.screens.patching.components.TimeElapsed
@Composable
fun LogEntryCard(
data: LogEntry,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val errorColor = MaterialTheme.colorScheme.error
ElevatedCard(
shape = RectangleShape,
modifier = modifier
.clickable(onClick = onClick)
.clip(RoundedCornerShape(topStart = 6.dp, 12.0.dp, bottomStart = 6.dp, bottomEnd = 12.0.dp))
.drawWithCache {
val color = when (data.isError) {
true -> errorColor
false -> Color(0xFF59B463)
}
onDrawWithContent {
drawContent()
drawRect(
color = color,
alpha = .8f,
topLeft = Offset.Zero,
size = Size(4.dp.toPx(), size.height),
)
}
}
) {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 18.dp, horizontal = 22.dp),
) {
StepStateIcon(
state = if (data.isError) StepState.Error else StepState.Success,
size = 24.dp,
)
Column {
Text(
text = when (data.isError) {
true -> stringResource(R.string.status_failed)
false -> stringResource(R.string.status_success)
},
)
Text(
text = data.installDate,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.alpha(.6f),
)
}
Spacer(Modifier.weight(1f, fill = true))
TimeElapsed(
seconds = data.durationSecs,
modifier = Modifier.alpha(.9f),
)
}
if (data.stacktracePreview != null) {
Column(
modifier = Modifier
.padding(start = 26.dp, end = 20.dp, bottom = 18.dp)
// https://stackoverflow.com/a/76270310/13964629
.graphicsLayer(
alpha = .95f,
compositingStrategy = CompositingStrategy.Offscreen,
)
.drawWithContent {
val colors = listOf(Color.Black, Color.Black, Color.Transparent)
drawContent()
drawRect(
brush = Brush.verticalGradient(colors),
blendMode = BlendMode.DstIn,
)
}
) {
// The stacktrace is separated into multiple Text elements because ellipsis is not supported per-line
for (line in data.stacktracePreview) key(line) {
Text(
text = line,
softWrap = false,
overflow = TextOverflow.Ellipsis,
lineHeight = 18.sp,
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Companion.Monospace,
)
}
}
}
}
}

View file

@ -0,0 +1,37 @@
package com.aliucord.manager.ui.screens.logs.components
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.navigator.LocalNavigator
import com.aliucord.manager.R
import com.aliucord.manager.ui.components.BackButton
import com.aliucord.manager.ui.screens.settings.SettingsScreen
@Composable
fun LogsListAppBar(
onDeleteLogs: () -> Unit,
) {
TopAppBar(
navigationIcon = { BackButton() },
title = { Text(stringResource(R.string.logs_title)) },
actions = {
val navigator = LocalNavigator.current
IconButton(onClick = onDeleteLogs) {
Icon(
painter = painterResource(R.drawable.ic_delete_forever),
contentDescription = stringResource(R.string.logs_action_delete_all)
)
}
IconButton(onClick = { navigator?.push(SettingsScreen()) }) {
Icon(
painter = painterResource(R.drawable.ic_settings),
contentDescription = stringResource(R.string.navigation_settings)
)
}
}
)
}

View file

@ -0,0 +1,33 @@
package com.aliucord.manager.ui.screens.logs.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.aliucord.manager.R
@Composable
fun LogsNone(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Column(
modifier = Modifier.align(Alignment.Center),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(R.drawable.ic_reciept_off),
contentDescription = null,
)
Text(
text = stringResource(R.string.logs_none),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.alpha(.8f),
)
}
}
}

View file

@ -0,0 +1,49 @@
package com.aliucord.manager.ui.screens.logs.components.dialogs
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.aliucord.manager.R
@Composable
fun DeleteLogsDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
painter = painterResource(R.drawable.ic_delete_forever),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.logs_wipe_title)) },
text = {
Text(
text = stringResource(R.string.logs_wipe_desc),
textAlign = TextAlign.Center,
)
},
confirmButton = {
Button(
onClick = onConfirm,
) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Text(stringResource(R.string.action_cancel))
}
}
)
}

View file

@ -134,40 +134,40 @@ class PatchingScreenModel(
delay(400)
// Execute all the steps and catch any errors
when (val error = runner.executeAll()) {
val error = when (val error = runner.executeAll()) {
null -> {
// If install step is marked skipped then the installation was manually aborted
// and if so, immediately close install screen
if (runner.getStep<InstallStep>().state == StepState.Skipped) {
mutableState.value = PatchingScreenState.CloseScreen
Error("Installation was aborted or cancelled")
.apply { stackTrace = emptyArray() }
}
// At this point, the installation has successfully completed
else {
mutableState.value = PatchingScreenState.Success
installLogs.storeInstallData(
id = installId!!,
installDate = startTime!!,
installDuration = runner.steps.sumOf { it.getDuration() }.milliseconds,
options = options,
log = runner.getLog(),
error = null,
)
null
}
}
else -> {
Log.e(BuildConfig.TAG, "Failed to perform installation process", error)
mutableState.value = PatchingScreenState.Failed(installId = installId!!)
installLogs.storeInstallData(
id = installId!!,
installDate = startTime!!,
installDuration = runner.steps.sumOf { it.getDuration() }.milliseconds,
options = options,
log = runner.getLog(),
error = error,
)
error
}
}
installLogs.storeInstallData(
id = installId!!,
installDate = startTime!!,
installDuration = runner.steps.sumOf { it.getDuration() }.milliseconds,
options = options,
log = runner.getLog(),
error = error,
)
}
private companion object {

View file

@ -4,6 +4,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import com.aliucord.manager.R
@Composable
@ -24,13 +25,16 @@ fun UninstallPluginDialog(
Text(stringResource(R.string.plugins_delete_plugin, pluginName))
},
text = {
Text(stringResource(R.string.plugins_delete_plugin_body))
Text(
text = stringResource(R.string.plugins_delete_plugin_body),
textAlign = TextAlign.Center,
)
},
confirmButton = {
Button(
onClick = onConfirm,
) {
Text(stringResource(android.R.string.ok))
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
@ -41,7 +45,7 @@ fun UninstallPluginDialog(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Text(stringResource(android.R.string.cancel))
Text(stringResource(R.string.action_cancel))
}
}
)

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#e8eaed"
android:pathData="M120,880v-800l60,60 60,-60 60,60 60,-60 60,60 60,-60 60,60 60,-60 60,60 60,-60 60,60 60,-60v800l-60,-60 -60,60 -60,-60 -60,60 -60,-60 -60,60 -60,-60 -60,60 -60,-60 -60,60 -60,-60 -60,60ZM240,680h480v-80L240,600v80ZM240,520h480v-80L240,440v80ZM240,360h480v-80L240,280v80Z" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#e8eaed"
android:pathData="m819,932 l-59,-59q-10,3 -19.5,5T720,880L240,880q-50,0 -85,-35t-35,-85v-120h120v-287L27,140l57,-57L876,875l-57,57ZM840,727 L760,647v-447L313,200l-73,-73v-47l60,60 60,-60 60,60 60,-60 60,60 60,-60 60,60 60,-60 60,60 60,-60v647ZM320,640h207L320,433v207ZM475,360 L395,280h205v80L475,360ZM595,480 L515,400h85v80h-5ZM680,480q-17,0 -28.5,-11.5T640,440q0,-17 11.5,-28.5T680,400q17,0 28.5,11.5T720,440q0,17 -11.5,28.5T680,480ZM680,360q-17,0 -28.5,-11.5T640,320q0,-17 11.5,-28.5T680,280q17,0 28.5,11.5T720,320q0,17 -11.5,28.5T680,360Z" />
</vector>

View file

@ -70,6 +70,7 @@
<string name="navigation_back">Back</string>
<string name="navigation_about">About</string>
<string name="navigation_logs">Open logs</string>
<string name="navigation_settings">Settings</string>
<string name="contributors_lead">Lead</string>
@ -194,6 +195,13 @@
<string name="log_action_export">Export log</string>
<string name="log_action_share">Share log</string>
<string name="logs_title">Logs</string>
<string name="logs_none">No logs present!</string>
<string name="logs_action_delete_all">Delete all logs</string>
<string name="logs_status_delete_success">Successfully deleted all logs</string>
<string name="logs_wipe_title">Clear Logs</string>
<string name="logs_wipe_desc">Are you sure you want to permanently delete all the logs?</string>
<string name="notif_group_install_title">Active installation</string>
<string name="notif_group_install_desc">Progress notifications sent during a minimized installation</string>
<string name="notif_install_ready_title">Aliucord installation ready!</string>