为MIUIX主题添加了专属的关于界面

This commit is contained in:
HapeLee
2026-05-17 01:20:23 +08:00
parent ede560f3da
commit f0bbd4697f
35 changed files with 2439 additions and 662 deletions
+2 -5
View File
@@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="top.yukonga.miuix.kmp.blur" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
@@ -212,11 +214,6 @@
android:name=".ui.book.search.SearchActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="standard" />
<!-- 关于界面 -->
<activity
android:name=".ui.about.AboutActivity"
android:enableOnBackInvokedCallback="true"
android:launchMode="singleTask" />
<activity
android:name=".ui.about.CrashReportActivity"
android:enableOnBackInvokedCallback="true"
@@ -58,6 +58,7 @@ import io.legado.app.help.coil.CoverFetcher
import io.legado.app.help.coil.CoverInterceptor
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.okHttpClientManga
import io.legado.app.ui.about.AboutViewModel
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel
@@ -189,6 +190,7 @@ val appModule = module {
viewModelOf(::MyViewModel)
viewModelOf(::BookshelfViewModel)
viewModelOf(::MainViewModel)
viewModelOf(::AboutViewModel)
viewModelOf(::GroupViewModel)
viewModelOf(::ReplaceRuleViewModel)
viewModelOf(::AllBookmarkViewModel)
@@ -1,197 +0,0 @@
package io.legado.app.ui.about
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.compose.setContent
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import io.legado.app.R
import io.legado.app.base.BaseComposeActivity
import io.legado.app.constant.AppLog
import io.legado.app.help.CrashHandler
import io.legado.app.help.config.AppConfig
import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.update.AppUpdate
import io.legado.app.ui.widget.dialog.TextDialog
import io.legado.app.ui.widget.dialog.WaitDialog
import io.legado.app.utils.FileDoc
import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFileIfNotExist
import io.legado.app.utils.createFolderIfNotExist
import io.legado.app.utils.delete
import io.legado.app.utils.externalCache
import io.legado.app.utils.find
import io.legado.app.utils.list
import io.legado.app.utils.openInputStream
import io.legado.app.utils.openOutputStream
import io.legado.app.utils.openUrl
import io.legado.app.utils.share
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.delay
import splitties.init.appCtx
import java.io.File
class AboutActivity : BaseComposeActivity() {
@Composable
override fun Content() {
AboutScreen(
onBack = { finish() },
onCheckUpdate = { checkUpdate() },
onOpenUrl = { openUrl(it) },
onShowMdFile = { title, file -> showMdFile(title, file) },
onSaveLog = { saveLog() },
onCreateHeapDump = { createHeapDump() },
onShowCrashLogs = { showDialogFragment<CrashLogsDialog>() },
)
}
private val waitDialog by lazy {
WaitDialog(this).setText(R.string.checking_update)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.about, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_share_it -> {
share(
getString(R.string.app_share_description),
getString(R.string.app_name)
)
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun showMdFile(title: String, fileName: String) {
val mdText = String(this.assets.open(fileName).readBytes())
showDialogFragment(TextDialog(title, mdText, TextDialog.Mode.MD))
}
private fun checkUpdate() {
waitDialog.show()
AppUpdate.gitHubUpdate?.run {
check(lifecycleScope)
.onSuccess {
showDialogFragment(UpdateDialog(it, UpdateDialog.Mode.UPDATE))
}.onError {
appCtx.toastOnUi("${getString(R.string.check_update)}\n${it.localizedMessage}")
}.onFinally {
waitDialog.dismiss()
}
}
}
@Suppress("SameParameterValue")
private fun openUrl(@StringRes addressID: Int) {
this.openUrl(getString(addressID))
}
private fun saveLog() {
Coroutine.async {
val backupPath = AppConfig.backupPath ?: let {
toastOnUi("未设置备份目录")
return@async
}
if (!AppConfig.recordLog) {
toastOnUi("未开启日志记录,请去其他设置里打开记录日志")
delay(3000)
}
val doc = FileDoc.fromUri(backupPath.toUri(), true)
copyLogs(doc)
copyHeapDump(doc)
toastOnUi("已保存至备份目录")
}.onError {
AppLog.put("保存日志出错\n${it.localizedMessage}", it, true)
}
}
private fun createHeapDump() {
Coroutine.async {
val backupPath = AppConfig.backupPath ?: let {
toastOnUi("未设置备份目录")
return@async
}
if (!AppConfig.recordHeapDump) {
toastOnUi("未开启堆转储记录,请去其他设置里打开记录堆转储")
delay(3000)
}
toastOnUi("开始创建堆转储")
System.gc()
CrashHandler.doHeapDump(true)
val doc = FileDoc.fromUri(backupPath.toUri(), true)
if (!copyHeapDump(doc)) {
toastOnUi("未找到堆转储文件")
} else {
toastOnUi("已保存至备份目录")
}
}.onError {
AppLog.put("保存堆转储失败\n${it.localizedMessage}", it)
}
}
private fun copyLogs(doc: FileDoc) {
val cacheDir = externalCache
val logFiles = File(cacheDir, "logs")
val crashFiles = File(cacheDir, "crash")
val logcatFile = File(cacheDir, "logcat.txt")
dumpLogcat(logcatFile)
val zipFile = File(cacheDir, "logs.zip")
ZipUtils.zipFiles(arrayListOf(logFiles, crashFiles, logcatFile), zipFile)
doc.find("logs.zip")?.delete()
zipFile.inputStream().use { input ->
doc.createFileIfNotExist("logs.zip").openOutputStream().getOrNull()
?.use {
input.copyTo(it)
}
}
zipFile.delete()
}
private fun copyHeapDump(doc: FileDoc): Boolean {
val heapFile = FileDoc.fromFile(File(externalCache, "heapDump")).list()
?.firstOrNull() ?: return false
doc.find("heapDump")?.delete()
val heapDumpDoc = doc.createFolderIfNotExist("heapDump")
heapFile.openInputStream().getOrNull()?.use { input ->
heapDumpDoc.createFileIfNotExist(heapFile.name).openOutputStream().getOrNull()
?.use {
input.copyTo(it)
}
}
return true
}
private fun dumpLogcat(file: File) {
try {
val process = Runtime.getRuntime().exec("logcat -d")
file.outputStream().use {
process.inputStream.copyTo(it)
}
} catch (e: Exception) {
AppLog.put("保存Logcat失败\n$e", e)
}
}
}
@@ -0,0 +1,48 @@
package io.legado.app.ui.about
import androidx.compose.runtime.Stable
import io.legado.app.help.update.AppUpdate
import io.legado.app.utils.FileDoc
@Stable
data class AboutUiState(
val sheet: AboutSheet = AboutSheet.None,
val dialog: AboutDialog? = null,
val crashLogFiles: List<FileDoc> = emptyList(),
)
sealed interface AboutSheet {
data object None : AboutSheet
data class Markdown(val title: String, val content: String) : AboutSheet
data object CrashLogs : AboutSheet
data class Update(
val updateInfo: AppUpdate.UpdateInfo,
val mode: UpdateMode = UpdateMode.UPDATE,
) : AboutSheet
}
enum class UpdateMode { UPDATE, VIEW_LOG }
sealed interface AboutDialog {
data object CheckingUpdate : AboutDialog
}
sealed interface AboutIntent {
data object DismissSheet : AboutIntent
data object DismissDialog : AboutIntent
data object CheckUpdate : AboutIntent
data class ShowMdFile(val title: String, val fileName: String) : AboutIntent
data object ShowCrashLogs : AboutIntent
data object SaveLog : AboutIntent
data object CreateHeapDump : AboutIntent
data class OpenUrl(val url: String) : AboutIntent
data object ClearCrashLogs : AboutIntent
data class ReadCrashFile(val fileDoc: FileDoc) : AboutIntent
data object StartDownload : AboutIntent
}
sealed interface AboutEffect {
data class OpenUrl(val url: String) : AboutEffect
data class ShowToast(val message: String) : AboutEffect
data class StartDownload(val url: String, val fileName: String) : AboutEffect
}
@@ -2,6 +2,7 @@ package io.legado.app.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,11 +17,15 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -29,30 +34,56 @@ import androidx.compose.ui.unit.sp
import io.legado.app.R
import io.legado.app.constant.AppConst.appInfo
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.settingItem.SettingItem
import io.legado.app.ui.widget.components.SettingItemWithDivider
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.log.CrashLogSheet
import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
import io.legado.app.ui.widget.components.settingItem.SettingItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AboutScreen(
versionName: String = appInfo.versionName,
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit = {},
onCheckUpdate: () -> Unit = {},
onOpenUrl: (String) -> Unit = {},
onShowMdFile: (String, String) -> Unit = { _, _ -> },
onSaveLog: () -> Unit = {},
onCreateHeapDump: () -> Unit = {},
onShowCrashLogs: () -> Unit = {}
versionName: String = appInfo.versionName,
) {
LocalContext.current
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
MiuixAboutScreen(
state = state,
onIntent = onIntent,
onBack = onBack,
versionName = versionName,
)
} else {
MaterialAboutScreen(
state = state,
onIntent = onIntent,
onBack = onBack,
versionName = versionName,
)
}
AboutOverlays(state = state, onIntent = onIntent)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun MaterialAboutScreen(
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit,
versionName: String,
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
AppScaffold(
@@ -88,14 +119,16 @@ fun AboutScreen(
fontWeight = FontWeight.Bold,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally))
.wrapContentWidth(Alignment.CenterHorizontally)
)
TextCard(
text = versionName,
cornerRadius = 8.dp,
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(vertical = 4.dp))
.padding(vertical = 4.dp)
)
AppText(
text = stringResource(R.string.about_description),
style = LegadoTheme.typography.bodyLarge,
@@ -103,25 +136,25 @@ fun AboutScreen(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(bottom = 4.dp))
Row (modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center){
FilledTonalIconButton ( onClick = { onOpenUrl("https://example.com") } ) {
.padding(bottom = 4.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }) {
Icon(
painter = painterResource(R.drawable.ic_web_outline),
contentDescription = stringResource(R.string.back)
)
}
FilledTonalIconButton (onClick = { onOpenUrl("https://github.com/HapeLee/legado-with-MD3") }) {
FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }) {
Icon(
painter = painterResource(R.drawable.ic_github),
contentDescription = stringResource(R.string.back)
)
}
FilledTonalIconButton(onClick = onCheckUpdate) {
FilledTonalIconButton(onClick = { onIntent(AboutIntent.CheckUpdate) }) {
Icon(
painter = painterResource(R.drawable.ic_import),
contentDescription = stringResource(R.string.back)
@@ -132,58 +165,113 @@ fun AboutScreen(
SplicedColumnGroup(
modifier = Modifier.padding(horizontal = 16.dp),
title = ""
){
) {
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.contributors),
onClick = {
onOpenUrl("https://github.com/gedoor/legado/graphs/contributors")
}
onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.privacy_policy),
onClick = {
onShowMdFile("隐私政策", "privacyPolicy.md")
onIntent(
AboutIntent.ShowMdFile(
"隐私政策",
"privacyPolicy.md"
)
)
}
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.license),
onClick = {
onShowMdFile("许可证", "LICENSE.md")
}
onClick = { onIntent(AboutIntent.ShowMdFile("许可证", "LICENSE.md")) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.disclaimer),
onClick = {
onShowMdFile("免责声明", "disclaimer.md")
}
onClick = { onIntent(AboutIntent.ShowMdFile("免责声明", "disclaimer.md")) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.crash_log),
onClick = onShowCrashLogs
onClick = { onIntent(AboutIntent.ShowCrashLogs) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.save_log),
onClick = onSaveLog
onClick = { onIntent(AboutIntent.SaveLog) }
)
}
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.create_heap_dump),
onClick = onCreateHeapDump
onClick = { onIntent(AboutIntent.CreateHeapDump) }
)
}
}
}
}
}
@Composable
private fun AboutOverlays(
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
) {
val currentSheet = state.sheet
var renderedSheet by remember { mutableStateOf<AboutSheet>(AboutSheet.None) }
LaunchedEffect(currentSheet) {
if (currentSheet is AboutSheet.None) {
delay(300)
renderedSheet = AboutSheet.None
} else {
renderedSheet = currentSheet
}
}
when (val sheet = renderedSheet) {
is AboutSheet.None -> Unit
is AboutSheet.Markdown -> MarkdownSheet(
show = currentSheet is AboutSheet.Markdown,
title = sheet.title,
content = sheet.content,
onDismissRequest = { onIntent(AboutIntent.DismissSheet) },
)
is AboutSheet.CrashLogs -> CrashLogSheet(
show = currentSheet is AboutSheet.CrashLogs,
logFiles = state.crashLogFiles,
onDismissRequest = { onIntent(AboutIntent.DismissSheet) },
onReadFile = { onIntent(AboutIntent.ReadCrashFile(it)) },
onClear = { onIntent(AboutIntent.ClearCrashLogs) },
)
is AboutSheet.Update -> UpdateSheet(
show = currentSheet is AboutSheet.Update,
updateInfo = sheet.updateInfo,
mode = sheet.mode,
onDismissRequest = { onIntent(AboutIntent.DismissSheet) },
onStartDownload = { onIntent(AboutIntent.StartDownload) },
)
}
AppAlertDialog(
show = state.dialog is AboutDialog.CheckingUpdate,
onDismissRequest = {},
content = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
AppCircularProgressIndicator()
}
}
)
}
@@ -0,0 +1,193 @@
package io.legado.app.ui.about
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import io.legado.app.BuildConfig
import io.legado.app.R
import io.legado.app.constant.AppConst.appInfo
import io.legado.app.help.config.AppConfig
import io.legado.app.help.update.AppUpdate
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.button.PrimaryButton
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MarkdownSheet(
show: Boolean,
title: String,
content: String,
onDismissRequest: () -> Unit,
) {
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = title,
) {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Markdown(
content = content,
colors = markdownColor(
text = LegadoTheme.colorScheme.onBackground,
codeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
inlineCodeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
dividerColor = LegadoTheme.colorScheme.outlineVariant,
tableBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.02f),
),
typography = markdownTypography(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.heightIn(min = 16.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateSheet(
show: Boolean,
updateInfo: AppUpdate.UpdateInfo,
mode: UpdateMode,
onDismissRequest: () -> Unit,
onStartDownload: () -> Unit,
) {
val title = when (mode) {
UpdateMode.UPDATE -> stringResource(R.string.check_update)
UpdateMode.VIEW_LOG -> "已经更新至"
}
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
title = title,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
if (mode == UpdateMode.UPDATE) {
Row(
modifier = Modifier.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = "当前版本",
style = LegadoTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = BuildConfig.VERSION_NAME,
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
Row(
modifier = Modifier.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = "新版本",
style = LegadoTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = updateInfo.tagName,
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Row(
modifier = Modifier.padding(bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = "ABI",
style = LegadoTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown",
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
Row(
modifier = Modifier.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppText(
text = "渠道",
style = LegadoTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = AppConfig.updateToVariant ?: appInfo.appVariant.toString(),
style = LegadoTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
} else {
AppText(
text = BuildConfig.VERSION_NAME,
style = LegadoTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp),
)
}
val updateLog = updateInfo.updateLog
if (updateLog.isNotBlank()) {
Markdown(
content = updateLog,
colors = markdownColor(
text = LegadoTheme.colorScheme.onBackground,
codeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
inlineCodeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
dividerColor = LegadoTheme.colorScheme.outlineVariant,
tableBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.02f),
),
typography = markdownTypography(),
modifier = Modifier.fillMaxWidth(),
)
}
if (mode == UpdateMode.UPDATE) {
Spacer(modifier = Modifier.height(16.dp))
PrimaryButton(
onClick = onStartDownload,
text = "更新",
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -0,0 +1,243 @@
package io.legado.app.ui.about
import android.app.Application
import android.net.Uri
import androidx.core.net.toUri
import androidx.lifecycle.viewModelScope
import io.legado.app.R
import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog
import io.legado.app.help.CrashHandler
import io.legado.app.help.config.AppConfig
import io.legado.app.help.update.AppUpdate
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils
import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFileIfNotExist
import io.legado.app.utils.createFolderIfNotExist
import io.legado.app.utils.delete
import io.legado.app.utils.externalCache
import io.legado.app.utils.find
import io.legado.app.utils.getFile
import io.legado.app.utils.list
import io.legado.app.utils.openInputStream
import io.legado.app.utils.openOutputStream
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File
import java.io.FileFilter
class AboutViewModel(application: Application) : BaseViewModel(application) {
private val _uiState = MutableStateFlow(AboutUiState())
val uiState: StateFlow<AboutUiState> = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<AboutEffect>(extraBufferCapacity = 8)
val effects = _effects.asSharedFlow()
fun onIntent(intent: AboutIntent) {
when (intent) {
is AboutIntent.DismissSheet -> _uiState.update { it.copy(sheet = AboutSheet.None) }
is AboutIntent.DismissDialog -> _uiState.update { it.copy(dialog = null) }
is AboutIntent.CheckUpdate -> checkUpdate()
is AboutIntent.ShowMdFile -> showMdFile(intent.title, intent.fileName)
is AboutIntent.ShowCrashLogs -> showCrashLogs()
is AboutIntent.SaveLog -> saveLog()
is AboutIntent.CreateHeapDump -> createHeapDump()
is AboutIntent.OpenUrl -> _effects.tryEmit(AboutEffect.OpenUrl(intent.url))
is AboutIntent.ClearCrashLogs -> clearCrashLogs()
is AboutIntent.ReadCrashFile -> readCrashFile(intent.fileDoc)
is AboutIntent.StartDownload -> startDownload()
}
}
private fun checkUpdate() {
_uiState.update { it.copy(dialog = AboutDialog.CheckingUpdate) }
AppUpdate.gitHubUpdate?.run {
check(viewModelScope)
.onSuccess { updateInfo ->
_uiState.update {
it.copy(
dialog = null,
sheet = AboutSheet.Update(updateInfo)
)
}
}.onError { e ->
_uiState.update { it.copy(dialog = null) }
_effects.tryEmit(
AboutEffect.ShowToast("${context.getString(R.string.check_update)}\n${e.localizedMessage}")
)
}.onFinally {
_uiState.update { it.copy(dialog = null) }
}
}
}
private fun showMdFile(title: String, fileName: String) {
execute {
String(context.assets.open(fileName).readBytes())
}.onSuccess { content ->
_uiState.update { it.copy(sheet = AboutSheet.Markdown(title, content)) }
}
}
private fun showCrashLogs() {
execute {
loadCrashLogFiles()
}.onSuccess { files ->
_uiState.update {
it.copy(
crashLogFiles = files,
sheet = AboutSheet.CrashLogs
)
}
}
}
private fun readCrashFile(fileDoc: FileDoc) {
execute {
String(fileDoc.readBytes())
}.onSuccess { content ->
_uiState.update { it.copy(sheet = AboutSheet.Markdown(fileDoc.name, content)) }
}.onError {
_effects.tryEmit(AboutEffect.ShowToast(it.localizedMessage ?: ""))
}
}
private fun clearCrashLogs() {
execute {
context.externalCacheDir
?.getFile("crash")
?.let { FileUtils.delete(it, false) }
val backupPath = AppConfig.backupPath
if (!backupPath.isNullOrEmpty()) {
val uri = Uri.parse(backupPath)
FileDoc.fromUri(uri, true)
.find("crash")
?.delete()
}
}.onError {
_effects.tryEmit(AboutEffect.ShowToast(it.localizedMessage ?: ""))
}.onFinally {
execute {
loadCrashLogFiles()
}.onSuccess { files ->
_uiState.update { it.copy(crashLogFiles = files) }
}
}
}
private fun loadCrashLogFiles(): List<FileDoc> {
val list = arrayListOf<FileDoc>()
context.externalCacheDir
?.getFile("crash")
?.listFiles(FileFilter { it.isFile })
?.forEach { list.add(FileDoc.fromFile(it)) }
val backupPath = AppConfig.backupPath
if (!backupPath.isNullOrEmpty()) {
val uri = Uri.parse(backupPath)
FileDoc.fromUri(uri, true)
.find("crash")
?.list { !it.isDir }
?.let { list.addAll(it) }
}
return list.sortedByDescending { it.name }.distinctBy { it.name }
}
private fun saveLog() {
execute {
val backupPath = AppConfig.backupPath ?: run {
_effects.tryEmit(AboutEffect.ShowToast("未设置备份目录"))
return@execute
}
if (!AppConfig.recordLog) {
_effects.tryEmit(AboutEffect.ShowToast("未开启日志记录,请去其他设置里打开记录日志"))
delay(3000)
}
val doc = FileDoc.fromUri(backupPath.toUri(), true)
copyLogs(doc)
copyHeapDump(doc)
_effects.tryEmit(AboutEffect.ShowToast("已保存至备份目录"))
}.onError {
AppLog.put("保存日志出错\n${it.localizedMessage}", it, true)
}
}
private fun createHeapDump() {
execute {
val backupPath = AppConfig.backupPath ?: run {
_effects.tryEmit(AboutEffect.ShowToast("未设置备份目录"))
return@execute
}
if (!AppConfig.recordHeapDump) {
_effects.tryEmit(AboutEffect.ShowToast("未开启堆转储记录,请去其他设置里打开记录堆转储"))
delay(3000)
}
_effects.tryEmit(AboutEffect.ShowToast("开始创建堆转储"))
System.gc()
CrashHandler.doHeapDump(true)
val doc = FileDoc.fromUri(backupPath.toUri(), true)
if (!copyHeapDump(doc)) {
_effects.tryEmit(AboutEffect.ShowToast("未找到堆转储文件"))
} else {
_effects.tryEmit(AboutEffect.ShowToast("已保存至备份目录"))
}
}.onError {
AppLog.put("保存堆转储失败\n${it.localizedMessage}", it)
}
}
private fun startDownload() {
val sheet = _uiState.value.sheet
if (sheet is AboutSheet.Update) {
val info = sheet.updateInfo
if (info.downloadUrl.isBlank() || info.fileName.isBlank()) {
_effects.tryEmit(AboutEffect.ShowToast("下载信息不完整"))
} else {
_effects.tryEmit(AboutEffect.StartDownload(info.downloadUrl, info.fileName))
}
}
}
private fun copyLogs(doc: FileDoc) {
val cacheDir = context.externalCache
val logFiles = File(cacheDir, "logs")
val crashFiles = File(cacheDir, "crash")
val logcatFile = File(cacheDir, "logcat.txt")
dumpLogcat(logcatFile)
val zipFile = File(cacheDir, "logs.zip")
ZipUtils.zipFiles(arrayListOf(logFiles, crashFiles, logcatFile), zipFile)
doc.find("logs.zip")?.delete()
zipFile.inputStream().use { input ->
doc.createFileIfNotExist("logs.zip").openOutputStream().getOrNull()
?.use { input.copyTo(it) }
}
zipFile.delete()
}
private fun copyHeapDump(doc: FileDoc): Boolean {
val heapFile = FileDoc.fromFile(File(context.externalCache, "heapDump")).list()
?.firstOrNull() ?: return false
doc.find("heapDump")?.delete()
val heapDumpDoc = doc.createFolderIfNotExist("heapDump")
heapFile.openInputStream().getOrNull()?.use { input ->
heapDumpDoc.createFileIfNotExist(heapFile.name).openOutputStream().getOrNull()
?.use { input.copyTo(it) }
}
return true
}
private fun dumpLogcat(file: File) {
try {
val process = Runtime.getRuntime().exec("logcat -d")
file.outputStream().use { process.inputStream.copyTo(it) }
} catch (e: Exception) {
AppLog.put("保存Logcat失败\n$e", e)
}
}
}
@@ -0,0 +1,515 @@
@file:OptIn(ExperimentalScrollBarApi::class)
package io.legado.app.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.legado.app.R
import io.legado.app.constant.AppConst.appInfo
import io.legado.app.ui.util.BlurredBar
import io.legado.app.ui.util.LocalAppState
import io.legado.app.ui.util.LocalIsWideScreen
import io.legado.app.ui.util.LocalNavigator
import io.legado.app.ui.util.MiuixNavigator
import io.legado.app.ui.util.pageContentPadding
import io.legado.app.ui.util.pageScrollModifiers
import io.legado.app.ui.util.rememberBlurBackdrop
import io.legado.app.ui.util.shouldShowSplitPane
import io.legado.app.ui.widget.components.effect.BgEffectBackground
import io.legado.app.ui.widget.components.effect.ColorBlendToken
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.VerticalScrollBar
import top.yukonga.miuix.kmp.basic.rememberScrollBarAdapter
import top.yukonga.miuix.kmp.blur.BlendColorEntry
import top.yukonga.miuix.kmp.blur.BlurBlendMode
import top.yukonga.miuix.kmp.blur.BlurColors
import top.yukonga.miuix.kmp.blur.BlurDefaults
import top.yukonga.miuix.kmp.blur.isRuntimeShaderSupported
import top.yukonga.miuix.kmp.blur.layerBackdrop
import top.yukonga.miuix.kmp.blur.textureBlur
import top.yukonga.miuix.kmp.interfaces.ExperimentalScrollBarApi
import top.yukonga.miuix.kmp.preference.ArrowPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme
import androidx.compose.ui.graphics.BlendMode as ComposeBlendMode
@Composable
fun MiuixAboutScreen(
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit = {},
versionName: String = appInfo.versionName,
) {
val topAppBarScrollBehavior = MiuixScrollBehavior()
val lazyListState = rememberLazyListState()
val isWideScreen = shouldShowSplitPane()
val scrollProgress by remember {
derivedStateOf {
when {
lazyListState.firstVisibleItemIndex > 0 -> 1f
else -> {
val spacer =
lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "logoSpacer" }
if (spacer != null && spacer.size > 0) {
(lazyListState.firstVisibleItemScrollOffset.toFloat() / spacer.size).coerceIn(
0f,
1f
)
} else {
0f
}
}
}
}
}
val backdrop = rememberBlurBackdrop()
val blurActive = backdrop != null && scrollProgress == 1f
val barColor = if (blurActive) {
Color.Transparent
} else {
if (scrollProgress == 1f) MiuixTheme.colorScheme.surface else Color.Transparent
}
val navigator = remember {
object : MiuixNavigator {
override fun pop() {
onBack()
}
override fun push(route: Any) {
if (route == "License") {
onIntent(AboutIntent.ShowMdFile("许可证", "LICENSE.md"))
}
}
}
}
CompositionLocalProvider(
LocalNavigator provides navigator,
LocalIsWideScreen provides isWideScreen
) {
Scaffold(
topBar = {
BlurredBar(backdrop, blurActive) {
SmallTopAppBar(
title = stringResource(R.string.about),
scrollBehavior = topAppBarScrollBehavior,
color = barColor,
titleColor = MiuixTheme.colorScheme.onSurface.copy(
alpha = ((scrollProgress - 0.35f) / 0.65f).coerceIn(0f, 1f),
),
defaultWindowInsetsPadding = false,
navigationIcon = {
TopBarNavigationButton(onClick = onBack)
},
)
}
},
) { innerPadding ->
Box(modifier = if (backdrop != null) Modifier.layerBackdrop(backdrop) else Modifier) {
AboutContent(
padding = innerPadding,
topAppBarScrollBehavior = topAppBarScrollBehavior,
lazyListState = lazyListState,
scrollProgress = scrollProgress,
versionName = versionName,
onIntent = onIntent,
)
}
}
}
}
@Composable
private fun AboutContent(
padding: PaddingValues,
topAppBarScrollBehavior: ScrollBehavior,
lazyListState: LazyListState,
scrollProgress: Float,
versionName: String,
onIntent: (AboutIntent) -> Unit,
) {
val appState = LocalAppState.current
val isWideScreen = LocalIsWideScreen.current
val backdrop = rememberBlurBackdrop()
var blurRadius by remember { mutableFloatStateOf(60f) }
var noiseCoefficient by remember { mutableFloatStateOf(BlurDefaults.NoiseCoefficient) }
var brightness by remember { mutableFloatStateOf(0f) }
var contrast by remember { mutableFloatStateOf(1f) }
var saturation by remember { mutableFloatStateOf(1f) }
val scrollPadding = pageContentPadding(
padding,
PaddingValues(0.dp), // outerPadding placeholder
isWideScreen,
extraStart = WindowInsets.displayCutout.asPaddingValues()
.calculateLeftPadding(LayoutDirection.Ltr) + 16.dp,
extraEnd = WindowInsets.displayCutout.asPaddingValues()
.calculateRightPadding(LayoutDirection.Ltr) + 16.dp,
)
val logoPadding = pageContentPadding(
padding,
PaddingValues(0.dp),
isWideScreen,
extraTop = 40.dp,
extraStart = WindowInsets.displayCutout.asPaddingValues()
.calculateLeftPadding(LayoutDirection.Ltr) + 16.dp,
extraEnd = WindowInsets.displayCutout.asPaddingValues()
.calculateRightPadding(LayoutDirection.Ltr) + 16.dp,
)
val isInDark = isSystemInDarkTheme()
val dynamicBackground = isRuntimeShaderSupported()
val cardBlend =
if (isInDark) ColorBlendToken.Overlay_Thin_Light else ColorBlendToken.Pured_Regular_Light
val logoBlend = remember(isInDark) {
if (isInDark) {
listOf(
BlendColorEntry(Color(0xe6a1a1a1), BlurBlendMode.ColorDodge),
BlendColorEntry(Color(0x4de6e6e6), BlurBlendMode.LinearLight),
BlendColorEntry(Color(0xff1af500), BlurBlendMode.Lab),
)
} else {
listOf(
BlendColorEntry(Color(0xcc4a4a4a), BlurBlendMode.ColorBurn),
BlendColorEntry(Color(0xff4f4f4f), BlurBlendMode.LinearLight),
BlendColorEntry(Color(0xff1af200), BlurBlendMode.Lab),
)
}
}
val density = LocalDensity.current
var logoHeightDp by remember { mutableStateOf(300.dp) }
val versionCodeProgress = ((scrollProgress - 0.05f) / 0.15f).coerceIn(0f, 1f)
val projectNameProgress = ((scrollProgress - 0.20f) / 0.15f).coerceIn(0f, 1f)
val iconProgress = ((scrollProgress - 0.35f) / 0.15f).coerceIn(0f, 1f)
BgEffectBackground(
dynamicBackground = dynamicBackground,
isOs3Effect = true,
isFullSize = true,
modifier = Modifier.fillMaxSize(),
bgModifier = if (backdrop != null) Modifier.layerBackdrop(backdrop) else Modifier,
alpha = { 1f - scrollProgress },
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
top = logoPadding.calculateTopPadding() + 52.dp,
start = logoPadding.calculateLeftPadding(LayoutDirection.Ltr),
end = logoPadding.calculateRightPadding(LayoutDirection.Ltr),
)
.onSizeChanged { size ->
with(density) { logoHeightDp = size.height.toDp() }
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(88.dp)
.graphicsLayer {
clip = true
shape = RoundedCornerShape(24.dp)
alpha = 1 - iconProgress
scaleX = 1 - (iconProgress * 0.05f)
scaleY = 1 - (iconProgress * 0.05f)
}
.background(Color.White),
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = null,
modifier = Modifier.scale(1.1f)
)
}
Text(
modifier = Modifier
.padding(top = 12.dp, bottom = 5.dp)
.graphicsLayer {
alpha = 1 - projectNameProgress
scaleX = 1 - (projectNameProgress * 0.05f)
scaleY = 1 - (projectNameProgress * 0.05f)
}
.then(
if (backdrop != null) {
Modifier
.textureBlur(
backdrop = backdrop,
shape = RoundedCornerShape(16.dp),
blurRadius = 150f,
noiseCoefficient = noiseCoefficient,
colors = BlurColors(
blendColors = logoBlend,
),
contentBlendMode = ComposeBlendMode.DstIn,
)
} else {
Modifier
},
),
text = stringResource(R.string.app_name),