fix: 主题相关

This commit is contained in:
HapeLee
2026-07-02 23:27:10 +08:00
parent 6f87071a85
commit 0c15737b7b
10 changed files with 266 additions and 67 deletions
@@ -72,6 +72,13 @@ object ThemeImportExport {
return saveThemeData(name, data)
}
fun uniqueSavedThemeName(name: String): String {
if (_savedThemes.none { it.name == name }) return name
var index = 2
while (_savedThemes.any { it.name == "$name $index" }) index++
return "$name $index"
}
private fun saveThemeData(name: String, data: ThemeExportData): SavedTheme {
val file = File(baseDir, "$name.json")
baseDir.mkdirs()
@@ -82,19 +89,6 @@ object ThemeImportExport {
return theme
}
/**
* 应用已保存的主题
*/
fun applySavedTheme(theme: SavedTheme): Boolean {
return try {
applyToThemeConfig(theme.data)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
/**
* 删除已保存的主题
*/
@@ -305,7 +299,10 @@ object ThemeImportExport {
/**
* 将导出数据应用到当前配置
*/
fun applyToThemeConfig(data: ThemeExportData) {
internal fun applyToThemeConfig(
data: ThemeExportData,
applyEmbeddedCoverAssets: Boolean = true,
): AppliedThemeAssets {
// 基础主题设置
ThemeConfig.appTheme = data.appTheme
ThemeConfig.themeMode = data.themeMode
@@ -416,16 +413,31 @@ object ThemeImportExport {
CoverConfig.coverInfoOrientation = data.coverInfoOrientation
// 应用嵌入的资源
data.assets?.let { assets ->
applyAssets(assets)
}
val embeddedAssets = data.assets?.let { assets ->
applyAssets(
assets = assets,
applyCoverAssets = applyEmbeddedCoverAssets,
)
} ?: AppliedThemeAssets()
return AppliedThemeAssets(
lightCoverPaths = embeddedAssets.lightCoverPaths.ifEmpty {
data.coverDefaultImage.toCoverPaths()
},
darkCoverPaths = embeddedAssets.darkCoverPaths.ifEmpty {
data.coverDefaultImageDark.toCoverPaths()
},
)
}
private fun applyAssets(assets: Map<String, String>) {
private fun applyAssets(
assets: Map<String, String>,
applyCoverAssets: Boolean,
): AppliedThemeAssets {
val coverPaths = mutableMapOf<String, MutableList<String>>()
assets.forEach { (key, base64) ->
if (base64.isBlank()) return@forEach
if (!applyCoverAssets && key.startsWith("coverDefaultImage")) return@forEach
try {
val bytes = EncoderUtils.base64DecodeToByteArray(base64)
val destFile = when {
@@ -493,6 +505,10 @@ object ThemeImportExport {
coverPaths["coverDefaultImageDark"]?.let { paths ->
CoverConfig.defaultCoverDark = paths.joinToString(",")
}
return AppliedThemeAssets(
lightCoverPaths = coverPaths["coverDefaultImage"].orEmpty(),
darkCoverPaths = coverPaths["coverDefaultImageDark"].orEmpty(),
)
}
/**
@@ -507,16 +523,22 @@ object ThemeImportExport {
* 从JSON字符串导入主题
*/
fun importFromJson(json: String): Boolean {
return importFromJsonWithAssets(json) != null
}
internal fun importFromJsonWithAssets(json: String): AppliedThemeAssets? {
return try {
val data = parseThemeData(json) ?: return false
val data = parseThemeData(json) ?: return null
applyToThemeConfig(data)
true
} catch (e: Exception) {
e.printStackTrace()
false
null
}
}
private fun String.toCoverPaths(): List<String> =
split(",").map(String::trim).filter(String::isNotEmpty)
private fun parseThemeData(json: String): ThemeExportData? {
val root = JsonParser.parseString(json).asJsonObject
return when {
@@ -761,6 +783,7 @@ data class ThemeExportData(
val bgImageBlurring: Int = 0,
val bgImageNBlurring: Int = 0,
val appFontPath: String? = null,
val selectedCoverAlbumId: String? = null,
// 封面配置 (CoverConfig)
val coverLoadOnlyWifi: Boolean = false,
@@ -792,3 +815,8 @@ data class SavedTheme(
val name: String,
val data: ThemeExportData
)
internal data class AppliedThemeAssets(
val lightCoverPaths: List<String> = emptyList(),
val darkCoverPaths: List<String> = emptyList(),
)
@@ -2,14 +2,13 @@ package io.legado.app.help.config
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import androidx.annotation.Keep
import com.google.gson.JsonParser
import com.google.gson.annotations.SerializedName
import io.legado.app.R
import io.legado.app.domain.model.CoverAlbumImageInput
import io.legado.app.domain.usecase.CoverAlbumUseCase
import io.legado.app.ui.config.coverConfig.CoverConfig
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.utils.GSON
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -60,7 +59,10 @@ class ThemePackageManager(
context.contentResolver.openOutputStream(uri)?.use { output ->
ZipOutputStream(BufferedOutputStream(output)).use { zip ->
val assetEntries = exportAssets(zip, rawConfig)
val coverData = exportCoverAlbums(zip)
val coverData = exportCoverAlbums(
zip = zip,
preferredAlbumId = rawConfig.selectedCoverAlbumId,
)
val manifest = ThemePackageManifest(
formatVersion = FORMAT_VERSION,
name = themeName,
@@ -126,6 +128,10 @@ class ThemePackageManager(
)
)
coverAlbumUseCase.selectAlbum(selectedAlbumId)
saveImportedTheme(
name = importedThemeName(uri, manifest.name),
selectedCoverAlbumId = selectedAlbumId,
)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
@@ -145,10 +151,58 @@ class ThemePackageManager(
val json = context.contentResolver.openInputStream(uri)?.use {
it.bufferedReader().readText()
} ?: error("无法读取旧主题配置")
check(ThemeImportExport.importFromJson(json)) {
"旧主题配置格式无效或字段已被混淆"
val appliedAssets = ThemeImportExport.importFromJsonWithAssets(json)
?: error("旧主题配置格式无效或字段已被混淆")
val name = importedThemeName(uri)
val selectedCoverAlbumId = importLegacyCoverAlbums(
albumName = name,
appliedAssets = appliedAssets,
)
saveImportedTheme(
name = name,
selectedCoverAlbumId = selectedCoverAlbumId,
)
}
}
fun saveTheme(
name: String,
data: ThemeExportData = ThemeImportExport.exportFromCurrent(),
): SavedTheme {
val selectedCoverAlbumId = data.selectedCoverAlbumId
?: coverAlbumUseCase.selection.value.albumId
return ThemeImportExport.saveCurrentAsTheme(
name = name,
data = data.copy(selectedCoverAlbumId = selectedCoverAlbumId),
)
}
suspend fun applySavedTheme(theme: SavedTheme): Result<Unit> =
withContext(Dispatchers.IO) {
runSuspendCatching {
val savedAlbumId = theme.data.selectedCoverAlbumId
?.takeIf { id -> coverAlbumUseCase.albums.value.any { it.id == id } }
val appliedAssets = ThemeImportExport.applyToThemeConfig(
data = theme.data,
applyEmbeddedCoverAssets = savedAlbumId == null,
)
val selectedCoverAlbumId = if (savedAlbumId != null) {
coverAlbumUseCase.selectAlbum(savedAlbumId)
savedAlbumId
} else {
importLegacyCoverAlbums(
albumName = theme.name,
appliedAssets = appliedAssets,
)
}
if (selectedCoverAlbumId != theme.data.selectedCoverAlbumId) {
ThemeImportExport.saveCurrentAsTheme(
name = theme.name,
data = theme.data.copy(
selectedCoverAlbumId = selectedCoverAlbumId,
),
)
}
importLegacyCoverAlbums()
}
}
@@ -183,10 +237,15 @@ class ThemePackageManager(
return result
}
private fun exportCoverAlbums(zip: ZipOutputStream): ExportedCoverData {
val selection = coverAlbumUseCase.selection.value
private fun exportCoverAlbums(
zip: ZipOutputStream,
preferredAlbumId: String?,
): ExportedCoverData {
val albumsById = coverAlbumUseCase.albums.value.associateBy { it.id }
val selectedIds = listOfNotNull(selection.albumId)
val selectedAlbumId = preferredAlbumId
?.takeIf(albumsById::containsKey)
?: coverAlbumUseCase.selection.value.albumId
val selectedIds = listOfNotNull(selectedAlbumId)
val refById = selectedIds.associateWith { "album_0" }
val exportedAlbums = selectedIds.mapNotNull { albumId ->
val album = albumsById[albumId] ?: return@mapNotNull null
@@ -217,7 +276,7 @@ class ThemePackageManager(
return ExportedCoverData(
albums = exportedAlbums,
selection = ThemePackageCoverSelection(
albumRef = selection.albumId?.let(refById::get),
albumRef = selectedAlbumId?.let(refById::get),
),
)
}
@@ -289,15 +348,18 @@ class ThemePackageManager(
return result
}
private suspend fun importLegacyCoverAlbums() {
private suspend fun importLegacyCoverAlbums(
albumName: String,
appliedAssets: AppliedThemeAssets,
): String? {
val importedIds = mutableListOf<String>()
try {
val lightFiles = CoverConfig.defaultCover.toExistingFiles()
val darkFiles = CoverConfig.defaultCoverDark.toExistingFiles()
val lightFiles = appliedAssets.lightCoverPaths.toExistingFiles()
val darkFiles = appliedAssets.darkCoverPaths.toExistingFiles()
val albumId = if (lightFiles.isNotEmpty() || darkFiles.isNotEmpty()) {
val existingNames = coverAlbumUseCase.albums.value.mapTo(mutableSetOf()) { it.name }
val name = uniqueImportedAlbumName(
context.getString(R.string.default_cover),
albumName,
existingNames,
)
coverAlbumUseCase.importAlbum(
@@ -311,6 +373,7 @@ class ThemePackageManager(
null
}
coverAlbumUseCase.selectAlbum(albumId)
return albumId
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
@@ -321,6 +384,43 @@ class ThemePackageManager(
}
}
private fun saveImportedTheme(
name: String,
selectedCoverAlbumId: String?,
) {
saveTheme(
name = ThemeImportExport.uniqueSavedThemeName(name),
data = ThemeImportExport.exportFromCurrent().copy(
selectedCoverAlbumId = selectedCoverAlbumId,
),
)
}
private fun importedThemeName(
uri: Uri,
packageName: String? = null,
): String {
val displayName = context.contentResolver.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME),
null,
null,
null,
)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
}
val rawName = packageName
?.takeIf(String::isNotBlank)
?: displayName?.substringBeforeLast('.')
?: uri.lastPathSegment?.substringBeforeLast('.')
?: context.getString(R.string.import_theme)
return rawName
.replace(Regex("""[\\/:*?"<>|]"""), "_")
.trim()
.trim('.')
.ifBlank { context.getString(R.string.import_theme) }
}
private fun extractPackage(uri: Uri, root: File) {
root.mkdirs()
var entryCount = 0
@@ -410,6 +510,7 @@ class ThemePackageManager(
appFontPath = null,
coverDefaultImage = "",
coverDefaultImageDark = "",
selectedCoverAlbumId = null,
assets = null,
)
@@ -456,9 +557,8 @@ class ThemePackageManager(
return entryBytes
}
private fun String.toExistingFiles(): List<File> =
split(",")
.map(String::trim)
private fun List<String>.toExistingFiles(): List<File> =
map(String::trim)
.filter(String::isNotEmpty)
.map(::File)
.filter(File::isFile)
@@ -1,14 +1,12 @@
package io.legado.app.ui.config.customTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -18,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@@ -33,13 +32,13 @@ import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.ui.theme.adaptiveContentPadding
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.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -140,7 +139,7 @@ fun CustomThemeScreen(
}
} else {
item {
SplicedColumnGroup(title = stringResource(R.string.custom_theme)) {
SplicedColumnGroup(title = stringResource(R.string.custom_theme_colors)) {
ClickableSettingItem(
title = stringResource(R.string.seed_color),
description = stringResource(R.string.day),
@@ -322,6 +322,11 @@ fun ThemeConfigScreen(
}
)
}
ClickableSettingItem(
title = stringResource(R.string.theme_pack),
description = stringResource(R.string.theme_pack_s),
onClick = onNavigateToThemeManage
)
}
SplicedColumnGroup {
@@ -331,7 +336,7 @@ fun ThemeConfigScreen(
)
if (selectedTheme == "12" && (!isMiuixEngine || useMiuixMonet)) {
ClickableSettingItem(
title = stringResource(R.string.custom_theme),
title = stringResource(R.string.custom_theme_colors),
onClick = onNavigateToCustomTheme
)
}
@@ -803,16 +808,6 @@ fun ThemeConfigScreen(
}
}
// Theme management
item {
SplicedColumnGroup(title = stringResource(R.string.theme_pack)) {
ClickableSettingItem(
title = stringResource(R.string.theme_pack),
description = stringResource(R.string.theme_pack_s),
onClick = onNavigateToThemeManage
)
}
}
}
}
@@ -115,6 +115,14 @@ fun ThemeManageScreen(
LaunchedEffect(Unit) {
viewModel.effects.collectLatest { effect ->
when (effect) {
ThemeManageEffect.SavedThemesChanged -> {
savedThemesVersion++
}
ThemeManageEffect.RestartRequired -> {
showRestartDialog = true
}
is ThemeManageEffect.ShowResult -> {
val message = buildString {
append(context.getString(effect.messageRes))
@@ -124,6 +132,9 @@ fun ThemeManageScreen(
}
}
context.toastOnUi(message)
if (effect.savedThemesChanged) {
savedThemesVersion++
}
if (effect.restartRequired) {
showRestartDialog = true
}
@@ -249,8 +260,7 @@ fun ThemeManageScreen(
confirmText = stringResource(R.string.theme_manage_save),
onConfirm = {
if (newThemeName.isNotBlank()) {
ThemeImportExport.saveCurrentAsTheme(newThemeName)
savedThemesVersion++
viewModel.onIntent(ThemeManageIntent.SaveTheme(newThemeName))
showSaveDialog = false
}
},
@@ -275,8 +285,7 @@ fun ThemeManageScreen(
confirmText = stringResource(R.string.theme_manage_apply),
onConfirm = {
applyTarget?.let { theme ->
ThemeImportExport.applySavedTheme(theme)
showRestartDialog = true
viewModel.onIntent(ThemeManageIntent.ApplySavedTheme(theme))
}
applyTarget = null
},
@@ -310,11 +319,13 @@ fun ThemeManageScreen(
themeName = editTarget?.name ?: "",
onDismissRequest = { editTarget = null },
onSave = { newName, newData ->
editTarget?.let { old ->
ThemeImportExport.deleteSavedTheme(old)
}
ThemeImportExport.saveCurrentAsTheme(newName, newData)
savedThemesVersion++
viewModel.onIntent(
ThemeManageIntent.SaveTheme(
name = newName,
data = newData,
replacedTheme = editTarget,
)
)
editTarget = null
}
)
@@ -5,8 +5,11 @@ import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.R
import io.legado.app.help.config.SavedTheme
import io.legado.app.help.config.ThemeExportData
import io.legado.app.help.config.ThemeImportExport
import io.legado.app.help.config.ThemePackageManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
@@ -23,6 +26,49 @@ class ThemeManageViewModel(
is ThemeManageIntent.ExportPackage -> exportPackage(intent)
is ThemeManageIntent.ImportPackage -> importPackage(intent.uri)
is ThemeManageIntent.ImportLegacyJson -> importLegacyJson(intent.uri)
is ThemeManageIntent.SaveTheme -> saveTheme(intent)
is ThemeManageIntent.ApplySavedTheme -> applySavedTheme(intent.theme)
}
}
private fun saveTheme(intent: ThemeManageIntent.SaveTheme) {
viewModelScope.launch(Dispatchers.IO) {
val result = runCatching {
themePackageManager.saveTheme(
name = intent.name,
data = intent.data ?: ThemeImportExport.exportFromCurrent(),
)
intent.replacedTheme
?.takeIf { it.name != intent.name }
?.let(ThemeImportExport::deleteSavedTheme)
}
if (result.isSuccess) {
_effects.emit(ThemeManageEffect.SavedThemesChanged)
} else {
_effects.emit(
ThemeManageEffect.ShowResult(
messageRes = R.string.theme_manage_save_failed,
detail = result.exceptionOrNull()?.localizedMessage,
)
)
}
}
}
private fun applySavedTheme(theme: SavedTheme) {
viewModelScope.launch {
val result = themePackageManager.applySavedTheme(theme)
if (result.isSuccess) {
_effects.emit(ThemeManageEffect.SavedThemesChanged)
_effects.emit(ThemeManageEffect.RestartRequired)
} else {
_effects.emit(
ThemeManageEffect.ShowResult(
messageRes = R.string.theme_manage_apply_failed,
detail = result.exceptionOrNull()?.localizedMessage,
)
)
}
}
}
@@ -64,6 +110,7 @@ class ThemeManageViewModel(
ThemeManageEffect.ShowResult(
messageRes = R.string.theme_manage_import_success,
restartRequired = true,
savedThemesChanged = true,
)
} else {
ThemeManageEffect.ShowResult(
@@ -84,12 +131,23 @@ sealed interface ThemeManageIntent {
data class ImportPackage(val uri: String) : ThemeManageIntent
data class ImportLegacyJson(val uri: String) : ThemeManageIntent
data class SaveTheme(
val name: String,
val data: ThemeExportData? = null,
val replacedTheme: SavedTheme? = null,
) : ThemeManageIntent
data class ApplySavedTheme(val theme: SavedTheme) : ThemeManageIntent
}
sealed interface ThemeManageEffect {
data object SavedThemesChanged : ThemeManageEffect
data object RestartRequired : ThemeManageEffect
data class ShowResult(
@param:StringRes val messageRes: Int,
val detail: String? = null,
val restartRequired: Boolean = false,
val savedThemesChanged: Boolean = false,
) : ThemeManageEffect
}
@@ -1436,6 +1436,8 @@
<string name="theme_manage_export_failed">主题导出失败</string>
<string name="theme_manage_import_success">主题导入成功,部分设置需要重启生效</string>
<string name="theme_manage_import_failed">主题导入失败</string>
<string name="theme_manage_save_failed">主题保存失败</string>
<string name="theme_manage_apply_failed">主题应用失败</string>
<string name="theme_manage_save_current">保存当前设置</string>
<string name="theme_manage_save_current_summary">保存当前主题配置为新主题</string>
<string name="theme_manage_export_current">导出当前主题</string>
@@ -2114,6 +2114,8 @@
<string name="theme_manage_import_config_summary">從舊版 JSON 檔案匯入主題設定</string>
<string name="theme_manage_import_failed">主題匯入失敗</string>
<string name="theme_manage_import_success">主題已匯入。部分更改需要重啟。</string>
<string name="theme_manage_save_failed">主題儲存失敗</string>
<string name="theme_manage_apply_failed">主題套用失敗</string>
<string name="theme_manage_label_container_color">標籤容器色</string>
<string name="theme_manage_label_visibility">標籤可見性</string>
<string name="theme_manage_name_hint">輸入主題名稱</string>
@@ -1583,6 +1583,8 @@
<string name="theme_manage_export_failed">無法匯出主題</string>
<string name="theme_manage_import_success">主題已匯入。部分變更需要重新啟動。</string>
<string name="theme_manage_import_failed">無法匯入主題</string>
<string name="theme_manage_save_failed">無法儲存主題</string>
<string name="theme_manage_apply_failed">無法套用主題</string>
<string name="theme_manage_save_current">儲存目前主題</string>
<string name="theme_manage_save_current_summary">將目前外觀設定儲存為主題</string>
<string name="theme_manage_export_current">匯出目前主題</string>
+2
View File
@@ -1471,6 +1471,8 @@
<string name="theme_manage_export_failed">Could not export theme</string>
<string name="theme_manage_import_success">Theme imported. Some changes require a restart.</string>
<string name="theme_manage_import_failed">Could not import theme</string>
<string name="theme_manage_save_failed">Could not save theme</string>
<string name="theme_manage_apply_failed">Could not apply theme</string>
<string name="theme_manage_save_current">Save Current Theme</string>
<string name="theme_manage_save_current_summary">Save the current appearance settings as a theme</string>
<string name="theme_manage_export_current">Export Current Theme</string>