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

This commit is contained in:
HapeLee
2026-07-01 01:02:55 +08:00
parent 44b1b582c0
commit 2a3cc12da5
25 changed files with 2479 additions and 359 deletions
@@ -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")
@@ -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(),
)
}
@@ -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)
@@ -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?)
}
@@ -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,
)
@@ -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)
}
@@ -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导入主题
*/
@@ -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,
)
@@ -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)]
}
@@ -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
}
@@ -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(),
)
},
)
}
@@ -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
)
)
}
}
}
}
@@ -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,
)
}
}
}
}
@@ -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.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 }
)
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 },
)
}
@@ -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(),
)
@@ -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")
)
}
@@ -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 }
)
@@ -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
}
@@ -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> {
@@ -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"
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>