Przeglądaj źródła

fix: 支持封面图集与 ZIP 主题包

HapeLee 4 miesięcy temu
rodzic
commit
2a3cc12da5
25 zmienionych plików z 2479 dodań i 359 usunięć
  1. 4 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  2. 500 0
      app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt
  3. 11 0
      app/src/main/java/io/legado/app/di/appModule.kt
  4. 30 0
      app/src/main/java/io/legado/app/domain/gateway/CoverAlbumGateway.kt
  5. 25 0
      app/src/main/java/io/legado/app/domain/model/CoverAlbum.kt
  6. 38 0
      app/src/main/java/io/legado/app/domain/usecase/CoverAlbumUseCase.kt
  7. 126 4
      app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt
  8. 535 0
      app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt
  9. 9 7
      app/src/main/java/io/legado/app/model/BookCover.kt
  10. 68 0
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumContract.kt
  11. 448 0
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageScreen.kt
  12. 169 0
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageViewModel.kt
  13. 165 0
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumSelectSheet.kt
  14. 46 47
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt
  15. 44 105
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt
  16. 0 158
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt
  17. 61 23
      app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt
  18. 95 0
      app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt
  19. 11 1
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  20. 4 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  21. 6 2
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  22. 21 3
      app/src/main/res/values-zh-rCN/strings.xml
  23. 21 3
      app/src/main/res/values-zh-rHK/strings.xml
  24. 21 3
      app/src/main/res/values-zh-rTW/strings.xml
  25. 21 3
      app/src/main/res/values/strings.xml

+ 4 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -27,6 +27,10 @@ object LocalPreferencesKeys {
     val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
     val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
     val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
+    val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated")
+    val SELECTED_COVER_ALBUM_ID = stringPreferencesKey("selected_cover_album_id")
+    val SELECTED_LIGHT_COVER_ALBUM_ID = stringPreferencesKey("selected_light_cover_album_id")
+    val SELECTED_DARK_COVER_ALBUM_ID = stringPreferencesKey("selected_dark_cover_album_id")
 
     // Change source options
     val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")

+ 500 - 0
app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt

@@ -0,0 +1,500 @@
+package io.legado.app.data.repository
+
+import android.content.Context
+import androidx.annotation.Keep
+import com.google.gson.annotations.SerializedName
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.domain.gateway.CoverAlbumGateway
+import io.legado.app.domain.model.CoverAlbum
+import io.legado.app.domain.model.CoverAlbumImage
+import io.legado.app.domain.model.CoverAlbumImageInput
+import io.legado.app.domain.model.CoverAlbumSelection
+import io.legado.app.ui.config.coverConfig.CoverConfig
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.nio.file.StandardCopyOption
+import java.security.DigestInputStream
+import java.security.MessageDigest
+import java.util.UUID
+
+class CoverAlbumRepository(
+    private val context: Context,
+    private val preferences: LocalPreferencesRepository,
+) : CoverAlbumGateway {
+
+    companion object {
+        private const val FORMAT_VERSION = 2
+        private const val INDEX_FILE_NAME = "albums.json"
+        private const val LEGACY_DEFAULT_ALBUM_ID = "default-cover"
+    }
+
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+    private val operationMutex = Mutex()
+    private val rootDir = File(
+        context.getExternalFilesDir(null) ?: context.filesDir,
+        "cover_albums",
+    )
+    private val indexFile = File(rootDir, INDEX_FILE_NAME)
+
+    private val initialSelection = runBlocking {
+        InitialSelection(
+            albumId = preferences.getPreference(
+                LocalPreferencesKeys.SELECTED_COVER_ALBUM_ID,
+                "",
+            ).first().ifBlank { null },
+            lightAlbumId = preferences.getPreference(
+                LocalPreferencesKeys.SELECTED_LIGHT_COVER_ALBUM_ID,
+                "",
+            ).first().ifBlank { null },
+            darkAlbumId = preferences.getPreference(
+                LocalPreferencesKeys.SELECTED_DARK_COVER_ALBUM_ID,
+                "",
+            ).first().ifBlank { null },
+        )
+    }
+
+    private val _albums = MutableStateFlow(loadAlbums(initialSelection))
+    override val albums: StateFlow<List<CoverAlbum>> = _albums.asStateFlow()
+
+    private val _selection = MutableStateFlow(
+        CoverAlbumSelection(
+            albumId = initialSelection.albumId
+                ?: initialSelection.lightAlbumId
+                ?: initialSelection.darkAlbumId,
+        )
+    )
+    override val selection: StateFlow<CoverAlbumSelection> = _selection.asStateFlow()
+
+    init {
+        scope.launch {
+            migrateLegacyCoversIfNeeded()
+        }
+    }
+
+    override fun selectedImagePaths(isDark: Boolean): List<String> {
+        return _albums.value
+            .firstOrNull { it.id == _selection.value.albumId }
+            ?.let { if (isDark) it.darkImages else it.lightImages }
+            ?.map { it.path }
+            .orEmpty()
+    }
+
+    override suspend fun createAlbum(name: String): String = withContext(Dispatchers.IO) {
+        operationMutex.withLock {
+            val album = CoverAlbum(
+                id = UUID.randomUUID().toString(),
+                name = name.trim(),
+                lightImages = emptyList(),
+                darkImages = emptyList(),
+            )
+            replaceAlbums(_albums.value + album)
+            album.id
+        }
+    }
+
+    override suspend fun importAlbum(
+        name: String,
+        lightImages: List<CoverAlbumImageInput>,
+        darkImages: List<CoverAlbumImageInput>,
+    ): String = withContext(Dispatchers.IO) {
+        operationMutex.withLock {
+            val albumId = UUID.randomUUID().toString()
+            val importedLightImages = copyImages(albumId, lightImages)
+            val importedDarkImages = copyImages(albumId, darkImages)
+            val album = CoverAlbum(
+                id = albumId,
+                name = name.trim(),
+                lightImages = importedLightImages,
+                darkImages = importedDarkImages,
+            )
+            runCatching {
+                replaceAlbums(_albums.value + album)
+            }.onFailure {
+                File(rootDir, albumId).deleteRecursively()
+            }.getOrThrow()
+            albumId
+        }
+    }
+
+    override suspend fun renameAlbum(
+        albumId: String,
+        name: String,
+    ) = withContext(Dispatchers.IO) {
+        operationMutex.withLock {
+            replaceAlbums(
+                _albums.value.map { album ->
+                    if (album.id == albumId) album.copy(name = name.trim()) else album
+                }
+            )
+        }
+    }
+
+    override suspend fun deleteAlbum(albumId: String) = withContext(Dispatchers.IO) {
+        operationMutex.withLock {
+            val updatedAlbums = _albums.value.filterNot { it.id == albumId }
+            replaceAlbums(updatedAlbums)
+            if (_selection.value.albumId == albumId) {
+                persistSelection(albumId = null)
+            }
+            File(rootDir, albumId).deleteRecursively()
+            syncLegacyCoverPaths()
+        }
+    }
+
+    override suspend fun addImages(
+        albumId: String,
+        isDark: Boolean,
+        images: List<CoverAlbumImageInput>,
+    ) = withContext(Dispatchers.IO) {
+        if (images.isEmpty()) return@withContext
+        operationMutex.withLock {
+            val album = _albums.value.firstOrNull { it.id == albumId } ?: return@withLock
+            val imported = copyImages(albumId, images)
+            replaceAlbums(
+                _albums.value.map {
+                    if (it.id != albumId) {
+                        it
+                    } else if (isDark) {
+                        it.copy(
+                            darkImages = (it.darkImages + imported).distinctBy { image -> image.id }
+                        )
+                    } else {
+                        it.copy(
+                            lightImages = (it.lightImages + imported)
+                                .distinctBy { image -> image.id }
+                        )
+                    }
+                }
+            )
+            syncLegacyCoverPaths()
+        }
+    }
+
+    override suspend fun removeImage(
+        albumId: String,
+        isDark: Boolean,
+        imageId: String,
+    ) = withContext(Dispatchers.IO) {
+        operationMutex.withLock {
+            val album = _albums.value.firstOrNull { it.id == albumId } ?: return@withLock
+            val images = if (isDark) album.darkImages else album.lightImages
+            val image = images.firstOrNull { it.id == imageId } ?: return@withLock
+            replaceAlbums(
+                _albums.value.map {
+                    if (it.id == albumId) {
+                        if (isDark) {
+                            it.copy(
+                                darkImages = it.darkImages.filterNot { item -> item.id == imageId }
+                            )
+                        } else {
+                            it.copy(
+                                lightImages = it.lightImages.filterNot { item -> item.id == imageId }
+                            )
+                        }
+                    } else {
+                        it
+                    }
+                }
+            )
+            val updatedAlbum = _albums.value.first { it.id == albumId }
+            val stillUsed = (updatedAlbum.lightImages + updatedAlbum.darkImages)
+                .any { it.path == image.path }
+            if (!stillUsed) File(image.path).delete()
+            syncLegacyCoverPaths()
+        }
+    }
+
+    override suspend fun selectAlbum(albumId: String?) = withContext(Dispatchers.IO) {
+        operationMutex.withLock {
+            if (albumId != null && _albums.value.none { it.id == albumId }) return@withLock
+            persistSelection(albumId)
+            syncLegacyCoverPaths()
+        }
+    }
+
+    private suspend fun migrateLegacyCoversIfNeeded() {
+        operationMutex.withLock {
+            if (_selection.value.albumId != null &&
+                _albums.value.none { it.id == _selection.value.albumId }
+            ) {
+                persistSelection(albumId = null)
+            }
+            val migrated = preferences.getPreference(
+                LocalPreferencesKeys.COVER_ALBUM_MIGRATED,
+                false,
+            ).first()
+            val legacyLight = CoverConfig.defaultCover.toExistingFiles()
+            val legacyDark = CoverConfig.defaultCoverDark.toExistingFiles()
+            val needsRecovery = _albums.value.isEmpty() &&
+                _selection.value.albumId == null &&
+                (legacyLight.isNotEmpty() || legacyDark.isNotEmpty())
+            if (migrated && !needsRecovery) {
+                preferences.updatePreference(
+                    LocalPreferencesKeys.SELECTED_COVER_ALBUM_ID,
+                    _selection.value.albumId.orEmpty(),
+                )
+                syncLegacyCoverPaths()
+                return
+            }
+
+            var updatedAlbums = _albums.value
+            if (legacyLight.isNotEmpty() || legacyDark.isNotEmpty()) {
+                val existing = updatedAlbums.firstOrNull { it.id == LEGACY_DEFAULT_ALBUM_ID }
+                val album = CoverAlbum(
+                    id = LEGACY_DEFAULT_ALBUM_ID,
+                    name = existing?.name ?: context.getString(R.string.default_cover),
+                    lightImages = (
+                        existing?.lightImages.orEmpty() +
+                            copyImages(LEGACY_DEFAULT_ALBUM_ID, legacyLight.toInputs())
+                        ).distinctBy { it.id },
+                    darkImages = (
+                        existing?.darkImages.orEmpty() +
+                            copyImages(LEGACY_DEFAULT_ALBUM_ID, legacyDark.toInputs())
+                        ).distinctBy { it.id },
+                )
+                updatedAlbums = updatedAlbums.filterNot { it.id == album.id } + album
+                persistSelection(album.id)
+            }
+            if (updatedAlbums != _albums.value) {
+                replaceAlbums(updatedAlbums)
+            }
+            preferences.updatePreference(LocalPreferencesKeys.COVER_ALBUM_MIGRATED, true)
+            syncLegacyCoverPaths()
+        }
+    }
+
+    private suspend fun persistSelection(albumId: String?) {
+        _selection.value = CoverAlbumSelection(albumId = albumId)
+        preferences.updatePreference(
+            LocalPreferencesKeys.SELECTED_COVER_ALBUM_ID,
+            albumId.orEmpty(),
+        )
+    }
+
+    private fun syncLegacyCoverPaths() {
+        CoverConfig.defaultCover = selectedImagePaths(isDark = false).joinToString(",")
+        CoverConfig.defaultCoverDark = selectedImagePaths(isDark = true).joinToString(",")
+    }
+
+    private fun copyImages(
+        albumId: String,
+        inputs: List<CoverAlbumImageInput>,
+    ): List<CoverAlbumImage> {
+        if (inputs.isEmpty()) return emptyList()
+        val albumDir = File(rootDir, albumId).apply { mkdirs() }
+        return inputs.mapNotNull { input ->
+            runCatching {
+                val extension = input.displayName
+                    .substringAfterLast('.', "img")
+                    .lowercase()
+                    .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
+                    ?: "img"
+                val tempFile = File(albumDir, ".${UUID.randomUUID()}.tmp")
+                val digest = MessageDigest.getInstance("SHA-256")
+                input.openStream().use { source ->
+                    DigestInputStream(source, digest).use { digestInput ->
+                        FileOutputStream(tempFile).use { output ->
+                            digestInput.copyTo(output)
+                        }
+                    }
+                }
+                val id = digest.digest().joinToString("") { "%02x".format(it) }
+                val target = File(albumDir, "$id.$extension")
+                if (target.exists()) {
+                    tempFile.delete()
+                } else if (!tempFile.renameTo(target)) {
+                    tempFile.copyTo(target, overwrite = false)
+                    tempFile.delete()
+                }
+                CoverAlbumImage(
+                    id = target.name,
+                    fileName = target.name,
+                    path = target.absolutePath,
+                )
+            }.onFailure {
+                AppLog.put("导入封面图集图片失败\n${it.localizedMessage}", it)
+            }.getOrNull()
+        }
+    }
+
+    private fun replaceAlbums(albums: List<CoverAlbum>) {
+        persistAlbums(albums)
+        _albums.value = albums
+    }
+
+    private fun persistAlbums(albums: List<CoverAlbum>) {
+        rootDir.mkdirs()
+        val stored = StoredCoverAlbumIndex(
+            formatVersion = FORMAT_VERSION,
+            albums = albums.map { album ->
+                StoredCoverAlbum(
+                    id = album.id,
+                    name = album.name,
+                    lightImages = album.lightImages.map { it.fileName },
+                    darkImages = album.darkImages.map { it.fileName },
+                )
+            },
+        )
+        val temp = File(rootDir, "$INDEX_FILE_NAME.tmp")
+        FileOutputStream(temp).bufferedWriter().use { writer ->
+            GSON.toJson(stored, writer)
+        }
+        runCatching {
+            java.nio.file.Files.move(
+                temp.toPath(),
+                indexFile.toPath(),
+                StandardCopyOption.ATOMIC_MOVE,
+                StandardCopyOption.REPLACE_EXISTING,
+            )
+        }.getOrElse {
+            java.nio.file.Files.move(
+                temp.toPath(),
+                indexFile.toPath(),
+                StandardCopyOption.REPLACE_EXISTING,
+            )
+        }
+    }
+
+    private fun loadAlbums(selection: InitialSelection): List<CoverAlbum> {
+        if (!indexFile.isFile) return emptyList()
+        return runCatching {
+            val stored = indexFile.bufferedReader().use {
+                GSON.fromJson(it, StoredCoverAlbumIndex::class.java)
+            }
+            var albums = stored.albums.map { album ->
+                val legacyImages = album.images.mapExistingImages(album.id)
+                val lightFileNames = if (stored.formatVersion >= FORMAT_VERSION) {
+                    album.lightImages
+                } else if (album.id == selection.darkAlbumId &&
+                    album.id != selection.lightAlbumId
+                ) {
+                    emptyList()
+                } else {
+                    album.images
+                }
+                val darkFileNames = if (stored.formatVersion >= FORMAT_VERSION) {
+                    album.darkImages
+                } else if (album.id == selection.darkAlbumId) {
+                    album.images
+                } else {
+                    emptyList()
+                }
+                CoverAlbum(
+                    id = album.id,
+                    name = album.name,
+                    lightImages = if (stored.formatVersion >= FORMAT_VERSION) {
+                        lightFileNames.mapExistingImages(album.id)
+                    } else if (lightFileNames == album.images) {
+                        legacyImages
+                    } else {
+                        emptyList()
+                    },
+                    darkImages = if (stored.formatVersion >= FORMAT_VERSION) {
+                        darkFileNames.mapExistingImages(album.id)
+                    } else if (darkFileNames == album.images) {
+                        legacyImages
+                    } else {
+                        emptyList()
+                    },
+                )
+            }
+            if (stored.formatVersion < FORMAT_VERSION &&
+                selection.lightAlbumId != null &&
+                selection.darkAlbumId != null &&
+                selection.lightAlbumId != selection.darkAlbumId
+            ) {
+                val lightAlbum = albums.firstOrNull { it.id == selection.lightAlbumId }
+                val darkAlbum = albums.firstOrNull { it.id == selection.darkAlbumId }
+                if (lightAlbum != null && darkAlbum != null) {
+                    val copiedDarkImages = copyImages(
+                        albumId = lightAlbum.id,
+                        inputs = darkAlbum.darkImages.map { image ->
+                            CoverAlbumImageInput(image.fileName) { File(image.path).inputStream() }
+                        },
+                    )
+                    albums = albums.map { album ->
+                        if (album.id == lightAlbum.id) {
+                            album.copy(darkImages = copiedDarkImages)
+                        } else {
+                            album
+                        }
+                    }
+                }
+            }
+            if (stored.formatVersion < FORMAT_VERSION) {
+                persistAlbums(albums)
+            }
+            albums
+        }.onFailure {
+            AppLog.put("读取封面图集失败\n${it.localizedMessage}", it)
+        }.getOrDefault(emptyList())
+    }
+
+    private fun List<String>.mapExistingImages(albumId: String): List<CoverAlbumImage> =
+        mapNotNull { fileName ->
+            val file = File(File(rootDir, albumId), fileName)
+            if (!file.isFile) return@mapNotNull null
+            CoverAlbumImage(
+                id = file.name,
+                fileName = file.name,
+                path = file.absolutePath,
+            )
+        }
+
+    private fun String.toExistingFiles(): List<File> =
+        split(",")
+            .asSequence()
+            .map(String::trim)
+            .filter(String::isNotEmpty)
+            .map(::File)
+            .filter(File::isFile)
+            .toList()
+
+    private fun List<File>.toInputs(): List<CoverAlbumImageInput> = map { file ->
+        CoverAlbumImageInput(file.name) { file.inputStream() }
+    }
+
+    private data class InitialSelection(
+        val albumId: String?,
+        val lightAlbumId: String?,
+        val darkAlbumId: String?,
+    )
+
+    @Keep
+    private data class StoredCoverAlbumIndex(
+        @SerializedName("formatVersion")
+        val formatVersion: Int = FORMAT_VERSION,
+        @SerializedName("albums")
+        val albums: List<StoredCoverAlbum> = emptyList(),
+    )
+
+    @Keep
+    private data class StoredCoverAlbum(
+        @SerializedName("id")
+        val id: String = "",
+        @SerializedName("name")
+        val name: String = "",
+        @SerializedName("lightImages")
+        val lightImages: List<String> = emptyList(),
+        @SerializedName("darkImages")
+        val darkImages: List<String> = emptyList(),
+        @SerializedName("images")
+        val images: List<String> = emptyList(),
+    )
+}

+ 11 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -23,6 +23,7 @@ import io.legado.app.data.repository.BookSourceCallbackRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.BookshelfRepository
 import io.legado.app.data.repository.CacheBookDownloadRepository
+import io.legado.app.data.repository.CoverAlbumRepository
 import io.legado.app.data.repository.DatabaseMaintenanceRepository
 import io.legado.app.data.repository.DictRuleRepository
 import io.legado.app.data.repository.DictionaryRepositoryImpl
@@ -60,6 +61,7 @@ import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
+import io.legado.app.domain.gateway.CoverAlbumGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.ExploreBooksGateway
@@ -80,6 +82,7 @@ import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
+import io.legado.app.domain.usecase.CoverAlbumUseCase
 import io.legado.app.domain.usecase.DeleteBooksUseCase
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
@@ -102,6 +105,7 @@ import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
 import io.legado.app.help.coil.CoverFetcher
 import io.legado.app.help.coil.CoverInterceptor
+import io.legado.app.help.config.ThemePackageManager
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClientManga
 import io.legado.app.ui.about.AboutViewModel
@@ -132,12 +136,14 @@ import io.legado.app.ui.config.ai.AiConfigViewModel
 import io.legado.app.ui.config.ai.AiModelEditViewModel
 import io.legado.app.ui.config.ai.AiProviderEditViewModel
 import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
+import io.legado.app.ui.config.coverConfig.CoverAlbumManageViewModel
 import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
 import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
 import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
 import io.legado.app.ui.config.readConfig.ReadConfigViewModel
 import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
+import io.legado.app.ui.config.themeManage.ThemeManageViewModel
 import io.legado.app.ui.dict.DictViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
@@ -198,6 +204,7 @@ val appModule = module {
     singleOf(::CacheBookChaptersUseCase)
     singleOf(::ChangeBookSourceUseCase)
     singleOf(::ClearBookCacheUseCase)
+    singleOf(::CoverAlbumUseCase)
     singleOf(::DeleteBooksUseCase)
     singleOf(::GetReadingProgressUseCase)
     single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) }
@@ -214,6 +221,7 @@ val appModule = module {
     singleOf(::ShrinkDatabaseUseCase)
     singleOf(::WebDavBackupUseCase)
     singleOf(::BookshelfManageScreenConfig)
+    singleOf(::ThemePackageManager)
 
     single<UploadRepository> { DirectLinkUploadRepository() }
     single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
@@ -227,6 +235,7 @@ val appModule = module {
     single<BackupRestoreGateway> { BackupRestoreRepository() }
     single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
     single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
+    single<CoverAlbumGateway> { CoverAlbumRepository(get(), get()) }
     single<BookSourceCallbackGateway> { BookSourceCallbackRepository(get(), get()) }
     single<LocalBookGateway> { LocalBookRepository(get()) }
     single<DatabaseMaintenanceGateway> { DatabaseMaintenanceRepository(get()) }
@@ -293,8 +302,10 @@ val appModule = module {
     viewModelOf(::OtherConfigViewModel)
     viewModelOf(::ReadConfigViewModel)
     viewModelOf(::CoverConfigViewModel)
+    viewModelOf(::CoverAlbumManageViewModel)
     viewModelOf(::DownloadCacheConfigViewModel)
     viewModelOf(::ThemeConfigViewModel)
+    viewModelOf(::ThemeManageViewModel)
     viewModelOf(::BackupConfigViewModel)
     viewModelOf(::AiConfigViewModel)
     viewModelOf(::AiChatViewModel)

+ 30 - 0
app/src/main/java/io/legado/app/domain/gateway/CoverAlbumGateway.kt

@@ -0,0 +1,30 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.CoverAlbum
+import io.legado.app.domain.model.CoverAlbumImageInput
+import io.legado.app.domain.model.CoverAlbumSelection
+import kotlinx.coroutines.flow.StateFlow
+
+interface CoverAlbumGateway {
+
+    val albums: StateFlow<List<CoverAlbum>>
+    val selection: StateFlow<CoverAlbumSelection>
+
+    fun selectedImagePaths(isDark: Boolean): List<String>
+
+    suspend fun createAlbum(name: String): String
+    suspend fun importAlbum(
+        name: String,
+        lightImages: List<CoverAlbumImageInput>,
+        darkImages: List<CoverAlbumImageInput>,
+    ): String
+    suspend fun renameAlbum(albumId: String, name: String)
+    suspend fun deleteAlbum(albumId: String)
+    suspend fun addImages(
+        albumId: String,
+        isDark: Boolean,
+        images: List<CoverAlbumImageInput>,
+    )
+    suspend fun removeImage(albumId: String, isDark: Boolean, imageId: String)
+    suspend fun selectAlbum(albumId: String?)
+}

+ 25 - 0
app/src/main/java/io/legado/app/domain/model/CoverAlbum.kt

@@ -0,0 +1,25 @@
+package io.legado.app.domain.model
+
+import java.io.InputStream
+
+data class CoverAlbum(
+    val id: String,
+    val name: String,
+    val lightImages: List<CoverAlbumImage>,
+    val darkImages: List<CoverAlbumImage>,
+)
+
+data class CoverAlbumImage(
+    val id: String,
+    val fileName: String,
+    val path: String,
+)
+
+data class CoverAlbumSelection(
+    val albumId: String? = null,
+)
+
+data class CoverAlbumImageInput(
+    val displayName: String,
+    val openStream: () -> InputStream,
+)

+ 38 - 0
app/src/main/java/io/legado/app/domain/usecase/CoverAlbumUseCase.kt

@@ -0,0 +1,38 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.gateway.CoverAlbumGateway
+import io.legado.app.domain.model.CoverAlbumImageInput
+
+class CoverAlbumUseCase(
+    private val gateway: CoverAlbumGateway,
+) {
+
+    val albums = gateway.albums
+    val selection = gateway.selection
+
+    fun selectedImagePaths(isDark: Boolean) = gateway.selectedImagePaths(isDark)
+
+    suspend fun createAlbum(name: String) = gateway.createAlbum(name)
+
+    suspend fun importAlbum(
+        name: String,
+        lightImages: List<CoverAlbumImageInput>,
+        darkImages: List<CoverAlbumImageInput>,
+    ) = gateway.importAlbum(name, lightImages, darkImages)
+
+    suspend fun renameAlbum(albumId: String, name: String) =
+        gateway.renameAlbum(albumId, name)
+
+    suspend fun deleteAlbum(albumId: String) = gateway.deleteAlbum(albumId)
+
+    suspend fun addImages(
+        albumId: String,
+        isDark: Boolean,
+        images: List<CoverAlbumImageInput>,
+    ) = gateway.addImages(albumId, isDark, images)
+
+    suspend fun removeImage(albumId: String, isDark: Boolean, imageId: String) =
+        gateway.removeImage(albumId, isDark, imageId)
+
+    suspend fun selectAlbum(albumId: String?) = gateway.selectAlbum(albumId)
+}

+ 126 - 4
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -4,6 +4,9 @@ import android.content.Context
 import android.net.Uri
 import androidx.annotation.Keep
 import com.google.gson.GsonBuilder
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import com.google.gson.reflect.TypeToken
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.EncoderUtils
@@ -41,7 +44,8 @@ object ThemeImportExport {
             if (file.isFile && file.extension == "json") {
                 kotlin.runCatching {
                     val json = file.readText()
-                    val data = EXPORT_GSON.fromJson(json, ThemeExportData::class.java)
+                    val data = parseThemeData(json)
+                        ?: error("不支持的主题配置格式")
                     val name = file.nameWithoutExtension
                     _savedThemes.add(SavedTheme(name = name, data = data))
                 }
@@ -121,7 +125,7 @@ object ThemeImportExport {
     /**
      * 从当前配置创建导出数据
      */
-    fun exportFromCurrent(): ThemeExportData {
+    fun exportFromCurrent(includeEmbeddedAssets: Boolean = true): ThemeExportData {
         return ThemeExportData(
             // 基础主题设置
             appTheme = ThemeConfig.appTheme,
@@ -229,7 +233,7 @@ object ThemeImportExport {
             coverShowNameN = CoverConfig.coverShowNameN,
             coverShowAuthorN = CoverConfig.coverShowAuthorN,
             coverInfoOrientation = CoverConfig.coverInfoOrientation,
-            assets = exportAssets()
+            assets = if (includeEmbeddedAssets) exportAssets() else null
         )
     }
 
@@ -417,6 +421,7 @@ object ThemeImportExport {
         val coverPaths = mutableMapOf<String, MutableList<String>>()
 
         assets.forEach { (key, base64) ->
+            if (base64.isBlank()) return@forEach
             try {
                 val bytes = EncoderUtils.base64DecodeToByteArray(base64)
                 val destFile = when {
@@ -499,7 +504,7 @@ object ThemeImportExport {
      */
     fun importFromJson(json: String): Boolean {
         return try {
-            val data = GSON.fromJson(json, ThemeExportData::class.java)
+            val data = parseThemeData(json) ?: return false
             applyToThemeConfig(data)
             true
         } catch (e: Exception) {
@@ -508,6 +513,123 @@ object ThemeImportExport {
         }
     }
 
+    private fun parseThemeData(json: String): ThemeExportData? {
+        val root = JsonParser.parseString(json).asJsonObject
+        return when {
+            root.has("appTheme") && root.has("themeMode") ->
+                GSON.fromJson(root, ThemeExportData::class.java)
+
+            root.has("a") && root.has("A") && root.has("y0") ->
+                parseObfuscatedThemeV1(root)
+
+            else -> null
+        }
+    }
+
+    /**
+     * 兼容曾由 R8 混淆字段名导出的主题配置。
+     * 该映射对应加入首页开关、但尚未加入导航顺序和日夜独立配色的旧格式。
+     */
+    private fun parseObfuscatedThemeV1(root: JsonObject): ThemeExportData {
+        val assetsType = object : TypeToken<Map<String, String>>() {}.type
+        return ThemeExportData(
+            appTheme = root.string("a", "0"),
+            themeMode = root.string("b", "0"),
+            isPureBlack = root.boolean("c"),
+            composeEngine = root.string("d", "material"),
+            paletteStyle = root.string("e", "tonalSpot"),
+            materialVersion = root.string("f", "material3"),
+            customMode = root.nullableString("g"),
+            customContrast = root.string("h", "Default"),
+            launcherIcon = root.string("i", "ic_launcher"),
+            isPredictiveBackEnabled = root.boolean("j", true),
+            fontScale = root.int("k", 10),
+            enableDeepPersonalization = root.boolean("l"),
+            cPrimary = root.int("m"),
+            cNPrimary = root.int("n"),
+            themeColor = root.int("o"),
+            secondaryThemeColor = root.int("p"),
+            primaryTextColor = root.int("q"),
+            secondaryTextColor = root.int("r"),
+            themeBackgroundColor = root.int("s"),
+            labelContainerColor = root.int("t"),
+            bookInfoInputColor = root.int("u"),
+            containerOpacity = root.int("v", 100),
+            enableItemDivider = root.boolean("w"),
+            itemDividerWidth = root.float("x", 1f),
+            itemDividerLength = root.float("y", 80f),
+            itemDividerColor = root.int("z"),
+            enableBlur = root.boolean("A"),
+            enableProgressiveBlur = root.boolean("B"),
+            topBarBlurRadius = root.int("C", 24),
+            bottomBarBlurRadius = root.int("D", 8),
+            topBarBlurAlpha = root.int("E", 73),
+            bottomBarBlurAlpha = root.int("F", 40),
+            bottomBarLensRadius = root.float("G", 24f),
+            topBarOpacity = root.int("H", 100),
+            bottomBarOpacity = root.int("I", 100),
+            enableCustomTagColors = root.boolean("J"),
+            customTagColorsJson = root.nullableString("K"),
+            showHome = root.boolean("L", true),
+            showDiscovery = root.boolean("M", true),
+            showRss = root.boolean("N", true),
+            showStatusBar = root.boolean("O", true),
+            swipeAnimation = root.boolean("P", true),
+            showBottomView = root.boolean("Q", true),
+            useFloatingBottomBar = root.boolean("R"),
+            useFloatingBottomBarLiquidGlass = root.boolean("S"),
+            tabletInterface = root.string("T", "auto"),
+            labelVisibilityMode = root.string("U", "auto"),
+            defaultHomePage = root.string("V", "bookshelf"),
+            navIconHome = root.string("W"),
+            navIconBookshelf = root.string("X"),
+            navIconExplore = root.string("Y"),
+            navIconRss = root.string("Z"),
+            navIconMy = root.string("a0"),
+            useMiuixMonet = root.boolean("b0"),
+            useFlexibleTopAppBar = root.boolean("c0", true),
+            bgImageLight = root.nullableString("d0"),
+            bgImageDark = root.nullableString("e0"),
+            bgImageBlurring = root.int("f0"),
+            bgImageNBlurring = root.int("g0"),
+            appFontPath = root.nullableString("h0"),
+            coverLoadOnlyWifi = root.boolean("i0"),
+            coverUseDefault = root.boolean("j0"),
+            coverShowShadow = root.boolean("k0"),
+            coverShowStroke = root.boolean("l0", true),
+            coverDefaultColor = root.boolean("m0", true),
+            coverDefaultImage = root.string("n0"),
+            coverTextColor = root.int("o0", -16777216),
+            coverShadowColor = root.int("p0", -16777216),
+            coverShowName = root.boolean("q0", true),
+            coverShowAuthor = root.boolean("r0", true),
+            coverDefaultImageDark = root.string("s0"),
+            coverTextColorN = root.int("t0", -1),
+            coverShadowColorN = root.int("u0", -1),
+            coverShowNameN = root.boolean("v0", true),
+            coverShowAuthorN = root.boolean("w0", true),
+            coverInfoOrientation = root.string("x0", "0"),
+            assets = root.get("y0")?.takeUnless { it.isJsonNull }?.let {
+                GSON.fromJson(it, assetsType)
+            },
+        )
+    }
+
+    private fun JsonObject.string(key: String, default: String = ""): String =
+        get(key)?.takeUnless { it.isJsonNull }?.asString ?: default
+
+    private fun JsonObject.nullableString(key: String): String? =
+        get(key)?.takeUnless { it.isJsonNull }?.asString
+
+    private fun JsonObject.boolean(key: String, default: Boolean = false): Boolean =
+        get(key)?.takeUnless { it.isJsonNull }?.asBoolean ?: default
+
+    private fun JsonObject.int(key: String, default: Int = 0): Int =
+        get(key)?.takeUnless { it.isJsonNull }?.asInt ?: default
+
+    private fun JsonObject.float(key: String, default: Float = 0f): Float =
+        get(key)?.takeUnless { it.isJsonNull }?.asFloat ?: default
+
     /**
      * 从文件URI导入主题
      */

+ 535 - 0
app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt

@@ -0,0 +1,535 @@
+package io.legado.app.help.config
+
+import android.content.Context
+import android.net.Uri
+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
+import kotlinx.coroutines.withContext
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.UUID
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+import java.util.zip.ZipOutputStream
+
+class ThemePackageManager(
+    private val context: Context,
+    private val coverAlbumUseCase: CoverAlbumUseCase,
+) {
+
+    companion object {
+        const val FILE_EXTENSION = "zip"
+        private const val FORMAT_VERSION = 1
+        private const val MANIFEST_PATH = "manifest.json"
+        private const val MAX_ENTRY_COUNT = 4_096
+        private const val MAX_ENTRY_BYTES = 64L * 1024 * 1024
+        private const val MAX_TOTAL_BYTES = 512L * 1024 * 1024
+
+        private const val ASSET_BACKGROUND_LIGHT = "background.light"
+        private const val ASSET_BACKGROUND_DARK = "background.dark"
+        private const val ASSET_NAV_HOME = "navigation.home"
+        private const val ASSET_NAV_BOOKSHELF = "navigation.bookshelf"
+        private const val ASSET_NAV_EXPLORE = "navigation.explore"
+        private const val ASSET_NAV_RSS = "navigation.rss"
+        private const val ASSET_NAV_MY = "navigation.my"
+        private const val ASSET_FONT = "font.app"
+    }
+
+    suspend fun exportPackage(
+        uri: Uri,
+        themeName: String? = null,
+        themeData: ThemeExportData? = null,
+    ): Result<Unit> = withContext(Dispatchers.IO) {
+        runSuspendCatching {
+            val rawConfig = themeData ?: ThemeImportExport.exportFromCurrent(
+                includeEmbeddedAssets = false
+            )
+            context.contentResolver.openOutputStream(uri)?.use { output ->
+                ZipOutputStream(BufferedOutputStream(output)).use { zip ->
+                    val assetEntries = exportAssets(zip, rawConfig)
+                    val coverData = exportCoverAlbums(zip)
+                    val manifest = ThemePackageManifest(
+                        formatVersion = FORMAT_VERSION,
+                        name = themeName,
+                        config = rawConfig.toPortableConfig(),
+                        assets = assetEntries,
+                        coverAlbums = coverData.albums,
+                        coverSelection = coverData.selection,
+                    )
+                    zip.writeEntry(
+                        MANIFEST_PATH,
+                        GSON.toJson(manifest).byteInputStream(),
+                    )
+                }
+            } ?: error("无法创建主题包")
+        }
+    }
+
+    suspend fun importPackage(uri: Uri): Result<Unit> = withContext(Dispatchers.IO) {
+        runSuspendCatching {
+            val tempRoot = File(context.cacheDir, "theme_import/${UUID.randomUUID()}")
+            val importedAlbumIds = mutableListOf<String>()
+            val copiedAssets = mutableListOf<File>()
+            try {
+                extractPackage(uri, tempRoot)
+                val manifestFile = resolvePackageFile(tempRoot, MANIFEST_PATH)
+                val manifestJson = manifestFile.readText()
+                val manifestRoot = JsonParser.parseString(manifestJson).asJsonObject
+                require(manifestRoot.has("formatVersion") && manifestRoot.has("config")) {
+                    "主题包清单不完整"
+                }
+                val manifest = GSON.fromJson(manifestJson, ThemePackageManifest::class.java)
+                require(manifest.formatVersion == FORMAT_VERSION) {
+                    "不支持的主题包版本: ${manifest.formatVersion}"
+                }
+
+                val localAssets = importAssets(
+                    root = tempRoot,
+                    entries = manifest.assets,
+                    copiedFiles = copiedAssets,
+                )
+                val albumIdMap = importCoverAlbums(
+                    root = tempRoot,
+                    albums = manifest.coverAlbums,
+                    importedIds = importedAlbumIds,
+                )
+                val selectedAlbumId = resolveAlbumRef(
+                    manifest.coverSelection.albumRef,
+                    albumIdMap,
+                )
+                ThemeImportExport.applyToThemeConfig(
+                    manifest.config.copy(
+                        bgImageLight = localAssets[ASSET_BACKGROUND_LIGHT],
+                        bgImageDark = localAssets[ASSET_BACKGROUND_DARK],
+                        navIconHome = localAssets[ASSET_NAV_HOME].orEmpty(),
+                        navIconBookshelf = localAssets[ASSET_NAV_BOOKSHELF].orEmpty(),
+                        navIconExplore = localAssets[ASSET_NAV_EXPLORE].orEmpty(),
+                        navIconRss = localAssets[ASSET_NAV_RSS].orEmpty(),
+                        navIconMy = localAssets[ASSET_NAV_MY].orEmpty(),
+                        appFontPath = localAssets[ASSET_FONT],
+                        coverDefaultImage = "",
+                        coverDefaultImageDark = "",
+                        assets = null,
+                    )
+                )
+                coverAlbumUseCase.selectAlbum(selectedAlbumId)
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                importedAlbumIds.forEach { id ->
+                    runCatching { coverAlbumUseCase.deleteAlbum(id) }
+                }
+                copiedAssets.forEach(File::delete)
+                throw error
+            } finally {
+                tempRoot.deleteRecursively()
+            }
+        }
+    }
+
+    suspend fun importLegacyJson(uri: Uri): Result<Unit> = withContext(Dispatchers.IO) {
+        runSuspendCatching {
+            val json = context.contentResolver.openInputStream(uri)?.use {
+                it.bufferedReader().readText()
+            } ?: error("无法读取旧主题配置")
+            check(ThemeImportExport.importFromJson(json)) {
+                "旧主题配置格式无效或字段已被混淆"
+            }
+            importLegacyCoverAlbums()
+        }
+    }
+
+    private fun exportAssets(
+        zip: ZipOutputStream,
+        config: ThemeExportData,
+    ): Map<String, String> {
+        val result = linkedMapOf<String, String>()
+        val sources = listOf(
+            AssetSource(ASSET_BACKGROUND_LIGHT, config.bgImageLight, "assets/background/light"),
+            AssetSource(ASSET_BACKGROUND_DARK, config.bgImageDark, "assets/background/dark"),
+            AssetSource(ASSET_NAV_HOME, config.navIconHome, "assets/navigation/home"),
+            AssetSource(
+                ASSET_NAV_BOOKSHELF,
+                config.navIconBookshelf,
+                "assets/navigation/bookshelf",
+            ),
+            AssetSource(ASSET_NAV_EXPLORE, config.navIconExplore, "assets/navigation/explore"),
+            AssetSource(ASSET_NAV_RSS, config.navIconRss, "assets/navigation/rss"),
+            AssetSource(ASSET_NAV_MY, config.navIconMy, "assets/navigation/my"),
+            AssetSource(ASSET_FONT, config.appFontPath, "assets/fonts/app"),
+        )
+        sources.forEach { source ->
+            val path = source.sourcePath?.takeIf(String::isNotBlank) ?: return@forEach
+            val extension = sourceExtension(path, source.key)
+            val entryPath = "${source.entryBase}.$extension"
+            openSource(path).use { input ->
+                zip.writeEntry(entryPath, input)
+            }
+            result[source.key] = entryPath
+        }
+        return result
+    }
+
+    private fun exportCoverAlbums(zip: ZipOutputStream): ExportedCoverData {
+        val selection = coverAlbumUseCase.selection.value
+        val albumsById = coverAlbumUseCase.albums.value.associateBy { it.id }
+        val selectedIds = listOfNotNull(selection.albumId)
+        val refById = selectedIds.associateWith { "album_0" }
+        val exportedAlbums = selectedIds.mapNotNull { albumId ->
+            val album = albumsById[albumId] ?: return@mapNotNull null
+            val albumRef = refById.getValue(albumId)
+            fun exportImages(
+                group: String,
+                images: List<io.legado.app.domain.model.CoverAlbumImage>,
+            ) = images.mapIndexed { index, image ->
+                val file = File(image.path)
+                require(file.isFile) { "图集图片不存在: ${image.path}" }
+                val extension = file.extension
+                    .lowercase()
+                    .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
+                    ?: "img"
+                val entryPath = "cover-albums/$albumRef/$group/image_$index.$extension"
+                file.inputStream().use { input ->
+                    zip.writeEntry(entryPath, input)
+                }
+                ThemePackageCoverImage(path = entryPath)
+            }
+            ThemePackageCoverAlbum(
+                ref = albumRef,
+                name = album.name,
+                lightImages = exportImages("light", album.lightImages),
+                darkImages = exportImages("dark", album.darkImages),
+            )
+        }
+        return ExportedCoverData(
+            albums = exportedAlbums,
+            selection = ThemePackageCoverSelection(
+                albumRef = selection.albumId?.let(refById::get),
+            ),
+        )
+    }
+
+    private fun importAssets(
+        root: File,
+        entries: Map<String, String>,
+        copiedFiles: MutableList<File>,
+    ): Map<String, String> {
+        return entries.mapNotNull { (key, entryPath) ->
+            val source = resolvePackageFile(root, entryPath)
+            require(source.isFile) { "主题资源不存在: $entryPath" }
+            val extension = source.extension
+                .lowercase()
+                .takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
+                ?: "asset"
+            val targetDir = when (key) {
+                ASSET_BACKGROUND_LIGHT, ASSET_BACKGROUND_DARK -> {
+                    File(context.getExternalFilesDir(null) ?: context.filesDir, "theme_assets")
+                }
+
+                ASSET_NAV_HOME, ASSET_NAV_BOOKSHELF, ASSET_NAV_EXPLORE,
+                ASSET_NAV_RSS, ASSET_NAV_MY -> File(context.filesDir, "nav_icons")
+
+                ASSET_FONT -> File(context.filesDir, "fonts")
+                else -> return@mapNotNull null
+            }.apply { mkdirs() }
+            val target = File(
+                targetDir,
+                "theme_${key.substringAfterLast('.')}_${UUID.randomUUID()}.$extension",
+            )
+            source.copyTo(target)
+            copiedFiles += target
+            key to target.absolutePath
+        }.toMap()
+    }
+
+    private suspend fun importCoverAlbums(
+        root: File,
+        albums: List<ThemePackageCoverAlbum>,
+        importedIds: MutableList<String>,
+    ): Map<String, String> {
+        val existingNames = coverAlbumUseCase.albums.value.mapTo(mutableSetOf()) { it.name }
+        val result = mutableMapOf<String, String>()
+        val albumRefs = mutableSetOf<String>()
+        albums.forEach { album ->
+            require(album.ref.isNotBlank()) { "主题包图集标识为空" }
+            require(albumRefs.add(album.ref)) { "主题包图集标识重复: ${album.ref}" }
+        }
+        albums.forEach { album ->
+            fun toInputs(images: List<ThemePackageCoverImage>) = images.map { image ->
+                val source = resolvePackageFile(root, image.path)
+                require(source.isFile) { "图集图片不存在: ${image.path}" }
+                CoverAlbumImageInput(
+                    displayName = source.name,
+                    openStream = source::inputStream,
+                )
+            }
+            val importedName = uniqueImportedAlbumName(album.name, existingNames)
+            val albumId = coverAlbumUseCase.importAlbum(
+                name = importedName,
+                lightImages = toInputs(album.lightImages),
+                darkImages = toInputs(album.darkImages),
+            )
+            importedIds += albumId
+            result[album.ref] = albumId
+            existingNames += importedName
+        }
+        return result
+    }
+
+    private suspend fun importLegacyCoverAlbums() {
+        val importedIds = mutableListOf<String>()
+        try {
+            val lightFiles = CoverConfig.defaultCover.toExistingFiles()
+            val darkFiles = CoverConfig.defaultCoverDark.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),
+                    existingNames,
+                )
+                coverAlbumUseCase.importAlbum(
+                    name = name,
+                    lightImages = lightFiles.toInputs(),
+                    darkImages = darkFiles.toInputs(),
+                ).also {
+                    importedIds += it
+                }
+            } else {
+                null
+            }
+            coverAlbumUseCase.selectAlbum(albumId)
+        } catch (error: CancellationException) {
+            throw error
+        } catch (error: Exception) {
+            importedIds.forEach { id ->
+                runCatching { coverAlbumUseCase.deleteAlbum(id) }
+            }
+            throw error
+        }
+    }
+
+    private fun extractPackage(uri: Uri, root: File) {
+        root.mkdirs()
+        var entryCount = 0
+        var totalBytes = 0L
+        val entryNames = mutableSetOf<String>()
+        val input = context.contentResolver.openInputStream(uri)
+            ?: error("无法读取主题包")
+        ZipInputStream(BufferedInputStream(input)).use { zip ->
+            while (true) {
+                val entry = zip.nextEntry ?: break
+                entryCount++
+                require(entryCount <= MAX_ENTRY_COUNT) { "主题包文件数量过多" }
+                require(entryNames.add(entry.name.removeSuffix("/"))) {
+                    "主题包包含重复路径: ${entry.name}"
+                }
+                val target = resolvePackageFile(root, entry.name)
+                if (entry.isDirectory) {
+                    target.mkdirs()
+                } else {
+                    target.parentFile?.mkdirs()
+                    FileOutputStream(target).use { output ->
+                        totalBytes += zip.copyLimitedTo(
+                            output = output,
+                            maxEntryBytes = MAX_ENTRY_BYTES,
+                            currentTotalBytes = totalBytes,
+                        )
+                    }
+                    require(totalBytes <= MAX_TOTAL_BYTES) { "主题包解压后体积过大" }
+                }
+                zip.closeEntry()
+            }
+        }
+        require(entryCount > 0) { "主题包为空" }
+    }
+
+    private fun resolvePackageFile(root: File, relativePath: String): File {
+        val normalizedPath = relativePath.removeSuffix("/")
+        require(normalizedPath.isNotBlank()) { "主题包包含空路径" }
+        require(!normalizedPath.startsWith("/") && '\\' !in normalizedPath) {
+            "主题包路径无效: $relativePath"
+        }
+        require(normalizedPath.split('/').none { it.isBlank() || it == "." || it == ".." }) {
+            "主题包路径无效: $relativePath"
+        }
+        val canonicalRoot = root.canonicalFile
+        val target = File(canonicalRoot, normalizedPath).canonicalFile
+        require(target.path.startsWith(canonicalRoot.path + File.separator)) {
+            "主题包路径越界: $relativePath"
+        }
+        return target
+    }
+
+    private fun resolveAlbumRef(
+        albumRef: String?,
+        albumIdMap: Map<String, String>,
+    ): String? {
+        if (albumRef == null) return null
+        return requireNotNull(albumIdMap[albumRef]) {
+            "主题包引用了不存在的图集: $albumRef"
+        }
+    }
+
+    private fun uniqueImportedAlbumName(
+        originalName: String,
+        existingNames: Set<String>,
+    ): String {
+        val original = originalName.ifBlank { context.getString(R.string.cover_albums) }
+        if (original !in existingNames) return original
+        val baseName = context.getString(
+            R.string.cover_album_imported_name,
+            original,
+        )
+        if (baseName !in existingNames) return baseName
+        var index = 2
+        while ("$baseName $index" in existingNames) index++
+        return "$baseName $index"
+    }
+
+    private fun ThemeExportData.toPortableConfig() = copy(
+        bgImageLight = null,
+        bgImageDark = null,
+        navIconHome = "",
+        navIconBookshelf = "",
+        navIconExplore = "",
+        navIconRss = "",
+        navIconMy = "",
+        appFontPath = null,
+        coverDefaultImage = "",
+        coverDefaultImageDark = "",
+        assets = null,
+    )
+
+    private fun openSource(path: String): InputStream {
+        return if (path.startsWith("content://")) {
+            context.contentResolver.openInputStream(Uri.parse(path))
+                ?: error("无法读取主题资源")
+        } else {
+            File(path).inputStream()
+        }
+    }
+
+    private fun sourceExtension(path: String, key: String): String {
+        val candidate = Uri.parse(path).lastPathSegment
+            ?.substringAfterLast('.', "")
+            ?.lowercase()
+            ?.takeIf { it.matches(Regex("[a-z0-9]{1,8}")) }
+        return candidate ?: if (key == ASSET_FONT) "ttf" else "img"
+    }
+
+    private fun ZipOutputStream.writeEntry(path: String, input: InputStream) {
+        putNextEntry(ZipEntry(path))
+        input.use { it.copyTo(this) }
+        closeEntry()
+    }
+
+    private fun InputStream.copyLimitedTo(
+        output: OutputStream,
+        maxEntryBytes: Long,
+        currentTotalBytes: Long,
+    ): Long {
+        val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+        var entryBytes = 0L
+        while (true) {
+            val read = read(buffer)
+            if (read < 0) break
+            entryBytes += read
+            require(entryBytes <= maxEntryBytes) { "主题包单个文件过大" }
+            require(currentTotalBytes + entryBytes <= MAX_TOTAL_BYTES) {
+                "主题包解压后体积过大"
+            }
+            output.write(buffer, 0, read)
+        }
+        return entryBytes
+    }
+
+    private fun String.toExistingFiles(): List<File> =
+        split(",")
+            .map(String::trim)
+            .filter(String::isNotEmpty)
+            .map(::File)
+            .filter(File::isFile)
+
+    private fun List<File>.toInputs(): List<CoverAlbumImageInput> = map { file ->
+        CoverAlbumImageInput(
+            displayName = file.name,
+            openStream = file::inputStream,
+        )
+    }
+
+    private suspend inline fun <T> runSuspendCatching(
+        crossinline block: suspend () -> T,
+    ): Result<T> {
+        return try {
+            Result.success(block())
+        } catch (error: CancellationException) {
+            throw error
+        } catch (error: Exception) {
+            Result.failure(error)
+        }
+    }
+
+    private data class AssetSource(
+        val key: String,
+        val sourcePath: String?,
+        val entryBase: String,
+    )
+
+    private data class ExportedCoverData(
+        val albums: List<ThemePackageCoverAlbum>,
+        val selection: ThemePackageCoverSelection,
+    )
+}
+
+@Keep
+data class ThemePackageManifest(
+    @SerializedName("formatVersion")
+    val formatVersion: Int = 1,
+    @SerializedName("name")
+    val name: String? = null,
+    @SerializedName("config")
+    val config: ThemeExportData = ThemeExportData(),
+    @SerializedName("assets")
+    val assets: Map<String, String> = emptyMap(),
+    @SerializedName("coverAlbums")
+    val coverAlbums: List<ThemePackageCoverAlbum> = emptyList(),
+    @SerializedName("coverSelection")
+    val coverSelection: ThemePackageCoverSelection = ThemePackageCoverSelection(),
+)
+
+@Keep
+data class ThemePackageCoverAlbum(
+    @SerializedName("ref")
+    val ref: String = "",
+    @SerializedName("name")
+    val name: String = "",
+    @SerializedName("lightImages")
+    val lightImages: List<ThemePackageCoverImage> = emptyList(),
+    @SerializedName("darkImages")
+    val darkImages: List<ThemePackageCoverImage> = emptyList(),
+)
+
+@Keep
+data class ThemePackageCoverImage(
+    @SerializedName("path")
+    val path: String = "",
+)
+
+@Keep
+data class ThemePackageCoverSelection(
+    @SerializedName("albumRef")
+    val albumRef: String? = null,
+)

+ 9 - 7
app/src/main/java/io/legado/app/model/BookCover.kt

@@ -32,28 +32,31 @@ import io.legado.app.help.glide.OkHttpModelLoader
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeUrl
+import io.legado.app.domain.usecase.CoverAlbumUseCase
 import io.legado.app.utils.BitmapUtils
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import kotlinx.coroutines.currentCoroutineContext
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
 import splitties.init.appCtx
 import java.io.File
 import kotlin.random.Random
 
 @Keep
-object BookCover {
+object BookCover : KoinComponent {
 
     private const val coverRuleConfigKey = "legadoCoverRuleConfig"
     const val configFileName = "coverRule.json"
+    private val coverAlbumUseCase: CoverAlbumUseCase by inject()
 
     val defaultDrawable: Drawable
         @SuppressLint("UseCompatLoadingForDrawables")
         get() {
             val isNightTheme = AppConfig.isNightTheme
-            val pathStr = if (isNightTheme) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
-            val paths = pathStr.split(",").filter { it.isNotBlank() }
+            val paths = coverAlbumUseCase.selectedImagePaths(isNightTheme)
 
-            if (paths.isNullOrEmpty()) {
+            if (paths.isEmpty()) {
                 return appCtx.resources.getDrawable(R.drawable.image_cover_default, null)
             }
 
@@ -67,9 +70,8 @@ object BookCover {
         seed: Any? = null,
         isNight: Boolean = AppConfig.isNightTheme
     ): String? {
-        val pathStr = if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
-        val paths = pathStr.split(",").filter { it.isNotBlank() }
-        if (paths.isNullOrEmpty()) return null
+        val paths = coverAlbumUseCase.selectedImagePaths(isNight)
+        if (paths.isEmpty()) return null
         val random = if (seed != null) Random(seed.hashCode()) else Random
         return paths[random.nextInt(paths.size)]
     }

+ 68 - 0
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumContract.kt

@@ -0,0 +1,68 @@
+package io.legado.app.ui.config.coverConfig
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class CoverAlbumItemUi(
+    val id: String,
+    val name: String,
+    val lightImages: ImmutableList<CoverAlbumImageUi> = persistentListOf(),
+    val darkImages: ImmutableList<CoverAlbumImageUi> = persistentListOf(),
+)
+
+@Stable
+data class CoverAlbumImageUi(
+    val id: String,
+    val path: String,
+)
+
+@Stable
+data class CoverAlbumSelectionUiState(
+    val albums: ImmutableList<CoverAlbumItemUi> = persistentListOf(),
+    val selectedAlbumId: String? = null,
+)
+
+@Stable
+data class CoverAlbumManageUiState(
+    val albums: ImmutableList<CoverAlbumItemUi> = persistentListOf(),
+    val selectedAlbumId: String? = null,
+    val editingAlbumId: String? = null,
+    val dialog: CoverAlbumDialog? = null,
+)
+
+sealed interface CoverAlbumIntent {
+    data object CreateClick : CoverAlbumIntent
+    data class EditClick(val albumId: String) : CoverAlbumIntent
+    data class RenameClick(val albumId: String) : CoverAlbumIntent
+    data class DeleteClick(val albumId: String) : CoverAlbumIntent
+    data class SaveName(val name: String) : CoverAlbumIntent
+    data object ConfirmDelete : CoverAlbumIntent
+    data class AddImagesClick(val albumId: String, val isDark: Boolean) : CoverAlbumIntent
+    data class ImagesSelected(
+        val albumId: String,
+        val isDark: Boolean,
+        val uriStrings: List<String>,
+    ) : CoverAlbumIntent
+
+    data class RemoveImage(
+        val albumId: String,
+        val isDark: Boolean,
+        val imageId: String,
+    ) : CoverAlbumIntent
+
+    data object DismissEditor : CoverAlbumIntent
+    data object DismissDialog : CoverAlbumIntent
+}
+
+sealed interface CoverAlbumEffect {
+    data class SelectImages(val albumId: String, val isDark: Boolean) : CoverAlbumEffect
+    data class ShowMessage(val message: String) : CoverAlbumEffect
+}
+
+sealed interface CoverAlbumDialog {
+    data object Create : CoverAlbumDialog
+    data class Rename(val albumId: String, val currentName: String) : CoverAlbumDialog
+    data class Delete(val albumId: String, val name: String) : CoverAlbumDialog
+}

+ 448 - 0
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageScreen.kt

@@ -0,0 +1,448 @@
+package io.legado.app.ui.config.coverConfig
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.outlined.Collections
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.AppIconButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.tabRow.CardTabRow
+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.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun CoverAlbumManageRouteScreen(
+    onBackClick: () -> Unit,
+    viewModel: CoverAlbumManageViewModel = koinViewModel(),
+) {
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+    var imageTargetAlbumId by remember { mutableStateOf<String?>(null) }
+    var imageTargetIsDark by remember { mutableStateOf(false) }
+    val imagePicker = rememberLauncherForActivityResult(
+        ActivityResultContracts.GetMultipleContents()
+    ) { uris ->
+        val albumId = imageTargetAlbumId
+        imageTargetAlbumId = null
+        if (albumId != null && uris.isNotEmpty()) {
+            viewModel.onIntent(
+                CoverAlbumIntent.ImagesSelected(
+                    albumId = albumId,
+                    isDark = imageTargetIsDark,
+                    uriStrings = uris.map { it.toString() },
+                )
+            )
+        }
+    }
+
+    LaunchedEffect(Unit) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is CoverAlbumEffect.SelectImages -> {
+                    imageTargetAlbumId = effect.albumId
+                    imageTargetIsDark = effect.isDark
+                    imagePicker.launch("image/*")
+                }
+
+                is CoverAlbumEffect.ShowMessage -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    CoverAlbumManageScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CoverAlbumManageScreen(
+    state: CoverAlbumManageUiState,
+    onIntent: (CoverAlbumIntent) -> Unit,
+    onBackClick: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.cover_albums),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { onIntent(CoverAlbumIntent.CreateClick) },
+                        imageVector = Icons.Default.Add,
+                        contentDescription = stringResource(R.string.cover_album_create),
+                    )
+                },
+            )
+        },
+    ) { paddingValues ->
+        if (state.albums.isEmpty()) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                contentAlignment = Alignment.Center,
+            ) {
+                Column(
+                    horizontalAlignment = Alignment.CenterHorizontally,
+                    verticalArrangement = Arrangement.spacedBy(12.dp),
+                ) {
+                    AppIcon(
+                        imageVector = Icons.Outlined.Collections,
+                        contentDescription = null,
+                        modifier = Modifier.size(48.dp),
+                        tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                    AppText(
+                        text = stringResource(R.string.cover_album_empty),
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+        } else {
+            LazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                contentPadding = adaptiveContentPadding(
+                    top = paddingValues.calculateTopPadding() + 8.dp,
+                    bottom = 120.dp,
+                ),
+                verticalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                items(
+                    items = state.albums,
+                    key = { it.id },
+                    contentType = { "coverAlbum" },
+                ) { album ->
+                    CoverAlbumCard(
+                        album = album,
+                        selected = state.selectedAlbumId == album.id,
+                        onClick = { onIntent(CoverAlbumIntent.EditClick(album.id)) },
+                        onRename = { onIntent(CoverAlbumIntent.RenameClick(album.id)) },
+                        onDelete = { onIntent(CoverAlbumIntent.DeleteClick(album.id)) },
+                    )
+                }
+            }
+        }
+    }
+
+    val editingAlbum = state.albums.firstOrNull { it.id == state.editingAlbumId }
+    CoverAlbumEditorSheet(
+        album = editingAlbum,
+        onAddImages = { isDark ->
+            editingAlbum?.let {
+                onIntent(CoverAlbumIntent.AddImagesClick(it.id, isDark))
+            }
+        },
+        onRemoveImage = { isDark, imageId ->
+            editingAlbum?.let {
+                onIntent(CoverAlbumIntent.RemoveImage(it.id, isDark, imageId))
+            }
+        },
+        onDismissRequest = { onIntent(CoverAlbumIntent.DismissEditor) },
+    )
+
+    CoverAlbumDialogs(
+        dialog = state.dialog,
+        onIntent = onIntent,
+    )
+}
+
+@Composable
+private fun CoverAlbumCard(
+    album: CoverAlbumItemUi,
+    selected: Boolean,
+    onClick: () -> Unit,
+    onRename: () -> Unit,
+    onDelete: () -> Unit,
+) {
+    GlassCard(
+        onClick = onClick,
+        modifier = Modifier.fillMaxWidth(),
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(12.dp),
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Box(
+                modifier = Modifier
+                    .size(width = 64.dp, height = 88.dp)
+                    .clip(RoundedCornerShape(10.dp)),
+                contentAlignment = Alignment.Center,
+            ) {
+                val preview = album.lightImages.firstOrNull()?.path
+                    ?: album.darkImages.firstOrNull()?.path
+                if (preview != null) {
+                    AsyncImage(
+                        model = preview,
+                        contentDescription = null,
+                        modifier = Modifier.fillMaxSize(),
+                        contentScale = ContentScale.Crop,
+                    )
+                } else {
+                    AppIcon(
+                        imageVector = Icons.Outlined.Collections,
+                        contentDescription = null,
+                        tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+            Column(
+                modifier = Modifier.weight(1f),
+                verticalArrangement = Arrangement.spacedBy(4.dp),
+            ) {
+                AppText(
+                    text = album.name,
+                    style = LegadoTheme.typography.titleSmall,
+                    maxLines = 1,
+                )
+                AppText(
+                    text = stringResource(
+                        R.string.cover_album_day_night_count,
+                        album.lightImages.size,
+                        album.darkImages.size,
+                    ),
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+                if (selected) {
+                    AppText(
+                        text = stringResource(R.string.cover_album_selected),
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.primary,
+                    )
+                }
+            }
+            Column {
+                SmallPlainButton(
+                    onClick = onRename,
+                    icon = Icons.Default.Edit,
+                    contentDescription = stringResource(R.string.cover_album_rename),
+                )
+                SmallPlainButton(
+                    onClick = onDelete,
+                    icon = Icons.Default.Delete,
+                    contentDescription = stringResource(R.string.cover_album_delete),
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun CoverAlbumEditorSheet(
+    album: CoverAlbumItemUi?,
+    onAddImages: (Boolean) -> Unit,
+    onRemoveImage: (Boolean, String) -> Unit,
+    onDismissRequest: () -> Unit,
+) {
+    var selectedTab by remember(album?.id) { mutableIntStateOf(0) }
+    val isDark = selectedTab == 1
+    AppModalBottomSheet(
+        show = album != null,
+        onDismissRequest = onDismissRequest,
+        title = album?.name,
+        endAction = {
+            SmallPlainButton(
+                onClick = { onAddImages(isDark) },
+                icon = Icons.Default.Add,
+                text = stringResource(R.string.cover_album_add_images),
+            )
+        },
+    ) {
+        CardTabRow(
+            tabTitles = listOf(
+                stringResource(R.string.day),
+                stringResource(R.string.night),
+            ),
+            selectedTabIndex = selectedTab,
+            onTabSelected = { selectedTab = it },
+        )
+        val images = if (isDark) album?.darkImages.orEmpty() else album?.lightImages.orEmpty()
+        LazyVerticalGrid(
+            columns = GridCells.Fixed(3),
+            modifier = Modifier
+                .fillMaxWidth()
+                .heightIn(max = 560.dp)
+                .padding(vertical = 8.dp),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            items(
+                items = images,
+                key = { it.id },
+                contentType = { "coverAlbumImage" },
+            ) { image ->
+                Box {
+                    NormalCard(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .aspectRatio(5f / 7f),
+                        cornerRadius = 12.dp,
+                        containerColor = Color.Transparent,
+                    ) {
+                        AsyncImage(
+                            model = image.path,
+                            contentDescription = null,
+                            modifier = Modifier.fillMaxSize(),
+                            contentScale = ContentScale.Crop,
+                        )
+                    }
+                    AppIconButton(
+                        onClick = { onRemoveImage(isDark, image.id) },
+                        modifier = Modifier
+                            .align(Alignment.TopEnd)
+                            .padding(4.dp)
+                            .size(28.dp),
+                        colors = IconButtonDefaults.filledIconButtonColors(
+                            containerColor = LegadoTheme.colorScheme.surfaceContainer.copy(
+                                alpha = 0.82f
+                            )
+                        ),
+                        shape = CircleShape,
+                    ) {
+                        AppIcon(
+                            imageVector = Icons.Default.Close,
+                            contentDescription = stringResource(R.string.delete),
+                            modifier = Modifier.size(16.dp),
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun CoverAlbumDialogs(
+    dialog: CoverAlbumDialog?,
+    onIntent: (CoverAlbumIntent) -> Unit,
+) {
+    when (dialog) {
+        CoverAlbumDialog.Create -> CoverAlbumNameDialog(
+            show = true,
+            title = stringResource(R.string.cover_album_create),
+            initialName = "",
+            onSave = { onIntent(CoverAlbumIntent.SaveName(it)) },
+            onDismiss = { onIntent(CoverAlbumIntent.DismissDialog) },
+        )
+
+        is CoverAlbumDialog.Rename -> CoverAlbumNameDialog(
+            show = true,
+            title = stringResource(R.string.cover_album_rename),
+            initialName = dialog.currentName,
+            onSave = { onIntent(CoverAlbumIntent.SaveName(it)) },
+            onDismiss = { onIntent(CoverAlbumIntent.DismissDialog) },
+        )
+
+        is CoverAlbumDialog.Delete -> AppAlertDialog(
+            show = true,
+            onDismissRequest = { onIntent(CoverAlbumIntent.DismissDialog) },
+            title = stringResource(R.string.cover_album_delete),
+            text = stringResource(R.string.cover_album_delete_confirmation, dialog.name),
+            confirmText = stringResource(R.string.delete),
+            onConfirm = { onIntent(CoverAlbumIntent.ConfirmDelete) },
+            dismissText = stringResource(R.string.cancel),
+            onDismiss = { onIntent(CoverAlbumIntent.DismissDialog) },
+        )
+
+        null -> Unit
+    }
+}
+
+@Composable
+private fun CoverAlbumNameDialog(
+    show: Boolean,
+    title: String,
+    initialName: String,
+    onSave: (String) -> Unit,
+    onDismiss: () -> Unit,
+) {
+    var name by remember(initialName, show) { mutableStateOf(initialName) }
+    AppAlertDialog(
+        show = show,
+        onDismissRequest = onDismiss,
+        title = title,
+        confirmText = stringResource(R.string.ok),
+        onConfirm = {
+            if (name.isNotBlank()) onSave(name)
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = onDismiss,
+        content = {
+            AppTextField(
+                value = name,
+                onValueChange = { name = it },
+                placeholder = { AppText(stringResource(R.string.cover_album_name)) },
+                singleLine = true,
+                modifier = Modifier.fillMaxWidth(),
+            )
+        },
+    )
+}

+ 169 - 0
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageViewModel.kt

@@ -0,0 +1,169 @@
+package io.legado.app.ui.config.coverConfig
+
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.domain.model.CoverAlbumImageInput
+import io.legado.app.domain.usecase.CoverAlbumUseCase
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class CoverAlbumManageViewModel(
+    private val context: Context,
+    private val coverAlbumUseCase: CoverAlbumUseCase,
+) : ViewModel() {
+
+    private val editingAlbumId = MutableStateFlow<String?>(null)
+    private val dialog = MutableStateFlow<CoverAlbumDialog?>(null)
+    private val _effects = MutableSharedFlow<CoverAlbumEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    val uiState = combine(
+        coverAlbumUseCase.albums,
+        coverAlbumUseCase.selection,
+        editingAlbumId,
+        dialog,
+    ) { albums, selection, editingId, activeDialog ->
+        CoverAlbumManageUiState(
+            albums = albums.map { it.toUi() }.toImmutableList(),
+            selectedAlbumId = selection.albumId,
+            editingAlbumId = editingId,
+            dialog = activeDialog,
+        )
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5_000),
+        initialValue = CoverAlbumManageUiState(),
+    )
+
+    fun onIntent(intent: CoverAlbumIntent) {
+        when (intent) {
+            CoverAlbumIntent.CreateClick -> dialog.value = CoverAlbumDialog.Create
+            is CoverAlbumIntent.EditClick -> editingAlbumId.value = intent.albumId
+            is CoverAlbumIntent.RenameClick -> {
+                val album = uiState.value.albums.firstOrNull { it.id == intent.albumId } ?: return
+                dialog.value = CoverAlbumDialog.Rename(album.id, album.name)
+            }
+
+            is CoverAlbumIntent.DeleteClick -> {
+                val album = uiState.value.albums.firstOrNull { it.id == intent.albumId } ?: return
+                dialog.value = CoverAlbumDialog.Delete(album.id, album.name)
+            }
+
+            is CoverAlbumIntent.SaveName -> saveName(intent.name)
+            CoverAlbumIntent.ConfirmDelete -> confirmDelete()
+            is CoverAlbumIntent.AddImagesClick -> {
+                _effects.tryEmit(
+                    CoverAlbumEffect.SelectImages(intent.albumId, intent.isDark)
+                )
+            }
+
+            is CoverAlbumIntent.ImagesSelected -> addImages(
+                albumId = intent.albumId,
+                isDark = intent.isDark,
+                uriStrings = intent.uriStrings,
+            )
+
+            is CoverAlbumIntent.RemoveImage -> removeImage(
+                albumId = intent.albumId,
+                isDark = intent.isDark,
+                imageId = intent.imageId,
+            )
+
+            CoverAlbumIntent.DismissEditor -> editingAlbumId.value = null
+            CoverAlbumIntent.DismissDialog -> dialog.value = null
+        }
+    }
+
+    private fun saveName(name: String) {
+        val trimmedName = name.trim()
+        if (trimmedName.isEmpty()) return
+        val activeDialog = dialog.value
+        dialog.value = null
+        launchOperation {
+            when (activeDialog) {
+                CoverAlbumDialog.Create -> {
+                    val id = coverAlbumUseCase.createAlbum(trimmedName)
+                    editingAlbumId.value = id
+                }
+
+                is CoverAlbumDialog.Rename -> {
+                    coverAlbumUseCase.renameAlbum(activeDialog.albumId, trimmedName)
+                }
+
+                else -> Unit
+            }
+        }
+    }
+
+    private fun confirmDelete() {
+        val activeDialog = dialog.value as? CoverAlbumDialog.Delete ?: return
+        dialog.value = null
+        editingAlbumId.update { id -> if (id == activeDialog.albumId) null else id }
+        launchOperation {
+            coverAlbumUseCase.deleteAlbum(activeDialog.albumId)
+        }
+    }
+
+    private fun addImages(albumId: String, isDark: Boolean, uriStrings: List<String>) {
+        if (uriStrings.isEmpty()) return
+        launchOperation {
+            val inputs = uriStrings.map { uriString ->
+                val uri = Uri.parse(uriString)
+                CoverAlbumImageInput(
+                    displayName = queryDisplayName(uri),
+                    openStream = {
+                        context.contentResolver.openInputStream(uri)
+                            ?: error("无法读取图片")
+                    },
+                )
+            }
+            coverAlbumUseCase.addImages(albumId, isDark, inputs)
+        }
+    }
+
+    private fun removeImage(albumId: String, isDark: Boolean, imageId: String) {
+        launchOperation {
+            coverAlbumUseCase.removeImage(albumId, isDark, imageId)
+        }
+    }
+
+    private fun queryDisplayName(uri: Uri): String {
+        return context.contentResolver.query(
+            uri,
+            arrayOf(OpenableColumns.DISPLAY_NAME),
+            null,
+            null,
+            null,
+        )?.use { cursor ->
+            if (cursor.moveToFirst()) cursor.getString(0) else null
+        } ?: "cover_image"
+    }
+
+    private fun launchOperation(block: suspend () -> Unit) {
+        viewModelScope.launch(Dispatchers.IO) {
+            try {
+                block()
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                _effects.emit(
+                    CoverAlbumEffect.ShowMessage(
+                        error.localizedMessage ?: error.javaClass.simpleName
+                    )
+                )
+            }
+        }
+    }
+}

+ 165 - 0
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumSelectSheet.kt

@@ -0,0 +1,165 @@
+package io.legado.app.ui.config.coverConfig
+
+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
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.outlined.Collections
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
+
+@Composable
+fun CoverAlbumSelectSheet(
+    show: Boolean,
+    state: CoverAlbumSelectionUiState,
+    onSelect: (String?) -> Unit,
+    onManage: () -> Unit,
+    onDismissRequest: () -> Unit,
+) {
+    val selectedId = state.selectedAlbumId
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.select_cover_album),
+        endAction = {
+            SmallPlainButton(
+                onClick = onManage,
+                icon = Icons.Default.Settings,
+                contentDescription = stringResource(R.string.manage_cover_albums),
+            )
+        },
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState())
+                .padding(vertical = 8.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            CoverAlbumSelectionItem(
+                name = stringResource(R.string.cover_album_none),
+                imagePath = null,
+                lightImageCount = null,
+                darkImageCount = null,
+                selected = selectedId == null,
+                onClick = {
+                    onSelect(null)
+                    onDismissRequest()
+                },
+            )
+            state.albums.forEach { album ->
+                CoverAlbumSelectionItem(
+                    name = album.name,
+                    imagePath = album.lightImages.firstOrNull()?.path
+                        ?: album.darkImages.firstOrNull()?.path,
+                    lightImageCount = album.lightImages.size,
+                    darkImageCount = album.darkImages.size,
+                    selected = selectedId == album.id,
+                    onClick = {
+                        onSelect(album.id)
+                        onDismissRequest()
+                    },
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun CoverAlbumSelectionItem(
+    name: String,
+    imagePath: String?,
+    lightImageCount: Int?,
+    darkImageCount: Int?,
+    selected: Boolean,
+    onClick: () -> Unit,
+) {
+    NormalCard(
+        onClick = onClick,
+        modifier = Modifier.fillMaxWidth(),
+        cornerRadius = 16.dp,
+        containerColor = if (selected) {
+            LegadoTheme.colorScheme.secondaryContainer
+        } else {
+            LegadoTheme.colorScheme.surfaceContainerHigh
+        },
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(12.dp),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            Box(
+                modifier = Modifier
+                    .size(width = 44.dp, height = 60.dp)
+                    .clip(RoundedCornerShape(8.dp)),
+                contentAlignment = Alignment.Center,
+            ) {
+                if (imagePath != null) {
+                    AsyncImage(
+                        model = imagePath,
+                        contentDescription = null,
+                        modifier = Modifier.matchParentSize(),
+                        contentScale = ContentScale.Crop,
+                    )
+                } else {
+                    AppIcon(
+                        imageVector = Icons.Outlined.Collections,
+                        contentDescription = null,
+                        tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+            Column(modifier = Modifier.weight(1f)) {
+                AppText(
+                    text = name,
+                    style = LegadoTheme.typography.titleSmall,
+                    maxLines = 1,
+                )
+                if (lightImageCount != null && darkImageCount != null) {
+                    AppText(
+                        text = stringResource(
+                            R.string.cover_album_day_night_count,
+                            lightImageCount,
+                            darkImageCount,
+                        ),
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+            if (selected) {
+                AppIcon(
+                    imageVector = Icons.Default.Check,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.primary,
+                )
+            }
+        }
+    }
+}

+ 46 - 47
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt

@@ -20,8 +20,8 @@ import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
-import io.legado.app.constant.PreferKey
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
@@ -38,11 +38,15 @@ import org.koin.androidx.compose.koinViewModel
 @Composable
 fun CoverConfigScreen(
     onBackClick: () -> Unit,
+    onNavigateToCoverAlbums: () -> Unit,
     viewModel: CoverConfigViewModel = koinViewModel()
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val albumState by viewModel.albumState.collectAsStateWithLifecycle()
+    val selectedAlbum = albumState.albums
+        .firstOrNull { it.id == albumState.selectedAlbumId }
     var showCoverRuleSheet by remember { mutableStateOf(false) }
-    var manageKey by remember { mutableStateOf<String?>(null) }
+    var showAlbumSelect by remember { mutableStateOf(false) }
     var showColorPickerByField by remember { mutableStateOf<String?>(null) }
 
     AppScaffold(
@@ -86,6 +90,20 @@ fun CoverConfigScreen(
                     onCheckedChange = { CoverConfig.useDefaultCover = it }
                 )
 
+                ClickableSettingItem(
+                    title = stringResource(R.string.default_cover),
+                    description = selectedAlbum?.let {
+                        "${it.name} · ${
+                            stringResource(
+                                R.string.cover_album_day_night_count,
+                                it.lightImages.size,
+                                it.darkImages.size,
+                            )
+                        }"
+                    } ?: stringResource(R.string.cover_album_none),
+                    onClick = { showAlbumSelect = true }
+                )
+
                 SwitchSettingItem(
                     title = stringResource(R.string.cover_show_shadow),
                     checked = CoverConfig.coverShowShadow,
@@ -130,35 +148,24 @@ fun CoverConfigScreen(
                 )
             }
 
-                SplicedColumnGroup(title = stringResource(R.string.network_book_badge_setting)) {
-                    DropdownListSettingItem(
-                        title = stringResource(R.string.network_book_badge_setting),
-                        selectedValue = CoverConfig.exploreFilterState.toString(),
-                        displayEntries = arrayOf(
-                            stringResource(R.string.filter_show_all),
-                            stringResource(R.string.filter_hide_in_shelf),
-                            stringResource(R.string.filter_hide_same_name_author),
-                            stringResource(R.string.filter_show_not_in_shelf_only)
-                        ),
-                        entryValues = arrayOf("0", "1", "2", "3"),
-                        onValueChange = {
-                            CoverConfig.exploreFilterState = it.toInt()
-                        }
-                    )
-                }
-
-            SplicedColumnGroup(title = stringResource(R.string.day)) {
-                val coverCount = CoverConfig.defaultCover.split(",").filter { it.isNotBlank() }.size
-                ClickableSettingItem(
-                    title = stringResource(R.string.default_cover),
-                    description = if (coverCount > 0) {
-                        stringResource(R.string.cover_selected_images_count, coverCount)
-                    } else {
-                        stringResource(R.string.select_image)
-                    },
-                    onClick = { manageKey = PreferKey.defaultCover }
+            SplicedColumnGroup(title = stringResource(R.string.network_book_badge_setting)) {
+                DropdownListSettingItem(
+                    title = stringResource(R.string.network_book_badge_setting),
+                    selectedValue = CoverConfig.exploreFilterState.toString(),
+                    displayEntries = arrayOf(
+                        stringResource(R.string.filter_show_all),
+                        stringResource(R.string.filter_hide_in_shelf),
+                        stringResource(R.string.filter_hide_same_name_author),
+                        stringResource(R.string.filter_show_not_in_shelf_only)
+                    ),
+                    entryValues = arrayOf("0", "1", "2", "3"),
+                    onValueChange = {
+                        CoverConfig.exploreFilterState = it.toInt()
+                    }
                 )
+            }
 
+            SplicedColumnGroup(title = stringResource(R.string.day)) {
                 ClickableSettingItem(
                     title = stringResource(R.string.text_color),
                     option = "#${Integer.toHexString(CoverConfig.coverTextColor).uppercase()}",
@@ -205,19 +212,7 @@ fun CoverConfigScreen(
                 )
             }
 
-                SplicedColumnGroup(title = stringResource(R.string.night)) {
-                val coverCount =
-                    CoverConfig.defaultCoverDark.split(",").filter { it.isNotBlank() }.size
-                ClickableSettingItem(
-                    title = stringResource(R.string.default_cover),
-                    description = if (coverCount > 0) {
-                        stringResource(R.string.cover_selected_images_count, coverCount)
-                    } else {
-                        stringResource(R.string.select_image)
-                    },
-                    onClick = { manageKey = PreferKey.defaultCoverDark }
-                )
-
+            SplicedColumnGroup(title = stringResource(R.string.night)) {
                 ClickableSettingItem(
                     title = stringResource(R.string.text_color),
                     option = "#${Integer.toHexString(CoverConfig.coverTextColorN).uppercase()}",
@@ -272,12 +267,16 @@ fun CoverConfigScreen(
         onDismissRequest = { showCoverRuleSheet = false }
     )
 
-    manageKey?.let { key ->
-        CoverManageSheet(
+    if (showAlbumSelect) {
+        CoverAlbumSelectSheet(
             show = true,
-            preferenceKey = key,
-            onDismissRequest = { manageKey = null },
-            viewModel = viewModel
+            state = albumState,
+            onSelect = viewModel::selectAlbum,
+            onManage = {
+                showAlbumSelect = false
+                onNavigateToCoverAlbums()
+            },
+            onDismissRequest = { showAlbumSelect = false },
         )
     }
 

+ 44 - 105
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt

@@ -1,114 +1,36 @@
 package io.legado.app.ui.config.coverConfig
 
-import android.content.Context
-import android.net.Uri
-import androidx.documentfile.provider.DocumentFile
 import androidx.lifecycle.ViewModel
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.PreferKey
-import io.legado.app.exception.NoStackTraceException
-import io.legado.app.lib.permission.Permissions
-import io.legado.app.lib.permission.PermissionsCompat
-import io.legado.app.utils.FileDoc
-import io.legado.app.utils.FileUtils
-import io.legado.app.utils.MD5Utils
-import io.legado.app.utils.RealPathUtil
-import io.legado.app.utils.externalFiles
-import io.legado.app.utils.isContentScheme
-import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.toastOnUi
-import splitties.init.appCtx
-import java.io.File
-import java.io.FileInputStream
-import java.io.FileOutputStream
-import java.io.InputStream
+import androidx.lifecycle.viewModelScope
+import io.legado.app.domain.usecase.CoverAlbumUseCase
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
 
-class CoverConfigViewModel : ViewModel() {
+class CoverConfigViewModel(
+    private val coverAlbumUseCase: CoverAlbumUseCase,
+) : ViewModel() {
 
-    fun addCoverFromUri(preferenceKey: String, uris: List<Uri>) {
-        uris.forEach { uri ->
-            readUri(appCtx, uri) { fileDoc, _ ->
-                kotlin.runCatching {
-                    var file = appCtx.externalFiles
-                    val suffix = fileDoc.name.substringAfterLast(".")
-                    val inputStreamForMd5 = appCtx.contentResolver.openInputStream(uri)
-                        ?: throw NoStackTraceException("无法打开输入流")
-                    val fileName = MD5Utils.md5Encode(inputStreamForMd5) + ".$suffix"
-                    file = FileUtils.createFileIfNotExist(file, "covers", fileName)
-                    FileOutputStream(file).use {
-                        appCtx.contentResolver.openInputStream(uri)?.use { input ->
-                            input.copyTo(it)
-                        }
-                    }
-                    val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
-                        CoverConfig.defaultCover
-                    } else {
-                        CoverConfig.defaultCoverDark
-                    }
-                    val newList =
-                        currentCovers.split(",").filter { it.isNotBlank() }.toMutableList()
-                    if (!newList.contains(file.absolutePath)) {
-                        newList.add(file.absolutePath)
-                    }
-                    val newCovers = newList.joinToString(",")
-                    if (preferenceKey == PreferKey.defaultCover) {
-                        CoverConfig.defaultCover = newCovers
-                    } else {
-                        CoverConfig.defaultCoverDark = newCovers
-                    }
-                }.onFailure {
-                    appCtx.toastOnUi(it.localizedMessage)
-                }
-            }
-        }
-    }
+    val albumState = combine(
+        coverAlbumUseCase.albums,
+        coverAlbumUseCase.selection,
+    ) { albums, selection ->
+        CoverAlbumSelectionUiState(
+            albums = albums.map { it.toUi() }.toImmutableList(),
+            selectedAlbumId = selection.albumId,
+        )
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5_000),
+        initialValue = CoverAlbumSelectionUiState(),
+    )
 
-    private fun readUri(
-        context: Context,
-        uri: Uri?,
-        success: (fileDoc: FileDoc, inputStream: InputStream) -> Unit
-    ) {
-        uri ?: return
-        try {
-            if (uri.isContentScheme()) {
-                val doc = DocumentFile.fromSingleUri(context, uri)
-                doc ?: throw NoStackTraceException("未获取到文件")
-                val fileDoc = FileDoc.fromDocumentFile(doc)
-                context.contentResolver.openInputStream(uri)!!.use { inputStream ->
-                    success.invoke(fileDoc, inputStream)
-                }
-            } else {
-                PermissionsCompat.Builder()
-                    .addPermissions(*Permissions.Group.STORAGE)
-                    .onGranted {
-                        RealPathUtil.getPath(context, uri)?.let { path ->
-                            val file = File(path)
-                            val fileDoc = FileDoc.fromFile(file)
-                            FileInputStream(file).use { inputStream ->
-                                success.invoke(fileDoc, inputStream)
-                            }
-                        }
-                    }
-                    .request()
-            }
-        } catch (e: Exception) {
-            e.printOnDebug()
-            AppLog.put("读取Uri出错\n$e", e, true)
-        }
-    }
-
-    fun removeCover(preferenceKey: String, path: String) {
-        val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
-            CoverConfig.defaultCover
-        } else {
-            CoverConfig.defaultCoverDark
-        }
-        val newList = currentCovers.split(",").filter { it.isNotBlank() && it != path }
-        val newCovers = newList.joinToString(",")
-        if (preferenceKey == PreferKey.defaultCover) {
-            CoverConfig.defaultCover = newCovers
-        } else {
-            CoverConfig.defaultCoverDark = newCovers
+    fun selectAlbum(albumId: String?) {
+        viewModelScope.launch(Dispatchers.IO) {
+            coverAlbumUseCase.selectAlbum(albumId)
         }
     }
 
@@ -132,3 +54,20 @@ class CoverConfigViewModel : ViewModel() {
         // no-op: Compose CoilBookCover reads CoverConfig preferences directly
     }
 }
+
+internal fun io.legado.app.domain.model.CoverAlbum.toUi() = CoverAlbumItemUi(
+    id = id,
+    name = name,
+    lightImages = lightImages.map {
+        CoverAlbumImageUi(
+            id = it.id,
+            path = it.path,
+        )
+    }.toImmutableList(),
+    darkImages = darkImages.map {
+        CoverAlbumImageUi(
+            id = it.id,
+            path = it.path,
+        )
+    }.toImmutableList(),
+)

+ 0 - 158
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt

@@ -1,158 +0,0 @@
-package io.legado.app.ui.config.coverConfig
-
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
-import androidx.compose.foundation.lazy.grid.items
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.Composable
-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.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
-import io.legado.app.R
-import io.legado.app.constant.PreferKey
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.card.NormalCard
-import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
-import io.legado.app.ui.widget.components.icon.AppIcon
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.text.AppText
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun CoverManageSheet(
-    show: Boolean,
-    preferenceKey: String,
-    onDismissRequest: () -> Unit,
-    viewModel: CoverConfigViewModel
-) {
-    var showFilePicker by remember { mutableStateOf(false) }
-    val selectImages =
-        rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
-            if (uris.isNotEmpty()) {
-                viewModel.addCoverFromUri(preferenceKey, uris)
-            }
-        }
-
-    val currentCovers = if (preferenceKey == PreferKey.defaultCover) {
-        CoverConfig.defaultCover
-    } else {
-        CoverConfig.defaultCoverDark
-    }
-    val coverList = currentCovers.split(",").filter { it.isNotBlank() }
-
-    AppModalBottomSheet(
-        show = show,
-        onDismissRequest = onDismissRequest,
-        title = stringResource(R.string.default_cover)
-    ) {
-        Column(
-            modifier = Modifier
-                .fillMaxWidth()
-                .padding(bottom = 24.dp)
-        ) {
-
-            LazyVerticalGrid(
-                columns = GridCells.Fixed(3),
-                horizontalArrangement = Arrangement.spacedBy(8.dp),
-                verticalArrangement = Arrangement.spacedBy(8.dp),
-                modifier = Modifier.fillMaxWidth()
-            ) {
-                items(coverList) { path ->
-                    Box {
-                        NormalCard(
-                            cornerRadius = 12.dp,
-                            containerColor = Color.Transparent
-                        ) {
-                            AsyncImage(
-                                model = path,
-                                contentDescription = null,
-                                modifier = Modifier
-                                    .fillMaxWidth()
-                                    .aspectRatio(0.7f),
-                                contentScale = ContentScale.Crop
-                            )
-                        }
-                        IconButton(
-                            onClick = { viewModel.removeCover(preferenceKey, path) },
-                            modifier = Modifier
-                                .align(Alignment.TopEnd)
-                                .padding(4.dp)
-                                .size(24.dp)
-                        ) {
-                            Surface(
-                                shape = CircleShape,
-                                color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.7f)
-                            ) {
-                                Icon(
-                                    imageVector = Icons.Default.Close,
-                                    contentDescription = "Remove",
-                                    modifier = Modifier.size(16.dp)
-                                )
-                            }
-                        }
-                    }
-                }
-
-                item {
-                    NormalCard(
-                        onClick = { showFilePicker = true },
-                        cornerRadius = 12.dp,
-                        containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .aspectRatio(0.7f)
-                    ) {
-                        Box(
-                            modifier = Modifier.fillMaxSize(),
-                            contentAlignment = Alignment.Center
-                        ) {
-                            AppIcon(
-                                modifier = Modifier.size(32.dp),
-                                imageVector = Icons.Default.Add,
-                                contentDescription = "Add",
-                                tint = MaterialTheme.colorScheme.primary
-                            )
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    FilePickerSheet(
-        show = showFilePicker,
-        onDismissRequest = { showFilePicker = false },
-        onSelectSysFiles = {
-            selectImages.launch("image/*")
-            showFilePicker = false
-        },
-        allowExtensions = arrayOf("jpg", "jpeg", "png", "webp")
-    )
-}

+ 61 - 23
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt

@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Share
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.help.config.SavedTheme
 import io.legado.app.help.config.ThemeImportExport
+import io.legado.app.help.config.ThemePackageManager
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
@@ -56,11 +58,14 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.restart
 import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun ThemeManageScreen(
-    onBackClick: () -> Unit
+    onBackClick: () -> Unit,
+    viewModel: ThemeManageViewModel = koinViewModel(),
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val context = LocalContext.current
@@ -76,34 +81,53 @@ fun ThemeManageScreen(
     val savedThemes = remember(savedThemesVersion) { ThemeImportExport.savedThemes.toList() }
 
     val exportLauncher = rememberLauncherForActivityResult(
-        ActivityResultContracts.CreateDocument("application/json")
+        ActivityResultContracts.CreateDocument("application/zip")
     ) { uri ->
         uri?.let {
             val target = exportTarget
-            if (target != null) {
-                exportTarget = null
-                if (ThemeImportExport.exportSavedThemeToFile(context, target, it)) {
-                    context.toastOnUi(R.string.theme_manage_export_success)
-                } else {
-                    context.toastOnUi(R.string.theme_manage_export_failed)
-                }
-            } else if (ThemeImportExport.exportToFile(context, it)) {
-                context.toastOnUi(R.string.theme_manage_export_success)
-            } else {
-                context.toastOnUi(R.string.theme_manage_export_failed)
-            }
+            exportTarget = null
+            viewModel.onIntent(
+                ThemeManageIntent.ExportPackage(
+                    uri = it.toString(),
+                    themeName = target?.name,
+                    themeData = target?.data,
+                )
+            )
         }
     }
 
-    val importLauncher = rememberLauncherForActivityResult(
+    val importPackageLauncher = rememberLauncherForActivityResult(
         ActivityResultContracts.OpenDocument()
     ) { uri ->
         uri?.let {
-            if (ThemeImportExport.importFromUri(context, it)) {
-                context.toastOnUi(R.string.theme_manage_import_success)
-                showRestartDialog = true
-            } else {
-                context.toastOnUi(R.string.theme_manage_import_failed)
+            viewModel.onIntent(ThemeManageIntent.ImportPackage(it.toString()))
+        }
+    }
+
+    val importLegacyLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocument()
+    ) { uri ->
+        uri?.let {
+            viewModel.onIntent(ThemeManageIntent.ImportLegacyJson(it.toString()))
+        }
+    }
+
+    LaunchedEffect(Unit) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is ThemeManageEffect.ShowResult -> {
+                    val message = buildString {
+                        append(context.getString(effect.messageRes))
+                        effect.detail?.takeIf(String::isNotBlank)?.let {
+                            append('\n')
+                            append(it)
+                        }
+                    }
+                    context.toastOnUi(message)
+                    if (effect.restartRequired) {
+                        showRestartDialog = true
+                    }
+                }
             }
         }
     }
@@ -145,14 +169,26 @@ fun ThemeManageScreen(
                         description = stringResource(R.string.theme_manage_export_current_summary),
                         onClick = {
                             exportTarget = null
-                            exportLauncher.launch("legado_theme_${System.currentTimeMillis()}.json")
+                            exportLauncher.launch(
+                                "materado_theme_${System.currentTimeMillis()}." +
+                                    ThemePackageManager.FILE_EXTENSION
+                            )
+                        }
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.theme_manage_import_package),
+                        description = stringResource(R.string.theme_manage_import_package_summary),
+                        onClick = {
+                            importPackageLauncher.launch(
+                                arrayOf("application/zip", "application/octet-stream")
+                            )
                         }
                     )
                     ClickableSettingItem(
                         title = stringResource(R.string.theme_manage_import_config),
                         description = stringResource(R.string.theme_manage_import_config_summary),
                         onClick = {
-                            importLauncher.launch(arrayOf("application/json"))
+                            importLegacyLauncher.launch(arrayOf("application/json", "text/json"))
                         }
                     )
                 }
@@ -175,7 +211,9 @@ fun ThemeManageScreen(
                         onEdit = { editTarget = theme },
                         onExport = {
                             exportTarget = theme
-                            exportLauncher.launch("${theme.name}.json")
+                            exportLauncher.launch(
+                                "${theme.name}.${ThemePackageManager.FILE_EXTENSION}"
+                            )
                         },
                         onDelete = { deleteTarget = theme }
                     )

+ 95 - 0
app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt

@@ -0,0 +1,95 @@
+package io.legado.app.ui.config.themeManage
+
+import android.net.Uri
+import androidx.annotation.StringRes
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.R
+import io.legado.app.help.config.ThemeExportData
+import io.legado.app.help.config.ThemePackageManager
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.launch
+
+class ThemeManageViewModel(
+    private val themePackageManager: ThemePackageManager,
+) : ViewModel() {
+
+    private val _effects = MutableSharedFlow<ThemeManageEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    fun onIntent(intent: ThemeManageIntent) {
+        when (intent) {
+            is ThemeManageIntent.ExportPackage -> exportPackage(intent)
+            is ThemeManageIntent.ImportPackage -> importPackage(intent.uri)
+            is ThemeManageIntent.ImportLegacyJson -> importLegacyJson(intent.uri)
+        }
+    }
+
+    private fun exportPackage(intent: ThemeManageIntent.ExportPackage) {
+        viewModelScope.launch {
+            val result = themePackageManager.exportPackage(
+                uri = Uri.parse(intent.uri),
+                themeName = intent.themeName,
+                themeData = intent.themeData,
+            )
+            _effects.emit(
+                if (result.isSuccess) {
+                    ThemeManageEffect.ShowResult(R.string.theme_manage_export_success)
+                } else {
+                    ThemeManageEffect.ShowResult(
+                        messageRes = R.string.theme_manage_export_failed,
+                        detail = result.exceptionOrNull()?.localizedMessage,
+                    )
+                }
+            )
+        }
+    }
+
+    private fun importPackage(uri: String) {
+        viewModelScope.launch {
+            emitImportResult(themePackageManager.importPackage(Uri.parse(uri)))
+        }
+    }
+
+    private fun importLegacyJson(uri: String) {
+        viewModelScope.launch {
+            emitImportResult(themePackageManager.importLegacyJson(Uri.parse(uri)))
+        }
+    }
+
+    private suspend fun emitImportResult(result: Result<Unit>) {
+        _effects.emit(
+            if (result.isSuccess) {
+                ThemeManageEffect.ShowResult(
+                    messageRes = R.string.theme_manage_import_success,
+                    restartRequired = true,
+                )
+            } else {
+                ThemeManageEffect.ShowResult(
+                    messageRes = R.string.theme_manage_import_failed,
+                    detail = result.exceptionOrNull()?.localizedMessage,
+                )
+            }
+        )
+    }
+}
+
+sealed interface ThemeManageIntent {
+    data class ExportPackage(
+        val uri: String,
+        val themeName: String? = null,
+        val themeData: ThemeExportData? = null,
+    ) : ThemeManageIntent
+
+    data class ImportPackage(val uri: String) : ThemeManageIntent
+    data class ImportLegacyJson(val uri: String) : ThemeManageIntent
+}
+
+sealed interface ThemeManageEffect {
+    data class ShowResult(
+        @param:StringRes val messageRes: Int,
+        val detail: String? = null,
+        val restartRequired: Boolean = false,
+    ) : ThemeManageEffect
+}

+ 11 - 1
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -51,6 +51,7 @@ import io.legado.app.ui.config.ai.AiConfigRouteScreen
 import io.legado.app.ui.config.ai.AiModelEditRouteScreen
 import io.legado.app.ui.config.ai.AiProviderEditRouteScreen
 import io.legado.app.ui.config.backupConfig.BackupConfigScreen
+import io.legado.app.ui.config.coverConfig.CoverAlbumManageRouteScreen
 import io.legado.app.ui.config.coverConfig.CoverConfigScreen
 import io.legado.app.ui.config.customTheme.CustomThemeScreen
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen
@@ -207,7 +208,16 @@ fun MainActivity.mainEntryProvider(
     }
 
     entry<MainRouteSettingsCover> {
-        CoverConfigScreen(onBackClick = { onNavigateBack() })
+        CoverConfigScreen(
+            onBackClick = { onNavigateBack() },
+            onNavigateToCoverAlbums = {
+                backStack.add(MainRouteSettingsCoverAlbums)
+            },
+        )
+    }
+
+    entry<MainRouteSettingsCoverAlbums> {
+        CoverAlbumManageRouteScreen(onBackClick = { onNavigateBack() })
     }
 
     entry<MainRouteSettingsTheme> {

+ 4 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -21,6 +21,9 @@ data object MainRouteSettingsRead : MainRoute
 @Serializable
 data object MainRouteSettingsCover : MainRoute
 
+@Serializable
+data object MainRouteSettingsCoverAlbums : MainRoute
+
 @Serializable
 data object MainRouteSettingsTheme : MainRoute
 
@@ -133,6 +136,7 @@ object MainRouteConst {
     const val ROUTE_SETTINGS_OTHER = "settings/other"
     const val ROUTE_SETTINGS_READ = "settings/read"
     const val ROUTE_SETTINGS_COVER = "settings/cover"
+    const val ROUTE_SETTINGS_COVER_ALBUMS = "settings/cover/albums"
     const val ROUTE_SETTINGS_THEME = "settings/theme"
     const val ROUTE_SETTINGS_BACKUP = "settings/backup"
     const val ROUTE_SETTINGS_AI = "settings/ai"

+ 6 - 2
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -74,8 +74,10 @@ fun BookCoverImage(
 
     val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
     val finalPath = if (useDefault) null else path
+    val defaultCoverPaths =
+        if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
 
-    val randomPath = remember(name, author, path, isNight) {
+    val randomPath = remember(name, author, path, isNight, defaultCoverPaths) {
         BookCoverModel.getRandomDefaultPath(
             seed = name ?: author ?: path ?: "",
             isNight = isNight
@@ -164,8 +166,10 @@ fun CoilBookCover(
 
     val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover
     val finalPath = if (useDefault) null else path
+    val defaultCoverPaths =
+        if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
 
-    val randomPath = remember(name, author, path, isNight) {
+    val randomPath = remember(name, author, path, isNight, defaultCoverPaths) {
         BookCoverModel.getRandomDefaultPath(
             seed = name ?: author ?: path ?: "",
             isNight = isNight

+ 21 - 3
app/src/main/res/values-zh-rCN/strings.xml

@@ -1439,9 +1439,11 @@
     <string name="theme_manage_save_current">保存当前设置</string>
     <string name="theme_manage_save_current_summary">保存当前主题配置为新主题</string>
     <string name="theme_manage_export_current">导出当前主题</string>
-    <string name="theme_manage_export_current_summary">将当前主题配置导出为 JSON 文件</string>
-    <string name="theme_manage_import_config">导入主题配置</string>
-    <string name="theme_manage_import_config_summary">从 JSON 文件导入主题配置</string>
+    <string name="theme_manage_export_current_summary">将配置、图片、字体和当前图集导出为主题包</string>
+    <string name="theme_manage_import_package">导入主题包</string>
+    <string name="theme_manage_import_package_summary">导入包含资源和封面图集的主题包</string>
+    <string name="theme_manage_import_config">导入旧版主题配置</string>
+    <string name="theme_manage_import_config_summary">从旧版 JSON 文件导入主题配置</string>
     <string name="theme_manage_saved_themes">已保存的主题</string>
     <string name="theme_manage_save_theme">保存主题</string>
     <string name="theme_manage_save">保存</string>
@@ -2217,4 +2219,20 @@
     <string name="switch_to_dark_mode_tip">当前环境光较暗,是否切换至深色模式?</string>
     <string name="switch_to_light_mode_tip">当前环境光较亮,是否切换至浅色模式?</string>
     <string name="switch_action">切换</string>
+    <string name="cover_albums">封面图集</string>
+    <string name="manage_cover_albums">管理封面图集</string>
+    <string name="select_cover_album">选择封面图集</string>
+    <string name="cover_album_none">应用内置封面</string>
+    <string name="cover_album_name">图集名称</string>
+    <string name="cover_album_empty">暂无封面图集</string>
+    <string name="cover_album_create">新建封面图集</string>
+    <string name="cover_album_rename">重命名封面图集</string>
+    <string name="cover_album_delete">删除封面图集</string>
+    <string name="cover_album_delete_confirmation">删除“%1$s”及其中的全部图片?</string>
+    <string name="cover_album_add_images">添加图片</string>
+    <string name="default_light_cover_album">默认日间封面</string>
+    <string name="default_dark_cover_album">默认夜间封面</string>
+    <string name="cover_album_imported_name">%1$s(导入)</string>
+    <string name="cover_album_day_night_count">日间 %1$d · 夜间 %2$d</string>
+    <string name="cover_album_selected">当前使用</string>
 </resources>

+ 21 - 3
app/src/main/res/values-zh-rHK/strings.xml

@@ -2105,11 +2105,13 @@
     <string name="theme_manage_delete_theme">刪除主題</string>
     <string name="theme_manage_edit_theme">編輯主題</string>
     <string name="theme_manage_export_current">匯出目前主題</string>
-    <string name="theme_manage_export_current_summary">將目前主題設定匯出為 JSON 檔案</string>
+    <string name="theme_manage_export_current_summary">將設定、圖片、字體和目前圖集匯出為主題包</string>
     <string name="theme_manage_export_failed">主題匯出失敗</string>
     <string name="theme_manage_export_success">主題已匯出</string>
-    <string name="theme_manage_import_config">匯入主題檔案</string>
-    <string name="theme_manage_import_config_summary">從 JSON 檔案匯入主題設定</string>
+    <string name="theme_manage_import_package">匯入主題包</string>
+    <string name="theme_manage_import_package_summary">匯入包含資源和封面圖集的主題包</string>
+    <string name="theme_manage_import_config">匯入舊版主題設定</string>
+    <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_label_container_color">標籤容器色</string>
@@ -2209,4 +2211,20 @@
     <string name="switch_to_dark_mode_tip">當前環境光較暗,是否切換至深色模式?</string>
     <string name="switch_to_light_mode_tip">當前環境光較亮,是否切換至淺色模式?</string>
     <string name="switch_action">切換</string>
+    <string name="cover_albums">封面圖集</string>
+    <string name="manage_cover_albums">管理封面圖集</string>
+    <string name="select_cover_album">選擇封面圖集</string>
+    <string name="cover_album_none">應用程式內置封面</string>
+    <string name="cover_album_name">圖集名稱</string>
+    <string name="cover_album_empty">暫無封面圖集</string>
+    <string name="cover_album_create">新增封面圖集</string>
+    <string name="cover_album_rename">重新命名封面圖集</string>
+    <string name="cover_album_delete">刪除封面圖集</string>
+    <string name="cover_album_delete_confirmation">刪除「%1$s」及其中的全部圖片?</string>
+    <string name="cover_album_add_images">加入圖片</string>
+    <string name="default_light_cover_album">預設日間封面</string>
+    <string name="default_dark_cover_album">預設夜間封面</string>
+    <string name="cover_album_imported_name">%1$s(匯入)</string>
+    <string name="cover_album_day_night_count">日間 %1$d · 夜間 %2$d</string>
+    <string name="cover_album_selected">目前使用</string>
 </resources>

+ 21 - 3
app/src/main/res/values-zh-rTW/strings.xml

@@ -1586,9 +1586,11 @@
     <string name="theme_manage_save_current">儲存目前主題</string>
     <string name="theme_manage_save_current_summary">將目前外觀設定儲存為主題</string>
     <string name="theme_manage_export_current">匯出目前主題</string>
-    <string name="theme_manage_export_current_summary">將目前主題設定匯出為 JSON 檔案</string>
-    <string name="theme_manage_import_config">匯入主題檔案</string>
-    <string name="theme_manage_import_config_summary">從 JSON 檔案匯入主題設定</string>
+    <string name="theme_manage_export_current_summary">將設定、圖片、字型和目前圖集匯出為主題包</string>
+    <string name="theme_manage_import_package">匯入主題包</string>
+    <string name="theme_manage_import_package_summary">匯入包含資源和封面圖集的主題包</string>
+    <string name="theme_manage_import_config">匯入舊版主題設定</string>
+    <string name="theme_manage_import_config_summary">從舊版 JSON 檔案匯入主題設定</string>
     <string name="theme_manage_saved_themes">已儲存的主題</string>
     <string name="theme_manage_save_theme">儲存主題</string>
     <string name="theme_manage_save">儲存</string>
@@ -2214,4 +2216,20 @@
     <string name="switch_to_dark_mode_tip">目前環境光較暗,是否切換至深色模式?</string>
     <string name="switch_to_light_mode_tip">目前環境光較亮,是否切換至淺色模式?</string>
     <string name="switch_action">切換</string>
+    <string name="cover_albums">封面圖集</string>
+    <string name="manage_cover_albums">管理封面圖集</string>
+    <string name="select_cover_album">選擇封面圖集</string>
+    <string name="cover_album_none">應用程式內建封面</string>
+    <string name="cover_album_name">圖集名稱</string>
+    <string name="cover_album_empty">暫無封面圖集</string>
+    <string name="cover_album_create">新增封面圖集</string>
+    <string name="cover_album_rename">重新命名封面圖集</string>
+    <string name="cover_album_delete">刪除封面圖集</string>
+    <string name="cover_album_delete_confirmation">刪除「%1$s」及其中的全部圖片?</string>
+    <string name="cover_album_add_images">加入圖片</string>
+    <string name="default_light_cover_album">預設日間封面</string>
+    <string name="default_dark_cover_album">預設夜間封面</string>
+    <string name="cover_album_imported_name">%1$s(匯入)</string>
+    <string name="cover_album_day_night_count">日間 %1$d · 夜間 %2$d</string>
+    <string name="cover_album_selected">目前使用</string>
 </resources>

+ 21 - 3
app/src/main/res/values/strings.xml

@@ -1474,9 +1474,11 @@
     <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>
-    <string name="theme_manage_export_current_summary">Export the current theme settings as a JSON file</string>
-    <string name="theme_manage_import_config">Import Theme File</string>
-    <string name="theme_manage_import_config_summary">Import theme settings from a JSON file</string>
+    <string name="theme_manage_export_current_summary">Export settings, assets, fonts, and active cover albums as a theme package</string>
+    <string name="theme_manage_import_package">Import theme package</string>
+    <string name="theme_manage_import_package_summary">Import a theme package with its assets and cover albums</string>
+    <string name="theme_manage_import_config">Import legacy theme configuration</string>
+    <string name="theme_manage_import_config_summary">Import a legacy JSON theme configuration</string>
     <string name="theme_manage_saved_themes">Saved Themes</string>
     <string name="theme_manage_save_theme">Save Theme</string>
     <string name="theme_manage_save">Save</string>
@@ -2222,4 +2224,20 @@
     <string name="switch_to_dark_mode_tip">Ambient light is low. Switch to dark mode?</string>
     <string name="switch_to_light_mode_tip">Ambient light is bright. Switch to light mode?</string>
     <string name="switch_action">Switch</string>
+    <string name="cover_albums">Cover albums</string>
+    <string name="manage_cover_albums">Manage cover albums</string>
+    <string name="select_cover_album">Select cover album</string>
+    <string name="cover_album_none">Built-in default cover</string>
+    <string name="cover_album_name">Album name</string>
+    <string name="cover_album_empty">No cover albums</string>
+    <string name="cover_album_create">Create cover album</string>
+    <string name="cover_album_rename">Rename cover album</string>
+    <string name="cover_album_delete">Delete cover album</string>
+    <string name="cover_album_delete_confirmation">Delete “%1$s” and all images in it?</string>
+    <string name="cover_album_add_images">Add images</string>
+    <string name="default_light_cover_album">Default light cover</string>
+    <string name="default_dark_cover_album">Default dark cover</string>
+    <string name="cover_album_imported_name">%1$s (Imported)</string>
+    <string name="cover_album_day_night_count">Light %1$d · Dark %2$d</string>
+    <string name="cover_album_selected">Currently selected</string>
 </resources>