diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 62cfb99..67030c9 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -36,4 +36,4 @@ jobs: git add * git merge l10n git push - git push origin --delete l10n + git push origin --delete l10n || true diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f63b167..177fa84 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -150,8 +150,9 @@ dependencies { debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.runtime.tracing) - implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.immutable) + implementation(libs.kotlinx.serialization.json) implementation(libs.apksig) implementation(libs.axml) diff --git a/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt b/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt index 7ac2f95..56a3a3c 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ManagerApplication.kt @@ -10,6 +10,7 @@ import com.aliucord.manager.network.services.* 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.patching.PatchingScreenModel import com.aliucord.manager.ui.screens.patchopts.PatchOptionsModel import com.aliucord.manager.ui.screens.plugins.PluginsModel @@ -54,6 +55,7 @@ class ManagerApplication : Application() { factoryOf(::SettingsModel) factoryOf(::PatchOptionsModel) factoryOf(::IconOptionsModel) + factoryOf(::LogScreenModel) viewModelOf(::UpdaterViewModel) }) @@ -63,6 +65,7 @@ class ManagerApplication : Application() { singleOf(::PathManager) singleOf(::InstallerManager) singleOf(::OverlayManager) + singleOf(::InstallLogManager) singleOf(::AndroidDownloadManager) singleOf(::KtorDownloadManager) diff --git a/app/src/main/kotlin/com/aliucord/manager/manager/InstallLogManager.kt b/app/src/main/kotlin/com/aliucord/manager/manager/InstallLogManager.kt new file mode 100644 index 0000000..8e15595 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/manager/InstallLogManager.kt @@ -0,0 +1,106 @@ +package com.aliucord.manager.manager + +import android.app.Application +import android.os.Build +import android.util.Log +import androidx.compose.runtime.Immutable +import com.aliucord.manager.BuildConfig +import com.aliucord.manager.ui.screens.patchopts.PatchOptions +import com.aliucord.manager.util.IS_PROBABLY_EMULATOR +import com.aliucord.manager.util.isPlayProtectEnabled +import kotlinx.datetime.Instant +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream + +/** + * Central manager for storing all attempted installations and + * their associated logs/crashes (not including manager crashes themselves). + */ +class InstallLogManager( + private val application: Application, + private val json: Json, +) { + val logsDir = application.filesDir.resolve("install-logs").apply { mkdir() } + + /** + * Loads the install log from disk, if it exists. + */ + fun fetchInstallData(id: String): InstallLogData? { + val path = logsDir.resolve("$id.json") + if (!path.exists()) return null + + return try { + @OptIn(ExperimentalSerializationApi::class) + json.decodeFromStream(path.inputStream()) + } catch (t: Throwable) { + Log.e(BuildConfig.TAG, "Failed to open install log $id", t) + null + } + } + + /** + * Writes an install log entry to disk. + */ + suspend fun storeInstallData( + id: String, + installDate: Instant, + options: PatchOptions, + error: Throwable?, + ) { + val path = logsDir.resolve("$id.json") + + val data = InstallLogData( + id = id, + installDate = installDate, + installOptions = options, + environmentInfo = getEnvironmentInfo(), + installationLog = "", // TODO: install log + errorStacktrace = error?.let { Log.getStackTraceString(it).trimEnd() }, + ) + + path.writeText(json.encodeToString(data)) + } + + /** + * Creates a list of details about the current installation environment. + */ + suspend fun getEnvironmentInfo(): String { + @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants") + val gitChanges = if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) "(Changes present)" else "" + val soc = if (Build.VERSION.SDK_INT >= 31) (Build.SOC_MANUFACTURER + ' ' + Build.SOC_MODEL) else "Unavailable" + val playProtect = when (application.isPlayProtectEnabled()) { + null -> "Unavailable" + true -> "Enabled" + false -> "Disabled" + } + + return """ + Aliucord Manager v${BuildConfig.VERSION_NAME} + Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $gitChanges + + Android API: ${Build.VERSION.SDK_INT} + Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()} + ROM: Android ${Build.VERSION.RELEASE} (Patch ${Build.VERSION.SECURITY_PATCH}) + Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}) + Emulator: $IS_PROBABLY_EMULATOR (guess) + Play Protect: $playProtect + SOC: $soc + """.trimIndent() + } +} + +@Immutable +@Serializable +data class InstallLogData( + val id: String, + val installDate: Instant, + val installOptions: PatchOptions, + val environmentInfo: String, + val installationLog: String, + val errorStacktrace: String?, +) { + val isError: Boolean + get() = errorStacktrace != null +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreen.kt new file mode 100644 index 0000000..1bd5ff2 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreen.kt @@ -0,0 +1,132 @@ +package com.aliucord.manager.ui.screens.log + +import android.os.Parcelable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.R +import com.aliucord.manager.manager.InstallLogData +import com.aliucord.manager.ui.components.* +import com.aliucord.manager.ui.screens.log.components.SelectableTextArea +import com.aliucord.manager.ui.screens.patchopts.components.options.PatchOption +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import org.koin.core.parameter.parametersOf + +@Parcelize +class LogScreen(private val installId: String) : Screen, Parcelable { + @IgnoredOnParcel + override val key: ScreenKey + get() = "LogScreen-$installId" + + @Composable + override fun Content() { + val model = getScreenModel { parametersOf(installId) } + val navigator = LocalNavigator.currentOrThrow + + if (model.shouldCloseScreen) { + navigator.back(currentActivity = null) + } + + model.data?.let { + LogScreenContent( + data = it, + onSaveLog = model::saveLog, + ) + } + } +} + +@Composable +fun LogScreenContent( + data: InstallLogData, + onSaveLog: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.log_title)) }, + navigationIcon = { BackButton() }, + ) + }, + ) { paddingValues -> + LazyColumn( + verticalArrangement = Arrangement.spacedBy(28.dp), + modifier = Modifier + .padding(paddingValues) + .padding(vertical = 10.dp, horizontal = 22.dp) + ) { + item("INSTALL_INFO") { + PatchOption( + name = stringResource(R.string.log_section_install_info), + description = null, + ) { + SelectableTextArea( + text = """ + Installation ID: ${data.id} + Installation Date: ${data.installDate} + """.trimIndent(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + + item("ENVIRONMENT_INFO") { + PatchOption( + name = stringResource(R.string.log_section_env_info), + description = stringResource(R.string.log_section_env_info_desc), + ) { + SelectableTextArea( + text = data.environmentInfo, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + if (data.errorStacktrace != null) { + item("ERROR_STACKTRACE") { + PatchOption( + name = stringResource(R.string.log_section_error), + description = null, + ) { + SelectableTextArea( + text = data.errorStacktrace, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + // item("LOG") { + // PatchOption( + // name = stringResource(R.string.log_section_log), + // description = null, + // ) { + // SelectableTextArea( + // text = data.installationLog, + // modifier = Modifier.fillMaxWidth(), + // ) + // } + // } + + item("SAVE_BUTTON") { + MainActionButton( + text = stringResource(R.string.log_action_export), + icon = painterResource(R.drawable.ic_download), + onClick = onSaveLog, + modifier = Modifier.padding(top = 10.dp), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreenModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreenModel.kt new file mode 100644 index 0000000..cbf9595 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/LogScreenModel.kt @@ -0,0 +1,76 @@ +package com.aliucord.manager.ui.screens.log + +import android.annotation.SuppressLint +import android.app.Application +import androidx.compose.runtime.* +import cafe.adriel.voyager.core.model.ScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.aliucord.manager.R +import com.aliucord.manager.manager.InstallLogData +import com.aliucord.manager.manager.InstallLogManager +import com.aliucord.manager.util.* +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class LogScreenModel( + private val installId: String, + private val logs: InstallLogManager, + private val application: Application, +) : ScreenModel { + var shouldCloseScreen by mutableStateOf(false) + private set + + var data by mutableStateOf(null) + private set + + init { + loadLogData() + } + + /** + * Formats the log data into a file and writes it to the downloads folder. + */ + fun saveLog() = screenModelScope.launchBlock { + val data = data ?: return@launchBlock + + @SuppressLint("SimpleDateFormat") + val formattedDate = SimpleDateFormat("yyyy-MM-dd hh-mm-s a", Locale.ENGLISH) + .format(Date(data.installDate.toEpochMilliseconds())) + + val content = buildString { + appendLine("////////////////// Installation Info //////////////////") + append("Install ID: ") + appendLine(data.id) + append("Install time: ") + appendLine(formattedDate) + append("Result: ") + appendLine(if (data.isError) "Failure" else "Success") + + append("\n\n") + appendLine("////////////////// Environment Info //////////////////") + appendLine(data.environmentInfo) + + append("\n\n") + appendLine("////////////////// Error Stacktrace //////////////////") + appendLine(data.errorStacktrace ?: "None") + + append("\n\n") + appendLine("////////////////// Installation Log //////////////////") + appendLine(data.installationLog) + } + + application.saveFile("Aliucord Manager $formattedDate.log", content) + } + + private fun loadLogData() = screenModelScope.launchBlock { + val result = logs.fetchInstallData(installId) + + if (result != null) { + data = result + } else { + shouldCloseScreen = true + application.showToast(R.string.network_load_fail) + } + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/components/SelectableTextArea.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/components/SelectableTextArea.kt new file mode 100644 index 0000000..ebb299d --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/log/components/SelectableTextArea.kt @@ -0,0 +1,40 @@ +package com.aliucord.manager.ui.screens.log.components + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +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 com.aliucord.manager.ui.util.horizontalScrollbar +import com.aliucord.manager.ui.util.thenIf + +@Composable +fun SelectableTextArea( + text: String, + modifier: Modifier = Modifier.Companion, +) { + val scrollState = rememberScrollState() + val scrollable by remember { derivedStateOf { scrollState.maxValue > 0 } } + + SelectionContainer { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Companion.Monospace, + softWrap = false, + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(start = 18.dp, end = 18.dp, top = 14.dp, bottom = 14.dp) + .horizontalScroll(scrollState) + .horizontalScrollbar(scrollState) + .thenIf(scrollable) { padding(bottom = 10.dp) } + ) + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreen.kt index 571c395..843cc28 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreen.kt @@ -220,12 +220,13 @@ class PatchingScreen(private val data: PatchOptions) : Screen, Parcelable { enter = fadeIn() + slideInVertically(), exit = fadeOut() + slideOutVertically { it * -2 }, ) { - Column { + Column( + verticalArrangement = Arrangement.spacedBy(VERTICAL_PADDING / 2), + ) { HorizontalDivider( thickness = 1.dp, modifier = Modifier - .padding(vertical = 4.dp) - .padding(bottom = VERTICAL_PADDING) + .padding(bottom = VERTICAL_PADDING / 2) ) when (filteredState) { @@ -244,7 +245,13 @@ class PatchingScreen(private val data: PatchOptions) : Screen, Parcelable { MainActionButton( text = stringResource(R.string.action_retry_install), icon = painterResource(R.drawable.ic_refresh), - onClick = model::restart, + onClick = model::launchApp, + ) + + MainActionButton( + text = stringResource(R.string.action_open_error_log), + icon = painterResource(R.drawable.ic_launch), + onClick = { model.openLog(navigator) }, ) } } @@ -260,9 +267,6 @@ class PatchingScreen(private val data: PatchOptions) : Screen, Parcelable { cacheCleared = true model.clearCache() }, - modifier = Modifier - .padding(top = 14.dp) - .fillMaxWidth() ) } } @@ -303,21 +307,3 @@ private fun BannerSection( } } } - -// TODO: error log -// item(key = "ERROR_LOG") { -// SelectionContainer { -// Text( -// text = failureLog, -// style = MaterialTheme.typography.labelSmall, -// fontFamily = FontFamily.Monospace, -// softWrap = false, -// modifier = Modifier -// .padding(top = VERTICAL_PADDING) -// .clip(RoundedCornerShape(10.dp)) -// .background(MaterialTheme.colorScheme.surfaceColorAtElevation(10.dp)) -// .padding(24.dp) -// .horizontalScroll(rememberScrollState()) -// ) -// } -// } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenModel.kt index 2b0d962..6f97ab4 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenModel.kt @@ -2,39 +2,42 @@ package com.aliucord.manager.ui.screens.patching import android.annotation.SuppressLint import android.app.Application -import android.os.Build import android.util.Log import androidx.annotation.StringRes import androidx.compose.runtime.* import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope +import cafe.adriel.voyager.navigator.Navigator import com.aliucord.manager.BuildConfig import com.aliucord.manager.R -import com.aliucord.manager.manager.PathManager -import com.aliucord.manager.manager.PreferencesManager +import com.aliucord.manager.manager.* import com.aliucord.manager.patcher.KotlinPatchRunner import com.aliucord.manager.patcher.StepRunner import com.aliucord.manager.patcher.steps.StepGroup import com.aliucord.manager.patcher.steps.base.Step import com.aliucord.manager.patcher.steps.base.StepState import com.aliucord.manager.patcher.steps.install.InstallStep +import com.aliucord.manager.ui.screens.log.LogScreen import com.aliucord.manager.ui.screens.patchopts.PatchOptions import com.aliucord.manager.ui.util.toUnsafeImmutable -import com.aliucord.manager.util.* +import com.aliucord.manager.util.showToast import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.coroutines.* -import java.text.SimpleDateFormat -import java.util.Date +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import java.util.UUID import kotlin.time.Duration.Companion.seconds class PatchingScreenModel( - private val application: Application, - private val paths: PathManager, private val options: PatchOptions, + private val paths: PathManager, private val prefs: PreferencesManager, + private val application: Application, + private val installLogs: InstallLogManager, ) : StateScreenModel(PatchingScreenState.Working) { - private var startTime: Date? = null + private var installId: String? = null + private var startTime: Instant? = null private var runnerJob: Job? = null private var stepRunner: StepRunner? = null @@ -73,26 +76,6 @@ class PatchingScreenModel( } } - fun copyDebugToClipboard() { - val content = (state.value as? PatchingScreenState.Failed)?.failureLog - ?: return - - application.copyToClipboard(content) - application.showToast(R.string.action_copied) - } - - fun saveFailureLog() { - val startTime = startTime ?: return - val failureLog = (state.value as? PatchingScreenState.Failed)?.failureLog - ?: return - - @SuppressLint("SimpleDateFormat") - val formattedDate = SimpleDateFormat("yyyy-MM-dd hh-mm-s a").format(startTime) - val fileName = "Aliucord Manager $formattedDate.log" - - application.saveFile(fileName, failureLog) - } - fun clearCache() { screenModelScope.launch { paths.clearCache() } application.showToast(R.string.action_cleared_cache) @@ -102,10 +85,14 @@ class PatchingScreenModel( runnerJob?.cancel("Manual cancellation") steps = null - startTime = Date() + @SuppressLint("MemberExtensionConflict") + installId = UUID.randomUUID().toString() + startTime = Clock.System.now() mutableState.value = PatchingScreenState.Working runnerJob = screenModelScope.launch { + Log.i(BuildConfig.TAG, "Starting installation with environment:\n" + installLogs.getEnvironmentInfo()) + try { startPatchRunner() } catch (_: CancellationException) { @@ -113,11 +100,22 @@ class PatchingScreenModel( mutableState.value = PatchingScreenState.CloseScreen } catch (error: Throwable) { Log.e(BuildConfig.TAG, "Failed to orchestrate patch runner", error) - mutableState.value = PatchingScreenState.Failed(failureLog = getFailureInfo(error)) + mutableState.value = PatchingScreenState.Failed(installId = installId!!) + installLogs.storeInstallData( + id = installId!!, + installDate = startTime!!, + options = options, + error = error, + ) } } } + fun openLog(navigator: Navigator) { + val id = installId ?: return + navigator.push(LogScreen(installId = id)) + } + private suspend fun startPatchRunner() { val runner = KotlinPatchRunner(options) .also { stepRunner = it } @@ -141,41 +139,28 @@ class PatchingScreenModel( // At this point, the installation has successfully completed else { mutableState.value = PatchingScreenState.Success + installLogs.storeInstallData( + id = installId!!, + installDate = startTime!!, + options = options, + error = null, + ) } } else -> { Log.e(BuildConfig.TAG, "Failed to perform installation process", error) - mutableState.value = PatchingScreenState.Failed(failureLog = getFailureInfo(error)) + mutableState.value = PatchingScreenState.Failed(installId = installId!!) + installLogs.storeInstallData( + id = installId!!, + installDate = startTime!!, + options = options, + error = error, + ) } } } - private suspend fun getFailureInfo(stacktrace: Throwable): String { - val gitChanges = if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) "(Changes present)" else "" - val soc = if (Build.VERSION.SDK_INT >= 31) (Build.SOC_MANUFACTURER + ' ' + Build.SOC_MODEL) else "Unavailable" - val playProtect = when (application.isPlayProtectEnabled()) { - null -> "Unavailable" - true -> "Enabled" - false -> "Disabled" - } - - val header = """ - Aliucord Manager v${BuildConfig.VERSION_NAME} - Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH} $gitChanges - - Android API: ${Build.VERSION.SDK_INT} - Emulator: $IS_PROBABLY_EMULATOR (guess) - ROM: Android ${Build.VERSION.RELEASE} (Patch ${Build.VERSION.SECURITY_PATCH}) - Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()} - Device: ${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE}) - Play Protect: $playProtect - SOC: $soc - """.trimIndent() - - return header + "\n\n" + Log.getStackTraceString(stacktrace).trimEnd() - } - private companion object { /** * Random fun facts to show on the installation screen. diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenState.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenState.kt index 2f5907c..92e99a4 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenState.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patching/PatchingScreenState.kt @@ -5,7 +5,7 @@ import com.aliucord.manager.ui.screens.patching.PatchingScreenState.CloseScreen sealed interface PatchingScreenState { data object Working : PatchingScreenState data object Success : PatchingScreenState - data class Failed(val failureLog: String) : PatchingScreenState + data class Failed(val installId: String) : PatchingScreenState data object CloseScreen : PatchingScreenState } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/components/options/PatchOption.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/components/options/PatchOption.kt index 44ccc94..6e1668f 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/components/options/PatchOption.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screens/patchopts/components/options/PatchOption.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp +// TODO: rename this to a generic titled component @Composable fun PatchOption( name: String, @@ -17,7 +18,7 @@ fun PatchOption( content: @Composable ColumnScope.() -> Unit, ) { Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), horizontalAlignment = Alignment.Start, modifier = modifier, ) { diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/util/Scrollbars.kt b/app/src/main/kotlin/com/aliucord/manager/ui/util/Scrollbars.kt new file mode 100644 index 0000000..b2dcc6f --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/ui/util/Scrollbars.kt @@ -0,0 +1,39 @@ +package com.aliucord.manager.ui.util + +import androidx.compose.foundation.ScrollState +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.max + +// Based on https://gist.github.com/XFY9326/2067efcc3c5899557cc6a334d76a92c8 + +fun Modifier.horizontalScrollbar( + scrollState: ScrollState, + scrollBarHeight: Dp = 4.dp, + minScrollBarWidth: Dp = 5.dp, + scrollBarColor: Color = Color.White.copy(alpha = .4f), + cornerRadius: Dp = 2.dp, +): Modifier = composed { + drawWithContent { + drawContent() + + if (scrollState.maxValue <= 0) return@drawWithContent + + val visibleWidth: Float = this.size.width - scrollState.maxValue + val scrollBarWidth: Float = max(visibleWidth * (visibleWidth / this.size.width), minScrollBarWidth.toPx()) + val scrollPercent: Float = scrollState.value.toFloat() / scrollState.maxValue + val scrollBarOffsetX: Float = scrollState.value + (visibleWidth - scrollBarWidth) * scrollPercent + + drawRoundRect( + color = scrollBarColor, + topLeft = Offset(scrollBarOffsetX, this.size.height - scrollBarHeight.toPx()), + size = Size(scrollBarWidth, scrollBarHeight.toPx()), + cornerRadius = CornerRadius(cornerRadius.toPx()) + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e904b38..3487bc2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,7 +10,8 @@ Cancel Retry - Retry Installation + Retry installation + Open error log Apply Confirm Dismiss @@ -184,6 +185,14 @@ Uncropped preview of the selected image No image selected + Log + Installation Info + Environment Info + Information about your device that may be crucial for debugging. + Error Stacktrace + Installation Log + Export log + Active installation Progress notifications sent during a minimized installation Aliucord installation ready! diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7d291aa..d6cd233 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ compose-pipette = "1.0.0-beta01" #diff = "4.12" koin = "4.0.2" kotlin = "2.1.10" +kotlinx-datetime = "0.6.2" kotlinx-immutable = "0.3.8" kotlinx-serialization = "1.8.0" ktor = "3.0.3" @@ -63,8 +64,9 @@ koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } # KotlinX -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-immutable" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } # Ktor ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }