diff --git a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt index e68653ae8..48d96a285 100644 --- a/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt +++ b/app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt @@ -27,6 +27,10 @@ object LocalPreferencesKeys { val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes") val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url") val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections") + val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated") + val SELECTED_COVER_ALBUM_ID = stringPreferencesKey("selected_cover_album_id") + val SELECTED_LIGHT_COVER_ALBUM_ID = stringPreferencesKey("selected_light_cover_album_id") + val SELECTED_DARK_COVER_ALBUM_ID = stringPreferencesKey("selected_dark_cover_album_id") // Change source options val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor") diff --git a/app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt b/app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt new file mode 100644 index 000000000..e862d2fa4 --- /dev/null +++ b/app/src/main/java/io/legado/app/data/repository/CoverAlbumRepository.kt @@ -0,0 +1,500 @@ +package io.legado.app.data.repository + +import android.content.Context +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import io.legado.app.R +import io.legado.app.constant.AppLog +import io.legado.app.data.local.preferences.LocalPreferencesKeys +import io.legado.app.data.local.preferences.LocalPreferencesRepository +import io.legado.app.domain.gateway.CoverAlbumGateway +import io.legado.app.domain.model.CoverAlbum +import io.legado.app.domain.model.CoverAlbumImage +import io.legado.app.domain.model.CoverAlbumImageInput +import io.legado.app.domain.model.CoverAlbumSelection +import io.legado.app.ui.config.coverConfig.CoverConfig +import io.legado.app.utils.GSON +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.nio.file.StandardCopyOption +import java.security.DigestInputStream +import java.security.MessageDigest +import java.util.UUID + +class CoverAlbumRepository( + private val context: Context, + private val preferences: LocalPreferencesRepository, +) : CoverAlbumGateway { + + companion object { + private const val FORMAT_VERSION = 2 + private const val INDEX_FILE_NAME = "albums.json" + private const val LEGACY_DEFAULT_ALBUM_ID = "default-cover" + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val operationMutex = Mutex() + private val rootDir = File( + context.getExternalFilesDir(null) ?: context.filesDir, + "cover_albums", + ) + private val indexFile = File(rootDir, INDEX_FILE_NAME) + + private val initialSelection = runBlocking { + InitialSelection( + albumId = preferences.getPreference( + LocalPreferencesKeys.SELECTED_COVER_ALBUM_ID, + "", + ).first().ifBlank { null }, + lightAlbumId = preferences.getPreference( + LocalPreferencesKeys.SELECTED_LIGHT_COVER_ALBUM_ID, + "", + ).first().ifBlank { null }, + darkAlbumId = preferences.getPreference( + LocalPreferencesKeys.SELECTED_DARK_COVER_ALBUM_ID, + "", + ).first().ifBlank { null }, + ) + } + + private val _albums = MutableStateFlow(loadAlbums(initialSelection)) + override val albums: StateFlow> = _albums.asStateFlow() + + private val _selection = MutableStateFlow( + CoverAlbumSelection( + albumId = initialSelection.albumId + ?: initialSelection.lightAlbumId + ?: initialSelection.darkAlbumId, + ) + ) + override val selection: StateFlow = _selection.asStateFlow() + + init { + scope.launch { + migrateLegacyCoversIfNeeded() + } + } + + override fun selectedImagePaths(isDark: Boolean): List { + 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, + darkImages: List, + ): 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, + ) = 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, + ): List { + 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) { + persistAlbums(albums) + _albums.value = albums + } + + private fun persistAlbums(albums: List) { + 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 { + 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.mapExistingImages(albumId: String): List = + 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 = + split(",") + .asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .map(::File) + .filter(File::isFile) + .toList() + + private fun List.toInputs(): List = 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 = emptyList(), + ) + + @Keep + private data class StoredCoverAlbum( + @SerializedName("id") + val id: String = "", + @SerializedName("name") + val name: String = "", + @SerializedName("lightImages") + val lightImages: List = emptyList(), + @SerializedName("darkImages") + val darkImages: List = emptyList(), + @SerializedName("images") + val images: List = emptyList(), + ) +} diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index fefe5cd7d..f2a9f13cd 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/app/src/main/java/io/legado/app/di/appModule.kt @@ -23,6 +23,7 @@ import io.legado.app.data.repository.BookSourceCallbackRepository import io.legado.app.data.repository.BookSourceRepository import io.legado.app.data.repository.BookshelfRepository import io.legado.app.data.repository.CacheBookDownloadRepository +import io.legado.app.data.repository.CoverAlbumRepository import io.legado.app.data.repository.DatabaseMaintenanceRepository import io.legado.app.data.repository.DictRuleRepository import io.legado.app.data.repository.DictionaryRepositoryImpl @@ -60,6 +61,7 @@ import io.legado.app.domain.gateway.BookCacheCleanupGateway import io.legado.app.domain.gateway.BookCacheDownloadGateway import io.legado.app.domain.gateway.BookSearchGateway import io.legado.app.domain.gateway.BookSourceCallbackGateway +import io.legado.app.domain.gateway.CoverAlbumGateway import io.legado.app.domain.gateway.DatabaseMaintenanceGateway import io.legado.app.domain.gateway.DictionaryGateway import io.legado.app.domain.gateway.ExploreBooksGateway @@ -80,6 +82,7 @@ import io.legado.app.domain.usecase.CacheBookChaptersUseCase import io.legado.app.domain.usecase.ChangeBookSourceUseCase import io.legado.app.domain.usecase.ChangeSourceSearchUseCase import io.legado.app.domain.usecase.ClearBookCacheUseCase +import io.legado.app.domain.usecase.CoverAlbumUseCase import io.legado.app.domain.usecase.DeleteBooksUseCase import io.legado.app.domain.usecase.ExploreBooksUseCase import io.legado.app.domain.usecase.ExploreKindUiUseCase @@ -102,6 +105,7 @@ import io.legado.app.domain.usecase.WebDavBackupUseCase import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase import io.legado.app.help.coil.CoverFetcher import io.legado.app.help.coil.CoverInterceptor +import io.legado.app.help.config.ThemePackageManager import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClientManga import io.legado.app.ui.about.AboutViewModel @@ -132,12 +136,14 @@ import io.legado.app.ui.config.ai.AiConfigViewModel import io.legado.app.ui.config.ai.AiModelEditViewModel import io.legado.app.ui.config.ai.AiProviderEditViewModel import io.legado.app.ui.config.backupConfig.BackupConfigViewModel +import io.legado.app.ui.config.coverConfig.CoverAlbumManageViewModel import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig import io.legado.app.ui.config.coverConfig.CoverConfigViewModel import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel import io.legado.app.ui.config.otherConfig.OtherConfigViewModel import io.legado.app.ui.config.readConfig.ReadConfigViewModel import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel +import io.legado.app.ui.config.themeManage.ThemeManageViewModel import io.legado.app.ui.dict.DictViewModel import io.legado.app.ui.dict.rule.DictRuleViewModel import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel @@ -198,6 +204,7 @@ val appModule = module { singleOf(::CacheBookChaptersUseCase) singleOf(::ChangeBookSourceUseCase) singleOf(::ClearBookCacheUseCase) + singleOf(::CoverAlbumUseCase) singleOf(::DeleteBooksUseCase) singleOf(::GetReadingProgressUseCase) single { HomeDashboardUseCase(get(), Clock.systemDefaultZone()) } @@ -214,6 +221,7 @@ val appModule = module { singleOf(::ShrinkDatabaseUseCase) singleOf(::WebDavBackupUseCase) singleOf(::BookshelfManageScreenConfig) + singleOf(::ThemePackageManager) single { DirectLinkUploadRepository() } single { TranslationCacheRepositoryImpl() } @@ -227,6 +235,7 @@ val appModule = module { single { BackupRestoreRepository() } single { CacheBookDownloadRepository(get()) } single { BookCacheCleanupRepository(get()) } + single { CoverAlbumRepository(get(), get()) } single { BookSourceCallbackRepository(get(), get()) } single { LocalBookRepository(get()) } single { 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) diff --git a/app/src/main/java/io/legado/app/domain/gateway/CoverAlbumGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/CoverAlbumGateway.kt new file mode 100644 index 000000000..dfbb37b8e --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/gateway/CoverAlbumGateway.kt @@ -0,0 +1,30 @@ +package io.legado.app.domain.gateway + +import io.legado.app.domain.model.CoverAlbum +import io.legado.app.domain.model.CoverAlbumImageInput +import io.legado.app.domain.model.CoverAlbumSelection +import kotlinx.coroutines.flow.StateFlow + +interface CoverAlbumGateway { + + val albums: StateFlow> + val selection: StateFlow + + fun selectedImagePaths(isDark: Boolean): List + + suspend fun createAlbum(name: String): String + suspend fun importAlbum( + name: String, + lightImages: List, + darkImages: List, + ): String + suspend fun renameAlbum(albumId: String, name: String) + suspend fun deleteAlbum(albumId: String) + suspend fun addImages( + albumId: String, + isDark: Boolean, + images: List, + ) + suspend fun removeImage(albumId: String, isDark: Boolean, imageId: String) + suspend fun selectAlbum(albumId: String?) +} diff --git a/app/src/main/java/io/legado/app/domain/model/CoverAlbum.kt b/app/src/main/java/io/legado/app/domain/model/CoverAlbum.kt new file mode 100644 index 000000000..4749bdf3e --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/model/CoverAlbum.kt @@ -0,0 +1,25 @@ +package io.legado.app.domain.model + +import java.io.InputStream + +data class CoverAlbum( + val id: String, + val name: String, + val lightImages: List, + val darkImages: List, +) + +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, +) diff --git a/app/src/main/java/io/legado/app/domain/usecase/CoverAlbumUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/CoverAlbumUseCase.kt new file mode 100644 index 000000000..b3d32b680 --- /dev/null +++ b/app/src/main/java/io/legado/app/domain/usecase/CoverAlbumUseCase.kt @@ -0,0 +1,38 @@ +package io.legado.app.domain.usecase + +import io.legado.app.domain.gateway.CoverAlbumGateway +import io.legado.app.domain.model.CoverAlbumImageInput + +class CoverAlbumUseCase( + private val gateway: CoverAlbumGateway, +) { + + val albums = gateway.albums + val selection = gateway.selection + + fun selectedImagePaths(isDark: Boolean) = gateway.selectedImagePaths(isDark) + + suspend fun createAlbum(name: String) = gateway.createAlbum(name) + + suspend fun importAlbum( + name: String, + lightImages: List, + darkImages: List, + ) = 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, + ) = 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) +} diff --git a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt index 7a10a20da..f069d7fda 100644 --- a/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt +++ b/app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt @@ -4,6 +4,9 @@ import android.content.Context import android.net.Uri import androidx.annotation.Keep import com.google.gson.GsonBuilder +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.reflect.TypeToken import io.legado.app.ui.config.coverConfig.CoverConfig import io.legado.app.ui.config.themeConfig.ThemeConfig import io.legado.app.utils.EncoderUtils @@ -41,7 +44,8 @@ object ThemeImportExport { if (file.isFile && file.extension == "json") { kotlin.runCatching { val json = file.readText() - val data = EXPORT_GSON.fromJson(json, ThemeExportData::class.java) + val data = parseThemeData(json) + ?: error("不支持的主题配置格式") val name = file.nameWithoutExtension _savedThemes.add(SavedTheme(name = name, data = data)) } @@ -121,7 +125,7 @@ object ThemeImportExport { /** * 从当前配置创建导出数据 */ - fun exportFromCurrent(): ThemeExportData { + fun exportFromCurrent(includeEmbeddedAssets: Boolean = true): ThemeExportData { return ThemeExportData( // 基础主题设置 appTheme = ThemeConfig.appTheme, @@ -229,7 +233,7 @@ object ThemeImportExport { coverShowNameN = CoverConfig.coverShowNameN, coverShowAuthorN = CoverConfig.coverShowAuthorN, coverInfoOrientation = CoverConfig.coverInfoOrientation, - assets = exportAssets() + assets = if (includeEmbeddedAssets) exportAssets() else null ) } @@ -417,6 +421,7 @@ object ThemeImportExport { val coverPaths = mutableMapOf>() 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>() {}.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导入主题 */ diff --git a/app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt b/app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt new file mode 100644 index 000000000..cd2f92a7b --- /dev/null +++ b/app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt @@ -0,0 +1,535 @@ +package io.legado.app.help.config + +import android.content.Context +import android.net.Uri +import androidx.annotation.Keep +import com.google.gson.JsonParser +import com.google.gson.annotations.SerializedName +import io.legado.app.R +import io.legado.app.domain.model.CoverAlbumImageInput +import io.legado.app.domain.usecase.CoverAlbumUseCase +import io.legado.app.ui.config.coverConfig.CoverConfig +import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.utils.GSON +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.util.UUID +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +class ThemePackageManager( + private val context: Context, + private val coverAlbumUseCase: CoverAlbumUseCase, +) { + + companion object { + const val FILE_EXTENSION = "zip" + private const val FORMAT_VERSION = 1 + private const val MANIFEST_PATH = "manifest.json" + private const val MAX_ENTRY_COUNT = 4_096 + private const val MAX_ENTRY_BYTES = 64L * 1024 * 1024 + private const val MAX_TOTAL_BYTES = 512L * 1024 * 1024 + + private const val ASSET_BACKGROUND_LIGHT = "background.light" + private const val ASSET_BACKGROUND_DARK = "background.dark" + private const val ASSET_NAV_HOME = "navigation.home" + private const val ASSET_NAV_BOOKSHELF = "navigation.bookshelf" + private const val ASSET_NAV_EXPLORE = "navigation.explore" + private const val ASSET_NAV_RSS = "navigation.rss" + private const val ASSET_NAV_MY = "navigation.my" + private const val ASSET_FONT = "font.app" + } + + suspend fun exportPackage( + uri: Uri, + themeName: String? = null, + themeData: ThemeExportData? = null, + ): Result = 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 = withContext(Dispatchers.IO) { + runSuspendCatching { + val tempRoot = File(context.cacheDir, "theme_import/${UUID.randomUUID()}") + val importedAlbumIds = mutableListOf() + val copiedAssets = mutableListOf() + 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 = 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 { + val result = linkedMapOf() + 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, + ) = 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, + copiedFiles: MutableList, + ): Map { + 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, + importedIds: MutableList, + ): Map { + val existingNames = coverAlbumUseCase.albums.value.mapTo(mutableSetOf()) { it.name } + val result = mutableMapOf() + val albumRefs = mutableSetOf() + albums.forEach { album -> + require(album.ref.isNotBlank()) { "主题包图集标识为空" } + require(albumRefs.add(album.ref)) { "主题包图集标识重复: ${album.ref}" } + } + albums.forEach { album -> + fun toInputs(images: List) = 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() + 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() + 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? { + if (albumRef == null) return null + return requireNotNull(albumIdMap[albumRef]) { + "主题包引用了不存在的图集: $albumRef" + } + } + + private fun uniqueImportedAlbumName( + originalName: String, + existingNames: Set, + ): 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 = + split(",") + .map(String::trim) + .filter(String::isNotEmpty) + .map(::File) + .filter(File::isFile) + + private fun List.toInputs(): List = map { file -> + CoverAlbumImageInput( + displayName = file.name, + openStream = file::inputStream, + ) + } + + private suspend inline fun runSuspendCatching( + crossinline block: suspend () -> T, + ): Result { + 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, + 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 = emptyMap(), + @SerializedName("coverAlbums") + val coverAlbums: List = 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 = emptyList(), + @SerializedName("darkImages") + val darkImages: List = emptyList(), +) + +@Keep +data class ThemePackageCoverImage( + @SerializedName("path") + val path: String = "", +) + +@Keep +data class ThemePackageCoverSelection( + @SerializedName("albumRef") + val albumRef: String? = null, +) diff --git a/app/src/main/java/io/legado/app/model/BookCover.kt b/app/src/main/java/io/legado/app/model/BookCover.kt index 7bed5e820..6dd85348f 100644 --- a/app/src/main/java/io/legado/app/model/BookCover.kt +++ b/app/src/main/java/io/legado/app/model/BookCover.kt @@ -32,28 +32,31 @@ import io.legado.app.help.glide.OkHttpModelLoader import io.legado.app.model.analyzeRule.AnalyzeRule import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext import io.legado.app.model.analyzeRule.AnalyzeUrl +import io.legado.app.domain.usecase.CoverAlbumUseCase import io.legado.app.utils.BitmapUtils import io.legado.app.utils.GSON import io.legado.app.utils.fromJsonObject import kotlinx.coroutines.currentCoroutineContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import splitties.init.appCtx import java.io.File import kotlin.random.Random @Keep -object BookCover { +object BookCover : KoinComponent { private const val coverRuleConfigKey = "legadoCoverRuleConfig" const val configFileName = "coverRule.json" + private val coverAlbumUseCase: CoverAlbumUseCase by inject() val defaultDrawable: Drawable @SuppressLint("UseCompatLoadingForDrawables") get() { val isNightTheme = AppConfig.isNightTheme - val pathStr = if (isNightTheme) CoverConfig.defaultCoverDark else CoverConfig.defaultCover - val paths = pathStr.split(",").filter { it.isNotBlank() } + val paths = coverAlbumUseCase.selectedImagePaths(isNightTheme) - if (paths.isNullOrEmpty()) { + if (paths.isEmpty()) { return appCtx.resources.getDrawable(R.drawable.image_cover_default, null) } @@ -67,9 +70,8 @@ object BookCover { seed: Any? = null, isNight: Boolean = AppConfig.isNightTheme ): String? { - val pathStr = if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover - val paths = pathStr.split(",").filter { it.isNotBlank() } - if (paths.isNullOrEmpty()) return null + val paths = coverAlbumUseCase.selectedImagePaths(isNight) + if (paths.isEmpty()) return null val random = if (seed != null) Random(seed.hashCode()) else Random return paths[random.nextInt(paths.size)] } diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumContract.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumContract.kt new file mode 100644 index 000000000..40c90067c --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumContract.kt @@ -0,0 +1,68 @@ +package io.legado.app.ui.config.coverConfig + +import androidx.compose.runtime.Stable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Stable +data class CoverAlbumItemUi( + val id: String, + val name: String, + val lightImages: ImmutableList = persistentListOf(), + val darkImages: ImmutableList = persistentListOf(), +) + +@Stable +data class CoverAlbumImageUi( + val id: String, + val path: String, +) + +@Stable +data class CoverAlbumSelectionUiState( + val albums: ImmutableList = persistentListOf(), + val selectedAlbumId: String? = null, +) + +@Stable +data class CoverAlbumManageUiState( + val albums: ImmutableList = 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, + ) : 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 +} diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageScreen.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageScreen.kt new file mode 100644 index 000000000..53d61c79d --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageScreen.kt @@ -0,0 +1,448 @@ +package io.legado.app.ui.config.coverConfig + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import io.legado.app.R +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.theme.adaptiveContentPadding +import io.legado.app.ui.widget.components.AppScaffold +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.AppIconButton +import io.legado.app.ui.widget.components.button.series.SmallPlainButton +import io.legado.app.ui.widget.components.card.GlassCard +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.tabRow.CardTabRow +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar +import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults +import io.legado.app.ui.widget.components.topbar.TopBarActionButton +import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton +import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.flow.collectLatest +import org.koin.androidx.compose.koinViewModel + +@Composable +fun CoverAlbumManageRouteScreen( + onBackClick: () -> Unit, + viewModel: CoverAlbumManageViewModel = koinViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + var imageTargetAlbumId by remember { mutableStateOf(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(), + ) + }, + ) +} diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageViewModel.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageViewModel.kt new file mode 100644 index 000000000..27b53f3ba --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageViewModel.kt @@ -0,0 +1,169 @@ +package io.legado.app.ui.config.coverConfig + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.legado.app.domain.model.CoverAlbumImageInput +import io.legado.app.domain.usecase.CoverAlbumUseCase +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class CoverAlbumManageViewModel( + private val context: Context, + private val coverAlbumUseCase: CoverAlbumUseCase, +) : ViewModel() { + + private val editingAlbumId = MutableStateFlow(null) + private val dialog = MutableStateFlow(null) + private val _effects = MutableSharedFlow(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) { + 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 + ) + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumSelectSheet.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumSelectSheet.kt new file mode 100644 index 000000000..1a69f33ab --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumSelectSheet.kt @@ -0,0 +1,165 @@ +package io.legado.app.ui.config.coverConfig + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.outlined.Collections +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import io.legado.app.R +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.button.series.SmallPlainButton +import io.legado.app.ui.widget.components.card.NormalCard +import io.legado.app.ui.widget.components.icon.AppIcon +import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet +import io.legado.app.ui.widget.components.text.AppText + +@Composable +fun CoverAlbumSelectSheet( + show: Boolean, + state: CoverAlbumSelectionUiState, + onSelect: (String?) -> Unit, + onManage: () -> Unit, + onDismissRequest: () -> Unit, +) { + val selectedId = state.selectedAlbumId + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.select_cover_album), + endAction = { + SmallPlainButton( + onClick = onManage, + icon = Icons.Default.Settings, + contentDescription = stringResource(R.string.manage_cover_albums), + ) + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CoverAlbumSelectionItem( + name = stringResource(R.string.cover_album_none), + imagePath = null, + lightImageCount = null, + darkImageCount = null, + selected = selectedId == null, + onClick = { + onSelect(null) + onDismissRequest() + }, + ) + state.albums.forEach { album -> + CoverAlbumSelectionItem( + name = album.name, + imagePath = album.lightImages.firstOrNull()?.path + ?: album.darkImages.firstOrNull()?.path, + lightImageCount = album.lightImages.size, + darkImageCount = album.darkImages.size, + selected = selectedId == album.id, + onClick = { + onSelect(album.id) + onDismissRequest() + }, + ) + } + } + } +} + +@Composable +private fun CoverAlbumSelectionItem( + name: String, + imagePath: String?, + lightImageCount: Int?, + darkImageCount: Int?, + selected: Boolean, + onClick: () -> Unit, +) { + NormalCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + cornerRadius = 16.dp, + containerColor = if (selected) { + LegadoTheme.colorScheme.secondaryContainer + } else { + LegadoTheme.colorScheme.surfaceContainerHigh + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(width = 44.dp, height = 60.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center, + ) { + if (imagePath != null) { + AsyncImage( + model = imagePath, + contentDescription = null, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } else { + AppIcon( + imageVector = Icons.Outlined.Collections, + contentDescription = null, + tint = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + } + Column(modifier = Modifier.weight(1f)) { + AppText( + text = name, + style = LegadoTheme.typography.titleSmall, + maxLines = 1, + ) + if (lightImageCount != null && darkImageCount != null) { + AppText( + text = stringResource( + R.string.cover_album_day_night_count, + lightImageCount, + darkImageCount, + ), + style = LegadoTheme.typography.bodySmall, + color = LegadoTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (selected) { + AppIcon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = LegadoTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt index 6ab78f78a..5ea185d8b 100644 --- a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt @@ -20,8 +20,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R -import io.legado.app.constant.PreferKey import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.SplicedColumnGroup @@ -38,11 +38,15 @@ import org.koin.androidx.compose.koinViewModel @Composable fun CoverConfigScreen( onBackClick: () -> Unit, + onNavigateToCoverAlbums: () -> Unit, viewModel: CoverConfigViewModel = koinViewModel() ) { val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() + val albumState by viewModel.albumState.collectAsStateWithLifecycle() + val selectedAlbum = albumState.albums + .firstOrNull { it.id == albumState.selectedAlbumId } var showCoverRuleSheet by remember { mutableStateOf(false) } - var manageKey by remember { mutableStateOf(null) } + var showAlbumSelect by remember { mutableStateOf(false) } var showColorPickerByField by remember { mutableStateOf(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 }, ) } diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt index 6aac53108..a8580c01f 100644 --- a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigViewModel.kt @@ -1,114 +1,36 @@ package io.legado.app.ui.config.coverConfig -import android.content.Context -import android.net.Uri -import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel -import io.legado.app.constant.AppLog -import io.legado.app.constant.PreferKey -import io.legado.app.exception.NoStackTraceException -import io.legado.app.lib.permission.Permissions -import io.legado.app.lib.permission.PermissionsCompat -import io.legado.app.utils.FileDoc -import io.legado.app.utils.FileUtils -import io.legado.app.utils.MD5Utils -import io.legado.app.utils.RealPathUtil -import io.legado.app.utils.externalFiles -import io.legado.app.utils.isContentScheme -import io.legado.app.utils.printOnDebug -import io.legado.app.utils.toastOnUi -import splitties.init.appCtx -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.InputStream +import androidx.lifecycle.viewModelScope +import io.legado.app.domain.usecase.CoverAlbumUseCase +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch -class CoverConfigViewModel : ViewModel() { +class CoverConfigViewModel( + private val coverAlbumUseCase: CoverAlbumUseCase, +) : ViewModel() { - fun addCoverFromUri(preferenceKey: String, uris: List) { - 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(), +) diff --git a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt b/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt deleted file mode 100644 index c6a3a3ebe..000000000 --- a/app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt +++ /dev/null @@ -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") - ) -} diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt index 2f9bfa24b..c830503a7 100644 --- a/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp import io.legado.app.R import io.legado.app.help.config.SavedTheme import io.legado.app.help.config.ThemeImportExport +import io.legado.app.help.config.ThemePackageManager import io.legado.app.ui.theme.adaptiveContentPadding import io.legado.app.ui.widget.components.AppScaffold import io.legado.app.ui.widget.components.AppTextField @@ -56,11 +58,14 @@ import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton import io.legado.app.utils.restart import io.legado.app.utils.toastOnUi +import kotlinx.coroutines.flow.collectLatest +import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun ThemeManageScreen( - onBackClick: () -> Unit + onBackClick: () -> Unit, + viewModel: ThemeManageViewModel = koinViewModel(), ) { val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() val context = LocalContext.current @@ -76,34 +81,53 @@ fun ThemeManageScreen( val savedThemes = remember(savedThemesVersion) { ThemeImportExport.savedThemes.toList() } val exportLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/json") + ActivityResultContracts.CreateDocument("application/zip") ) { uri -> uri?.let { val target = exportTarget - if (target != null) { - exportTarget = null - if (ThemeImportExport.exportSavedThemeToFile(context, target, it)) { - context.toastOnUi(R.string.theme_manage_export_success) - } else { - context.toastOnUi(R.string.theme_manage_export_failed) - } - } else if (ThemeImportExport.exportToFile(context, it)) { - context.toastOnUi(R.string.theme_manage_export_success) - } else { - context.toastOnUi(R.string.theme_manage_export_failed) - } + exportTarget = null + viewModel.onIntent( + ThemeManageIntent.ExportPackage( + uri = it.toString(), + themeName = target?.name, + themeData = target?.data, + ) + ) } } - val importLauncher = rememberLauncherForActivityResult( + val importPackageLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocument() ) { uri -> uri?.let { - if (ThemeImportExport.importFromUri(context, it)) { - context.toastOnUi(R.string.theme_manage_import_success) - showRestartDialog = true - } else { - context.toastOnUi(R.string.theme_manage_import_failed) + viewModel.onIntent(ThemeManageIntent.ImportPackage(it.toString())) + } + } + + val importLegacyLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { + viewModel.onIntent(ThemeManageIntent.ImportLegacyJson(it.toString())) + } + } + + LaunchedEffect(Unit) { + viewModel.effects.collectLatest { effect -> + when (effect) { + is ThemeManageEffect.ShowResult -> { + val message = buildString { + append(context.getString(effect.messageRes)) + effect.detail?.takeIf(String::isNotBlank)?.let { + append('\n') + append(it) + } + } + context.toastOnUi(message) + if (effect.restartRequired) { + showRestartDialog = true + } + } } } } @@ -145,14 +169,26 @@ fun ThemeManageScreen( description = stringResource(R.string.theme_manage_export_current_summary), onClick = { exportTarget = null - exportLauncher.launch("legado_theme_${System.currentTimeMillis()}.json") + exportLauncher.launch( + "materado_theme_${System.currentTimeMillis()}." + + ThemePackageManager.FILE_EXTENSION + ) + } + ) + ClickableSettingItem( + title = stringResource(R.string.theme_manage_import_package), + description = stringResource(R.string.theme_manage_import_package_summary), + onClick = { + importPackageLauncher.launch( + arrayOf("application/zip", "application/octet-stream") + ) } ) ClickableSettingItem( title = stringResource(R.string.theme_manage_import_config), description = stringResource(R.string.theme_manage_import_config_summary), onClick = { - importLauncher.launch(arrayOf("application/json")) + importLegacyLauncher.launch(arrayOf("application/json", "text/json")) } ) } @@ -175,7 +211,9 @@ fun ThemeManageScreen( onEdit = { editTarget = theme }, onExport = { exportTarget = theme - exportLauncher.launch("${theme.name}.json") + exportLauncher.launch( + "${theme.name}.${ThemePackageManager.FILE_EXTENSION}" + ) }, onDelete = { deleteTarget = theme } ) diff --git a/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt new file mode 100644 index 000000000..dce3b3e4a --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/config/themeManage/ThemeManageViewModel.kt @@ -0,0 +1,95 @@ +package io.legado.app.ui.config.themeManage + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.legado.app.R +import io.legado.app.help.config.ThemeExportData +import io.legado.app.help.config.ThemePackageManager +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +class ThemeManageViewModel( + private val themePackageManager: ThemePackageManager, +) : ViewModel() { + + private val _effects = MutableSharedFlow(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) { + _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 +} diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 5fd360f48..c7ee1fccf 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -51,6 +51,7 @@ import io.legado.app.ui.config.ai.AiConfigRouteScreen import io.legado.app.ui.config.ai.AiModelEditRouteScreen import io.legado.app.ui.config.ai.AiProviderEditRouteScreen import io.legado.app.ui.config.backupConfig.BackupConfigScreen +import io.legado.app.ui.config.coverConfig.CoverAlbumManageRouteScreen import io.legado.app.ui.config.coverConfig.CoverConfigScreen import io.legado.app.ui.config.customTheme.CustomThemeScreen import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen @@ -207,7 +208,16 @@ fun MainActivity.mainEntryProvider( } entry { - CoverConfigScreen(onBackClick = { onNavigateBack() }) + CoverConfigScreen( + onBackClick = { onNavigateBack() }, + onNavigateToCoverAlbums = { + backStack.add(MainRouteSettingsCoverAlbums) + }, + ) + } + + entry { + CoverAlbumManageRouteScreen(onBackClick = { onNavigateBack() }) } entry { diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt index 97b5690e5..c43400fa0 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt @@ -21,6 +21,9 @@ data object MainRouteSettingsRead : MainRoute @Serializable data object MainRouteSettingsCover : MainRoute +@Serializable +data object MainRouteSettingsCoverAlbums : MainRoute + @Serializable data object MainRouteSettingsTheme : MainRoute @@ -133,6 +136,7 @@ object MainRouteConst { const val ROUTE_SETTINGS_OTHER = "settings/other" const val ROUTE_SETTINGS_READ = "settings/read" const val ROUTE_SETTINGS_COVER = "settings/cover" + const val ROUTE_SETTINGS_COVER_ALBUMS = "settings/cover/albums" const val ROUTE_SETTINGS_THEME = "settings/theme" const val ROUTE_SETTINGS_BACKUP = "settings/backup" const val ROUTE_SETTINGS_AI = "settings/ai" diff --git a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt index 478a57085..10f0a78e4 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt @@ -74,8 +74,10 @@ fun BookCoverImage( val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover val finalPath = if (useDefault) null else path + val defaultCoverPaths = + if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover - val randomPath = remember(name, author, path, isNight) { + val randomPath = remember(name, author, path, isNight, defaultCoverPaths) { BookCoverModel.getRandomDefaultPath( seed = name ?: author ?: path ?: "", isNight = isNight @@ -164,8 +166,10 @@ fun CoilBookCover( val useDefault = !ignoreUseDefaultCover && CoverConfig.useDefaultCover val finalPath = if (useDefault) null else path + val defaultCoverPaths = + if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover - val randomPath = remember(name, author, path, isNight) { + val randomPath = remember(name, author, path, isNight, defaultCoverPaths) { BookCoverModel.getRandomDefaultPath( seed = name ?: author ?: path ?: "", isNight = isNight diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 9b1ec9463..98d0d4978 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1439,9 +1439,11 @@ 保存当前设置 保存当前主题配置为新主题 导出当前主题 - 将当前主题配置导出为 JSON 文件 - 导入主题配置 - 从 JSON 文件导入主题配置 + 将配置、图片、字体和当前图集导出为主题包 + 导入主题包 + 导入包含资源和封面图集的主题包 + 导入旧版主题配置 + 从旧版 JSON 文件导入主题配置 已保存的主题 保存主题 保存 @@ -2217,4 +2219,20 @@ 当前环境光较暗,是否切换至深色模式? 当前环境光较亮,是否切换至浅色模式? 切换 + 封面图集 + 管理封面图集 + 选择封面图集 + 应用内置封面 + 图集名称 + 暂无封面图集 + 新建封面图集 + 重命名封面图集 + 删除封面图集 + 删除“%1$s”及其中的全部图片? + 添加图片 + 默认日间封面 + 默认夜间封面 + %1$s(导入) + 日间 %1$d · 夜间 %2$d + 当前使用 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index a96fdb1a0..a31462054 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -2105,11 +2105,13 @@ 刪除主題 編輯主題 匯出目前主題 - 將目前主題設定匯出為 JSON 檔案 + 將設定、圖片、字體和目前圖集匯出為主題包 主題匯出失敗 主題已匯出 - 匯入主題檔案 - 從 JSON 檔案匯入主題設定 + 匯入主題包 + 匯入包含資源和封面圖集的主題包 + 匯入舊版主題設定 + 從舊版 JSON 檔案匯入主題設定 主題匯入失敗 主題已匯入。部分更改需要重啟。 標籤容器色 @@ -2209,4 +2211,20 @@ 當前環境光較暗,是否切換至深色模式? 當前環境光較亮,是否切換至淺色模式? 切換 + 封面圖集 + 管理封面圖集 + 選擇封面圖集 + 應用程式內置封面 + 圖集名稱 + 暫無封面圖集 + 新增封面圖集 + 重新命名封面圖集 + 刪除封面圖集 + 刪除「%1$s」及其中的全部圖片? + 加入圖片 + 預設日間封面 + 預設夜間封面 + %1$s(匯入) + 日間 %1$d · 夜間 %2$d + 目前使用 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 377652723..a30883bcd 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1586,9 +1586,11 @@ 儲存目前主題 將目前外觀設定儲存為主題 匯出目前主題 - 將目前主題設定匯出為 JSON 檔案 - 匯入主題檔案 - 從 JSON 檔案匯入主題設定 + 將設定、圖片、字型和目前圖集匯出為主題包 + 匯入主題包 + 匯入包含資源和封面圖集的主題包 + 匯入舊版主題設定 + 從舊版 JSON 檔案匯入主題設定 已儲存的主題 儲存主題 儲存 @@ -2214,4 +2216,20 @@ 目前環境光較暗,是否切換至深色模式? 目前環境光較亮,是否切換至淺色模式? 切換 + 封面圖集 + 管理封面圖集 + 選擇封面圖集 + 應用程式內建封面 + 圖集名稱 + 暫無封面圖集 + 新增封面圖集 + 重新命名封面圖集 + 刪除封面圖集 + 刪除「%1$s」及其中的全部圖片? + 加入圖片 + 預設日間封面 + 預設夜間封面 + %1$s(匯入) + 日間 %1$d · 夜間 %2$d + 目前使用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fce5ac0fa..d543b25f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1474,9 +1474,11 @@ Save Current Theme Save the current appearance settings as a theme Export Current Theme - Export the current theme settings as a JSON file - Import Theme File - Import theme settings from a JSON file + Export settings, assets, fonts, and active cover albums as a theme package + Import theme package + Import a theme package with its assets and cover albums + Import legacy theme configuration + Import a legacy JSON theme configuration Saved Themes Save Theme Save @@ -2222,4 +2224,20 @@ Ambient light is low. Switch to dark mode? Ambient light is bright. Switch to light mode? Switch + Cover albums + Manage cover albums + Select cover album + Built-in default cover + Album name + No cover albums + Create cover album + Rename cover album + Delete cover album + Delete “%1$s” and all images in it? + Add images + Default light cover + Default dark cover + %1$s (Imported) + Light %1$d · Dark %2$d + Currently selected