feat: release QieKan 3.26.14 privacy mode
Auto Release / prepare (push) Canceled after 0s
Auto Release / build (app, release) (push) Canceled after 0s
Auto Release / create_release (push) Canceled after 0s
CodeQL / Analyze (java-kotlin) (java-kotlin) (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 00:18:46 +08:00
parent bf73f84b71
commit a6b88cff62
51 changed files with 4052 additions and 212 deletions
+17 -1
View File
@@ -51,7 +51,7 @@ android {
applicationId = "io.legato.kazusa" applicationId = "io.legato.kazusa"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = System.getenv("COMMIT_NUMBER")?.toInt()?.let { 10000 + it } ?: 32640 versionCode = System.getenv("COMMIT_NUMBER")?.toInt()?.let { 10000 + it } ?: 32641
versionName = System.getenv("APP_VERSION_NAME") ?: projectVersionName versionName = System.getenv("APP_VERSION_NAME") ?: projectVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -152,6 +152,22 @@ android {
} }
} }
androidComponents {
onVariants(selector().all()) { variant ->
variant.outputs.forEach { output ->
val abi = output.filters
.firstOrNull { it.filterType.name == "ABI" }
?.identifier
?: "universal"
output.outputFileName.set(
output.versionName.zip(output.versionCode) { versionName, versionCode ->
"QieKan-$abi-$versionName-vc$versionCode.apk"
}
)
}
}
}
kotlin { kotlin {
jvmToolchain { jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21)) languageVersion.set(JavaLanguageVersion.of(21))
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
<resources> <resources>
<string name="app_name">阅读·D</string> <string name="app_name">且看</string>
<string name="receiving_shared_label">阅读·D·搜索</string> <string name="receiving_shared_label">且看·搜索</string>
</resources> </resources>
+2 -2
View File
@@ -1,4 +1,4 @@
<resources> <resources>
<string name="app_name">legado·D</string> <string name="app_name">且看</string>
<string name="receiving_shared_label">legado·D·search</string> <string name="receiving_shared_label">且看·搜索</string>
</resources> </resources>
@@ -11,6 +11,7 @@ object PreferKey {
const val showUnread = "showUnread" const val showUnread = "showUnread"
const val showUnreadNew = "showUnreadNew" const val showUnreadNew = "showUnreadNew"
const val bookGroupStyle = "bookGroupStyle" const val bookGroupStyle = "bookGroupStyle"
const val bookshelfPrivacyMode = "bookshelfPrivacyMode"
const val useDefaultCover = "useDefaultCover" const val useDefaultCover = "useDefaultCover"
const val loadCoverOnlyWifi = "loadCoverOnlyWifi" const val loadCoverOnlyWifi = "loadCoverOnlyWifi"
const val coverShowName = "coverShowName" const val coverShowName = "coverShowName"
@@ -90,7 +90,7 @@ val appDb by lazy {
} }
@Database( @Database(
version = 93, version = 94,
exportSchema = true, exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class, entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class, ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -153,7 +153,8 @@ val appDb by lazy {
AutoMigration(from = 89, to = 90), AutoMigration(from = 89, to = 90),
AutoMigration(from = 90, to = 91), AutoMigration(from = 90, to = 91),
AutoMigration(from = 91, to = 92), AutoMigration(from = 91, to = 92),
AutoMigration(from = 92, to = 93) AutoMigration(from = 92, to = 93),
AutoMigration(from = 93, to = 94, spec = DatabaseMigrations.Migration_93_94::class)
] ]
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -496,4 +496,35 @@ object DatabaseMigrations {
columnName = "enabledReview" columnName = "enabledReview"
) )
class Migration_64_65 : AutoMigrationSpec class Migration_64_65 : AutoMigrationSpec
@Suppress("ClassName")
class Migration_93_94 : AutoMigrationSpec {
override fun onPostMigrate(db: SupportSQLiteDatabase) {
backfillBookUrl(db, "readRecord")
backfillBookUrl(db, "readRecordDetail")
backfillBookUrl(db, "readRecordSession")
}
private fun backfillBookUrl(db: SupportSQLiteDatabase, tableName: String) {
db.execSQL(
"""
UPDATE `$tableName`
SET bookUrl = (
SELECT MIN(book.bookUrl)
FROM books AS book
WHERE TRIM(book.name) = TRIM(`$tableName`.bookName)
AND (`$tableName`.bookAuthor = '' OR book.author = `$tableName`.bookAuthor)
)
WHERE bookUrl IS NULL
AND 1 = (
SELECT COUNT(*)
FROM books AS book
WHERE TRIM(book.name) = TRIM(`$tableName`.bookName)
AND (`$tableName`.bookAuthor = '' OR book.author = `$tableName`.bookAuthor)
)
""".trimIndent()
)
}
}
} }
@@ -22,11 +22,14 @@ data class GroupBookCount(
val count: Int val count: Int
) )
private const val USER_GROUP_ID_FILTER =
"(groupId > 0 OR groupId = ${Long.MIN_VALUE})"
private const val PRIVATE_GROUP_MASK = private const val PRIVATE_GROUP_MASK =
"(SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0 AND isPrivate = 1)" "(SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE $USER_GROUP_ID_FILTER AND isPrivate = 1)"
private const val PUBLIC_GROUP_MASK = private const val PUBLIC_GROUP_MASK =
"(SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0 AND isPrivate = 0)" "(SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE $USER_GROUP_ID_FILTER AND isPrivate = 0)"
private const val PUBLIC_BOOK_FILTER = private const val PUBLIC_BOOK_FILTER =
"(`group` = 0 OR (`group` & $PRIVATE_GROUP_MASK) = 0)" "(`group` = 0 OR (`group` & $PRIVATE_GROUP_MASK) = 0)"
@@ -162,6 +165,21 @@ interface BookDao {
) )
fun flowBookShelf(): Flow<List<BookShelfItem>> fun flowBookShelf(): Flow<List<BookShelfItem>>
@Query(
"""
SELECT
bookUrl, name, author, origin, originName,
coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
durChapterPos, latestChapterTitle, latestChapterTime,
lastCheckCount, totalChapterNum, durChapterIndex,
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind, wordCount
FROM books
ORDER BY durChapterTime DESC
"""
)
fun flowBookShelfIncludingPrivate(): Flow<List<BookShelfItem>>
@Query("SELECT * FROM books WHERE type & ${BookType.audio} > 0") @Query("SELECT * FROM books WHERE type & ${BookType.audio} > 0")
fun flowAudio(): Flow<List<Book>> fun flowAudio(): Flow<List<Book>>
@@ -235,7 +253,7 @@ interface BookDao {
@Query( @Query(
""" """
select * from books where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0 select * from books where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0
and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0 or groupId = ${Long.MIN_VALUE}) & `group`) = 0
""" """
) )
fun flowNetNoGroup(): Flow<List<Book>> fun flowNetNoGroup(): Flow<List<Book>>
@@ -276,7 +294,7 @@ interface BookDao {
@Query( @Query(
""" """
select * from books where type & ${BookType.local} > 0 select * from books where type & ${BookType.local} > 0
and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0 and ((SELECT sum(groupId) FROM book_groups where groupId > 0 or groupId = ${Long.MIN_VALUE}) & `group`) = 0
""" """
) )
fun flowLocalNoGroup(): Flow<List<Book>> fun flowLocalNoGroup(): Flow<List<Book>>
@@ -314,7 +332,7 @@ interface BookDao {
) )
fun flowBookShelfLocalNoGroup(): Flow<List<BookShelfItem>> fun flowBookShelfLocalNoGroup(): Flow<List<BookShelfItem>>
@Query("SELECT * FROM books WHERE (`group` & :group) > 0") @Query("SELECT * FROM books WHERE (`group` & :group) != 0")
fun flowByUserGroup(group: Long): Flow<List<Book>> fun flowByUserGroup(group: Long): Flow<List<Book>>
@Query( @Query(
@@ -343,7 +361,7 @@ interface BookDao {
kind, kind,
wordCount wordCount
FROM books FROM books
WHERE (`group` & :group) > 0 WHERE (`group` & :group) != 0
AND ((SELECT isPrivate FROM book_groups WHERE groupId = :group) = 1 OR $PUBLIC_BOOK_FILTER) AND ((SELECT isPrivate FROM book_groups WHERE groupId = :group) = 1 OR $PUBLIC_BOOK_FILTER)
""" """
) )
@@ -671,7 +689,7 @@ interface BookDao {
) )
fun flowBookShelfText(): Flow<List<BookShelfItem>> fun flowBookShelfText(): Flow<List<BookShelfItem>>
@Query("SELECT * FROM books WHERE (`group` & :group) > 0") @Query("SELECT * FROM books WHERE (`group` & :group) != 0")
fun getBooksByGroup(group: Long): List<Book> fun getBooksByGroup(group: Long): List<Book>
@Query("SELECT * FROM books WHERE `name` in (:names)") @Query("SELECT * FROM books WHERE `name` in (:names)")
@@ -703,6 +721,15 @@ interface BookDao {
@Query("SELECT * FROM books WHERE name = :name and author = :author") @Query("SELECT * FROM books WHERE name = :name and author = :author")
fun getBook(name: String, author: String): Book? fun getBook(name: String, author: String): Book?
@Query(
"""
SELECT * FROM books
WHERE name = :name AND author = :author
ORDER BY durChapterTime DESC, bookUrl ASC
"""
)
fun getBooks(name: String, author: String): List<Book>
@Query("""select distinct bs.* from books, book_sources bs @Query("""select distinct bs.* from books, book_sources bs
where origin == bookSourceUrl and origin not like '${BookType.localTag}%' where origin == bookSourceUrl and origin not like '${BookType.localTag}%'
and origin not like '${BookType.webDavTag}%'""") and origin not like '${BookType.webDavTag}%'""")
@@ -774,7 +801,7 @@ interface BookDao {
@Query("update books set `group` = :newGroupId where `group` = :oldGroupId") @Query("update books set `group` = :newGroupId where `group` = :oldGroupId")
fun upGroup(oldGroupId: Long, newGroupId: Long) fun upGroup(oldGroupId: Long, newGroupId: Long)
@Query("update books set `group` = `group` - :group where `group` & :group > 0") @Query("update books set `group` = `group` & ~:group where `group` & :group != 0")
fun removeGroup(group: Long) fun removeGroup(group: Long)
@Query("delete from books where type & ${BookType.notShelf} > 0") @Query("delete from books where type & ${BookType.notShelf} > 0")
@@ -818,7 +845,7 @@ interface BookDao {
@Query( @Query(
""" """
SELECT COUNT(*) FROM books SELECT COUNT(*) FROM books
WHERE (`group` & :groupId) > 0 WHERE (`group` & :groupId) != 0
AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER) AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER)
""" """
) )
@@ -1096,7 +1123,7 @@ interface BookDao {
type, `group`, `order`, canUpdate, type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind, wordCount ifnull(customIntro, intro) as intro, kind, wordCount
FROM books FROM books
WHERE (`group` & :groupId) > 0 WHERE (`group` & :groupId) != 0
AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER) AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER)
ORDER BY durChapterTime DESC ORDER BY durChapterTime DESC
LIMIT 10 LIMIT 10
@@ -27,16 +27,16 @@ interface BookGroupDao {
""" """
with const as ( with const as (
SELECT SELECT
COALESCE(SUM(CASE WHEN groupId > 0 AND isPrivate = 0 THEN groupId ELSE 0 END), 0) sumPublicGroupId, COALESCE(SUM(CASE WHEN (groupId > 0 OR groupId = ${Long.MIN_VALUE}) AND isPrivate = 0 THEN groupId ELSE 0 END), 0) sumPublicGroupId,
COALESCE(SUM(CASE WHEN groupId > 0 AND isPrivate = 1 THEN groupId ELSE 0 END), 0) sumPrivateGroupId COALESCE(SUM(CASE WHEN (groupId > 0 OR groupId = ${Long.MIN_VALUE}) AND isPrivate = 1 THEN groupId ELSE 0 END), 0) sumPrivateGroupId
FROM book_groups FROM book_groups
) )
SELECT book_groups.* FROM book_groups join const SELECT book_groups.* FROM book_groups join const
where show > 0 where show > 0
and ( and (
(groupId >= 0 and exists ( ((groupId >= 0 or groupId = ${Long.MIN_VALUE}) and exists (
select 1 from books select 1 from books
where `group` & book_groups.groupId > 0 where `group` & book_groups.groupId != 0
and (book_groups.isPrivate = 1 or `group` = 0 or (const.sumPrivateGroupId & `group`) = 0) and (book_groups.isPrivate = 1 or `group` = 0 or (const.sumPrivateGroupId & `group`) = 0)
)) ))
or groupId = ${BookGroup.IdAll} or groupId = ${BookGroup.IdAll}
@@ -124,13 +124,13 @@ interface BookGroupDao {
) )
val show: LiveData<List<BookGroup>> val show: LiveData<List<BookGroup>>
@Query("SELECT * FROM book_groups where groupId >= 0 ORDER BY `order`") @Query("SELECT * FROM book_groups where groupId >= 0 or groupId = ${Long.MIN_VALUE} ORDER BY `order`")
fun flowSelect(): Flow<List<BookGroup>> fun flowSelect(): Flow<List<BookGroup>>
@get:Query("SELECT sum(groupId) FROM book_groups where groupId >= 0") @get:Query("SELECT sum(groupId) FROM book_groups where groupId >= 0 or groupId = ${Long.MIN_VALUE}")
val idsSum: Long val idsSum: Long
@get:Query("SELECT MAX(`order`) FROM book_groups where groupId >= 0") @get:Query("SELECT MAX(`order`) FROM book_groups where groupId >= 0 or groupId = ${Long.MIN_VALUE}")
val maxOrder: Int val maxOrder: Int
@get:Query("SELECT * FROM book_groups ORDER BY `order`") @get:Query("SELECT * FROM book_groups ORDER BY `order`")
@@ -142,7 +142,7 @@ interface BookGroupDao {
@Query("update book_groups set show = 1 where groupId = :groupId") @Query("update book_groups set show = 1 where groupId = :groupId")
fun enableGroup(groupId: Long) fun enableGroup(groupId: Long)
@Query("select groupName from book_groups where groupId > 0 and (groupId & :id) > 0") @Query("select groupName from book_groups where (groupId > 0 or groupId = ${Long.MIN_VALUE}) and (groupId & :id) != 0")
fun getGroupNames(id: Long): List<String> fun getGroupNames(id: Long): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -5,6 +5,7 @@ import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update import androidx.room.Update
import io.legado.app.data.entities.readRecord.HomeRecentBookRow import io.legado.app.data.entities.readRecord.HomeRecentBookRow
import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
@@ -44,17 +45,26 @@ interface ReadRecordDao {
""" """
WITH recent AS ( WITH recent AS (
SELECT SELECT
bookName, base.bookName,
bookAuthor, base.bookAuthor,
MAX(lastRead) AS lastRead (
FROM readRecord SELECT latest.bookUrl
GROUP BY bookName, bookAuthor FROM readRecord AS latest
ORDER BY lastRead DESC WHERE latest.bookName = base.bookName
LIMIT :limit AND latest.bookAuthor = base.bookAuthor
AND latest.bookUrl IS NOT NULL
AND latest.bookUrl != ''
ORDER BY latest.lastRead DESC, latest.deviceId ASC
LIMIT 1
) AS recordBookUrl,
MAX(base.lastRead) AS lastRead
FROM readRecord AS base
GROUP BY base.bookName, base.bookAuthor
) )
SELECT SELECT
recent.bookName AS recordName, recent.bookName AS recordName,
recent.bookAuthor AS recordAuthor, recent.bookAuthor AS recordAuthor,
recent.recordBookUrl AS recordBookUrl,
book.bookUrl AS bookUrl, book.bookUrl AS bookUrl,
book.origin AS origin, book.origin AS origin,
book.coverUrl AS coverUrl, book.coverUrl AS coverUrl,
@@ -63,7 +73,14 @@ interface ReadRecordDao {
book.totalChapterNum AS totalChapterNum, book.totalChapterNum AS totalChapterNum,
book.durChapterIndex AS chapterIndex book.durChapterIndex AS chapterIndex
FROM recent FROM recent
LEFT JOIN books AS book ON book.bookUrl = ( LEFT JOIN books AS book ON book.bookUrl = COALESCE(
(
SELECT currentBook.bookUrl
FROM books AS currentBook
WHERE currentBook.bookUrl = recent.recordBookUrl
LIMIT 1
),
(
SELECT candidate.bookUrl SELECT candidate.bookUrl
FROM books AS candidate FROM books AS candidate
WHERE candidate.name = recent.bookName WHERE candidate.name = recent.bookName
@@ -71,10 +88,11 @@ interface ReadRecordDao {
ORDER BY candidate.durChapterTime DESC, candidate.bookUrl ASC ORDER BY candidate.durChapterTime DESC, candidate.bookUrl ASC
LIMIT 1 LIMIT 1
) )
)
ORDER BY recent.lastRead DESC ORDER BY recent.lastRead DESC
""" """
) )
fun observeRecentHomeBooks(limit: Int): Flow<List<HomeRecentBookRow>> fun observeRecentHomeBooks(): Flow<List<HomeRecentBookRow>>
@Query("select sum(readTime) from readRecord where bookName = :bookName") @Query("select sum(readTime) from readRecord where bookName = :bookName")
fun getReadTime(bookName: String): Long? fun getReadTime(bookName: String): Long?
@@ -85,6 +103,33 @@ interface ReadRecordDao {
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor") @Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
suspend fun getReadRecord(deviceId: String, bookName: String, bookAuthor: String): ReadRecord? suspend fun getReadRecord(deviceId: String, bookName: String, bookAuthor: String): ReadRecord?
@Query(
"""
SELECT bookUrl FROM books
WHERE TRIM(name) = TRIM(:bookName)
AND (:bookAuthor = '' OR author = :bookAuthor)
ORDER BY durChapterTime DESC, bookUrl ASC
LIMIT 2
"""
)
suspend fun findMatchingBookUrls(bookName: String, bookAuthor: String): List<String>
@Query("UPDATE readRecord SET bookUrl = :newBookUrl WHERE bookUrl = :oldBookUrl")
fun replaceReadRecordBookUrl(oldBookUrl: String, newBookUrl: String)
@Query("UPDATE readRecordDetail SET bookUrl = :newBookUrl WHERE bookUrl = :oldBookUrl")
fun replaceReadRecordDetailBookUrl(oldBookUrl: String, newBookUrl: String)
@Query("UPDATE readRecordSession SET bookUrl = :newBookUrl WHERE bookUrl = :oldBookUrl")
fun replaceReadRecordSessionBookUrl(oldBookUrl: String, newBookUrl: String)
@Transaction
fun replaceBookUrl(oldBookUrl: String, newBookUrl: String) {
replaceReadRecordBookUrl(oldBookUrl, newBookUrl)
replaceReadRecordDetailBookUrl(oldBookUrl, newBookUrl)
replaceReadRecordSessionBookUrl(oldBookUrl, newBookUrl)
}
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg readRecord: ReadRecord) suspend fun insert(vararg readRecord: ReadRecord)
@@ -3,6 +3,7 @@ package io.legado.app.data.entities.readRecord
data class HomeRecentBookRow( data class HomeRecentBookRow(
val recordName: String, val recordName: String,
val recordAuthor: String, val recordAuthor: String,
val recordBookUrl: String?,
val bookUrl: String?, val bookUrl: String?,
val origin: String?, val origin: String?,
val coverUrl: String?, val coverUrl: String?,
@@ -9,6 +9,7 @@ data class ReadRecord(
var bookName: String = "", var bookName: String = "",
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
var bookAuthor: String = "", var bookAuthor: String = "",
var bookUrl: String? = null,
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
var readTime: Long = 0L, var readTime: Long = 0L,
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
@@ -12,6 +12,7 @@ data class ReadRecordDetail(
val bookName: String = "", val bookName: String = "",
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
val bookAuthor: String = "", val bookAuthor: String = "",
val bookUrl: String? = null,
val date: String = "", val date: String = "",
// 当天阅读总时长 // 当天阅读总时长
@@ -13,6 +13,7 @@ data class ReadRecordSession(
val bookName: String = "", val bookName: String = "",
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
val bookAuthor: String = "", val bookAuthor: String = "",
val bookUrl: String? = null,
// 一次阅读的开始/结束 // 一次阅读的开始/结束
val startTime: Long = 0, val startTime: Long = 0,
@@ -19,6 +19,7 @@ import io.legado.app.help.book.ContentProcessor
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.MD5Utils import io.legado.app.utils.MD5Utils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class AiToolRepository( class AiToolRepository(
@@ -27,7 +28,8 @@ class AiToolRepository(
private val bookmarkDao: BookmarkDao, private val bookmarkDao: BookmarkDao,
private val readRecordDao: ReadRecordDao, private val readRecordDao: ReadRecordDao,
private val aiArtifactDao: AiArtifactDao, private val aiArtifactDao: AiArtifactDao,
private val aiMemoryGateway: AiMemoryGateway private val aiMemoryGateway: AiMemoryGateway,
private val bookPrivacyRepository: BookPrivacyRepository,
) : AiToolGateway { ) : AiToolGateway {
override fun availableTools(): List<AiToolDefinition> = tools override fun availableTools(): List<AiToolDefinition> = tools
@@ -175,11 +177,15 @@ class AiToolRepository(
return GSON.toJson(mapOf("bookmarks" to bookmarks)) return GSON.toJson(mapOf("bookmarks" to bookmarks))
} }
private fun getReadingStats(args: JsonObject): String { private suspend fun getReadingStats(args: JsonObject): String {
val query = args.string("query").orEmpty().trim() val query = args.string("query").orEmpty().trim()
val date = args.string("date")?.trim().orEmpty() val date = args.string("date")?.trim().orEmpty()
val limit = args.int("limit", 10).coerceIn(1, 30) val limit = args.int("limit", 10).coerceIn(1, 30)
val records = readRecordDao.all val privacyState = bookPrivacyRepository.observe().first()
val visibleRecords = readRecordDao.all.filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor)
}
val records = visibleRecords
.asSequence() .asSequence()
.filter { .filter {
query.isBlank() || query.isBlank() ||
@@ -200,6 +206,7 @@ class AiToolRepository(
val dailyDetails = readRecordDao.allDetail val dailyDetails = readRecordDao.allDetail
.asSequence() .asSequence()
.filter { .filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) &&
(date.isBlank() || it.date == date) && (date.isBlank() || it.date == date) &&
(query.isBlank() || (query.isBlank() ||
it.bookName.contains(query, ignoreCase = true) || it.bookName.contains(query, ignoreCase = true) ||
@@ -222,7 +229,7 @@ class AiToolRepository(
.toList() .toList()
return GSON.toJson( return GSON.toJson(
mapOf( mapOf(
"totalReadTimeMillis" to readRecordDao.all.sumOf { it.readTime }, "totalReadTimeMillis" to visibleRecords.sumOf { it.readTime },
"recentRecords" to records, "recentRecords" to records,
"dailyDetails" to dailyDetails "dailyDetails" to dailyDetails
) )
@@ -0,0 +1,102 @@
package io.legado.app.data.repository
import io.legado.app.constant.PreferKey
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.BookGroupDao
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import java.text.Normalizer
import java.util.Locale
data class BookRecordKey(
val name: String,
val author: String,
)
data class BookPrivacyState(
val privacyModeEnabled: Boolean,
val privateGroupMask: Long,
val visibleBookUrls: Set<String>,
val visibleBookKeys: Set<BookRecordKey>,
val visibleBookNames: Set<String>,
val hiddenBookUrls: Set<String>,
val hiddenBookKeys: Set<BookRecordKey>,
val hiddenBookNames: Set<String>,
) {
fun isReadRecordVisible(
bookUrl: String?,
bookName: String,
bookAuthor: String = "",
): Boolean {
if (!privacyModeEnabled) return true
if (!bookUrl.isNullOrBlank()) {
if (bookUrl in hiddenBookUrls) return false
if (bookUrl in visibleBookUrls) return true
}
val normalizedName = normalizeBookName(bookName)
val normalizedAuthor = normalizeBookName(bookAuthor)
if (normalizedAuthor.isNotEmpty()) {
val key = BookRecordKey(normalizedName, normalizedAuthor)
if (key in hiddenBookKeys) return false
if (key in visibleBookKeys) return true
}
return normalizedName in visibleBookNames && normalizedName !in hiddenBookNames
}
companion object {
fun create(
books: List<Book>,
groups: List<BookGroup>,
privacyModeEnabled: Boolean,
): BookPrivacyState {
val privateGroupMask = groups.asSequence()
.filter {
(it.groupId > 0L || it.groupId == Long.MIN_VALUE) && it.isPrivate
}
.fold(0L) { mask, group -> mask or group.groupId }
val hiddenBooks = books.filter { (it.group and privateGroupMask) != 0L }
val visibleBooks = books.filterNot { (it.group and privateGroupMask) != 0L }
return BookPrivacyState(
privacyModeEnabled = privacyModeEnabled,
privateGroupMask = privateGroupMask,
visibleBookUrls = visibleBooks.mapTo(hashSetOf()) { it.bookUrl },
visibleBookKeys = visibleBooks.mapTo(hashSetOf()) {
BookRecordKey(normalizeBookName(it.name), normalizeBookName(it.author))
},
visibleBookNames = visibleBooks.mapTo(hashSetOf()) {
normalizeBookName(it.name)
},
hiddenBookUrls = hiddenBooks.mapTo(hashSetOf()) { it.bookUrl },
hiddenBookKeys = hiddenBooks.mapTo(hashSetOf()) {
BookRecordKey(normalizeBookName(it.name), normalizeBookName(it.author))
},
hiddenBookNames = hiddenBooks.mapTo(hashSetOf()) {
normalizeBookName(it.name)
},
)
}
}
}
class BookPrivacyRepository(
private val bookDao: BookDao,
private val bookGroupDao: BookGroupDao,
private val settingsRepository: SettingsRepository,
) {
fun observe(): Flow<BookPrivacyState> = combine(
bookDao.flowAll(),
bookGroupDao.flowAll(),
settingsRepository.getBoolean(PreferKey.bookshelfPrivacyMode, true),
BookPrivacyState::create,
).distinctUntilChanged()
}
internal fun normalizeBookName(name: String): String =
Normalizer.normalize(name, Normalizer.Form.NFKC)
.lowercase(Locale.ROOT)
.filterNot { it.isWhitespace() || it == '\u200B' || it == '\uFEFF' }
@@ -8,32 +8,48 @@ import io.legado.app.data.entities.BookChapter
import io.legado.app.ui.main.bookshelf.BookShelfItem import io.legado.app.ui.main.bookshelf.BookShelfItem
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class BookRepository( class BookRepository(
private val bookDao: BookDao, private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao private val bookChapterDao: BookChapterDao,
private val bookPrivacyRepository: BookPrivacyRepository,
) { ) {
fun getAllBooks(): Flow<List<Book>> { fun getAllBooks(): Flow<List<Book>> {
return bookDao.flowAll() return bookDao.flowAll()
} }
suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? { fun getVisibleBooks(): Flow<List<Book>> {
return withContext(Dispatchers.IO) { return combine(bookDao.flowAll(), bookPrivacyRepository.observe()) { books, privacyState ->
bookDao.getBook(bookName, bookAuthor)?.getDisplayCover() books.filter {
privacyState.isReadRecordVisible(it.bookUrl, it.name, it.author)
} }
} }
}
suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? {
return getVisibleBook(bookName, bookAuthor)?.getDisplayCover()
}
suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndex: Int): String? { suspend fun getChapterTitle(bookName: String, bookAuthor: String, chapterIndex: Int): String? {
val bookUrl = getVisibleBook(bookName, bookAuthor)?.bookUrl ?: return null
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val book = bookDao.getBook(bookName, bookAuthor)
val bookUrl = book?.bookUrl
if (bookUrl.isNullOrEmpty()) return@withContext null
bookChapterDao.getChapterTitleByUrlAndIndex(bookUrl, chapterIndex) bookChapterDao.getChapterTitleByUrlAndIndex(bookUrl, chapterIndex)
} }
} }
suspend fun getVisibleBook(name: String, author: String): Book? {
return withContext(Dispatchers.IO) {
val privacyState = bookPrivacyRepository.observe().first()
bookDao.getBooks(name, author).firstOrNull {
privacyState.isReadRecordVisible(it.bookUrl, it.name, it.author)
}
}
}
suspend fun getBook(bookUrl: String): Book? { suspend fun getBook(bookUrl: String): Book? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
bookDao.getBook(bookUrl) bookDao.getBook(bookUrl)
@@ -50,6 +66,12 @@ class BookRepository(
return bookDao.flowBookShelfByGroup(groupId) return bookDao.flowBookShelfByGroup(groupId)
} }
fun flowBookShelfIncludingPrivate(): Flow<List<BookShelfItem>> {
return bookDao.flowBookShelfIncludingPrivate().map { books ->
books.filterNot { it.isNotShelf }
}
}
fun flowSystemGroupCounts(): Flow<List<GroupBookCount>> { fun flowSystemGroupCounts(): Flow<List<GroupBookCount>> {
return bookDao.flowSystemGroupCounts() return bookDao.flowSystemGroupCounts()
} }
@@ -8,25 +8,40 @@ import io.legado.app.domain.model.DEFAULT_HOME_DASHBOARD_SECTIONS
import io.legado.app.domain.model.HomeDashboardSection import io.legado.app.domain.model.HomeDashboardSection
import io.legado.app.domain.model.HomeReadingBook import io.legado.app.domain.model.HomeReadingBook
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class HomeDashboardRepository( class HomeDashboardRepository(
private val readRecordDao: ReadRecordDao, private val readRecordDao: ReadRecordDao,
private val localPreferencesRepository: LocalPreferencesRepository, private val localPreferencesRepository: LocalPreferencesRepository,
private val readRecordRepository: ReadRecordRepository,
private val bookPrivacyRepository: BookPrivacyRepository,
) : HomeDashboardGateway { ) : HomeDashboardGateway {
override fun observeTotalReadBooks(): Flow<Int> = override fun observeTotalReadBooks(): Flow<Int> =
readRecordDao.observeTotalReadBookCount() readRecordRepository.getTotalReadBookCount()
override fun observeTotalReadTime(): Flow<Long> = override fun observeTotalReadTime(): Flow<Long> =
readRecordDao.getTotalReadTime().map { it ?: 0L } readRecordRepository.getTotalReadTime()
override fun observeReadTime(date: String): Flow<Long> = override fun observeReadTime(date: String): Flow<Long> =
readRecordDao.observeReadTimeByDate(date).map { it ?: 0L } readRecordRepository.getReadTime(date)
override fun observeRecentBooks(limit: Int): Flow<List<HomeReadingBook>> = override fun observeRecentBooks(limit: Int): Flow<List<HomeReadingBook>> {
readRecordDao.observeRecentHomeBooks(limit).map { rows -> return combine(
rows.map { row -> readRecordDao.observeRecentHomeBooks(),
bookPrivacyRepository.observe(),
) { rows, privacyState ->
rows.asSequence()
.filter { row ->
privacyState.isReadRecordVisible(
row.recordBookUrl,
row.recordName,
row.recordAuthor,
)
}
.take(limit)
.map { row ->
HomeReadingBook( HomeReadingBook(
bookUrl = row.bookUrl, bookUrl = row.bookUrl,
name = row.recordName, name = row.recordName,
@@ -51,6 +66,8 @@ class HomeDashboardRepository(
}, },
) )
} }
.toList()
}
} }
override fun observeDailyGoal(defaultValue: Int): Flow<Int> = override fun observeDailyGoal(defaultValue: Int): Flow<Int> =
@@ -10,6 +10,8 @@ import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import java.util.Date import java.util.Date
import kotlin.math.max import kotlin.math.max
@@ -18,6 +20,7 @@ import kotlin.math.min
class ReadRecordRepository( class ReadRecordRepository(
private val dao: ReadRecordDao, private val dao: ReadRecordDao,
private val database: AppDatabase, private val database: AppDatabase,
private val bookPrivacyRepository: BookPrivacyRepository,
) { ) {
private fun getCurrentDeviceId(): String = "" private fun getCurrentDeviceId(): String = ""
@@ -25,37 +28,73 @@ class ReadRecordRepository(
* 获取总阅读时长流 * 获取总阅读时长流
*/ */
fun getTotalReadTime(): Flow<Long> { fun getTotalReadTime(): Flow<Long> {
return dao.getTotalReadTime().map { it ?: 0L } return getLatestReadRecords().map { records -> records.sumOf { it.readTime } }
}
fun getTotalReadBookCount(): Flow<Int> {
return getLatestReadRecords().map { records ->
records.distinctBy { it.bookName to it.bookAuthor }.size
}
}
fun getReadTime(date: String): Flow<Long> {
return getAllRecordDetails().map { details ->
details.filter { it.date == date }.sumOf { it.readTime }
}
} }
/** /**
* 根据搜索关键字获取最新的阅读书籍列表流 * 根据搜索关键字获取最新的阅读书籍列表流
*/ */
fun getLatestReadRecords(query: String = ""): Flow<List<ReadRecord>> { fun getLatestReadRecords(query: String = ""): Flow<List<ReadRecord>> {
return if (query.isBlank()) { val records = if (query.isBlank()) {
dao.getAllReadRecordsSortedByLastRead() dao.getAllReadRecordsSortedByLastRead()
} else { } else {
dao.searchReadRecordsByLastRead(query) dao.searchReadRecordsByLastRead(query)
} }
return combine(records, bookPrivacyRepository.observe()) { items, privacyState ->
items.filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor)
}
}
} }
/** /**
* 获取所有的每日统计详情流 * 获取所有的每日统计详情流
*/ */
fun getAllRecordDetails(query: String = ""): Flow<List<ReadRecordDetail>> { fun getAllRecordDetails(query: String = ""): Flow<List<ReadRecordDetail>> {
return if (query.isBlank()) { val details = if (query.isBlank()) {
dao.getAllDetails() dao.getAllDetails()
} else { } else {
dao.searchDetails(query) dao.searchDetails(query)
} }
return combine(details, bookPrivacyRepository.observe()) { items, privacyState ->
items.filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor)
}
}
} }
fun getAllSessions(): Flow<List<ReadRecordSession>> { fun getAllSessions(): Flow<List<ReadRecordSession>> {
return dao.getAllSessions(getCurrentDeviceId()) return combine(
dao.getAllSessions(getCurrentDeviceId()),
bookPrivacyRepository.observe(),
) { sessions, privacyState ->
sessions.filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor)
}
}
} }
fun getBookSessions(bookName: String, bookAuthor: String): Flow<List<ReadRecordSession>> { fun getBookSessions(bookName: String, bookAuthor: String): Flow<List<ReadRecordSession>> {
return dao.getSessionsByBookFlow(getCurrentDeviceId(), bookName, bookAuthor) return combine(
dao.getSessionsByBookFlow(getCurrentDeviceId(), bookName, bookAuthor),
bookPrivacyRepository.observe(),
) { sessions, privacyState ->
sessions.filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor)
}
}
} }
fun getBookTimelineDays(bookName: String, bookAuthor: String): Flow<List<ReadRecordTimelineDay>> { fun getBookTimelineDays(bookName: String, bookAuthor: String): Flow<List<ReadRecordTimelineDay>> {
@@ -72,15 +111,27 @@ class ReadRecordRepository(
} }
fun getBookReadTime(bookName: String, bookAuthor: String): Flow<Long> { fun getBookReadTime(bookName: String, bookAuthor: String): Flow<Long> {
return dao.getReadTimeFlow(getCurrentDeviceId(), bookName, bookAuthor).map { it ?: 0L } return combine(
dao.getReadTimeFlow(getCurrentDeviceId(), bookName, bookAuthor),
bookPrivacyRepository.observe(),
) { readTime, privacyState ->
if (privacyState.isReadRecordVisible(null, bookName, bookAuthor)) {
readTime ?: 0L
} else {
0L
}
}
} }
suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> { suspend fun getMergeCandidates(targetRecord: ReadRecord): List<ReadRecord> {
val privacyState = bookPrivacyRepository.observe().first()
return dao.getReadRecordsByNameExcludingAuthor( return dao.getReadRecordsByNameExcludingAuthor(
targetRecord.deviceId, targetRecord.deviceId,
targetRecord.bookName, targetRecord.bookName,
targetRecord.bookAuthor targetRecord.bookAuthor
) ).filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor)
}
} }
/** /**
@@ -104,6 +155,7 @@ class ReadRecordRepository(
if (existingRecord != null) { if (existingRecord != null) {
dao.update( dao.update(
existingRecord.copy( existingRecord.copy(
bookUrl = session.bookUrl ?: existingRecord.bookUrl,
readTime = existingRecord.readTime + durationDelta, readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime lastRead = session.endTime
) )
@@ -114,6 +166,7 @@ class ReadRecordRepository(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
bookAuthor = session.bookAuthor, bookAuthor = session.bookAuthor,
bookUrl = session.bookUrl,
readTime = durationDelta, readTime = durationDelta,
lastRead = session.endTime lastRead = session.endTime
) )
@@ -135,17 +188,22 @@ class ReadRecordRepository(
dateString dateString
) )
if (existingDetail != null) { if (existingDetail != null) {
existingDetail.readTime += durationDelta dao.insertDetail(
existingDetail.readWords += wordsDelta existingDetail.copy(
existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime) bookUrl = session.bookUrl ?: existingDetail.bookUrl,
existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime) readTime = existingDetail.readTime + durationDelta,
dao.insertDetail(existingDetail) readWords = existingDetail.readWords + wordsDelta,
firstReadTime = min(existingDetail.firstReadTime, session.startTime),
lastReadTime = max(existingDetail.lastReadTime, session.endTime),
)
)
} else { } else {
dao.insertDetail( dao.insertDetail(
ReadRecordDetail( ReadRecordDetail(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
bookAuthor = session.bookAuthor, bookAuthor = session.bookAuthor,
bookUrl = session.bookUrl,
date = dateString, date = dateString,
readTime = durationDelta, readTime = durationDelta,
readWords = wordsDelta, readWords = wordsDelta,
@@ -204,6 +262,7 @@ class ReadRecordRepository(
) )
dao.insertDetail( dao.insertDetail(
existingDetail?.copy( existingDetail?.copy(
bookUrl = existingDetail.bookUrl ?: remainingSessions.resolvedBookUrl(),
readTime = totalTime, readTime = totalTime,
readWords = totalWords, readWords = totalWords,
firstReadTime = firstRead, firstReadTime = firstRead,
@@ -212,6 +271,7 @@ class ReadRecordRepository(
deviceId = session.deviceId, deviceId = session.deviceId,
bookName = session.bookName, bookName = session.bookName,
bookAuthor = session.bookAuthor, bookAuthor = session.bookAuthor,
bookUrl = remainingSessions.resolvedBookUrl(),
date = dateString, date = dateString,
readTime = totalTime, readTime = totalTime,
readWords = totalWords, readWords = totalWords,
@@ -233,6 +293,7 @@ class ReadRecordRepository(
} else { } else {
val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime } val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
val lastRead = allRemainingSessions.maxOf { it.endTime } val lastRead = allRemainingSessions.maxOf { it.endTime }
val bookUrl = allRemainingSessions.resolvedBookUrl()
val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor) val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor)
if (existingRecord == null) { if (existingRecord == null) {
@@ -241,6 +302,7 @@ class ReadRecordRepository(
deviceId = deviceId, deviceId = deviceId,
bookName = bookName, bookName = bookName,
bookAuthor = bookAuthor, bookAuthor = bookAuthor,
bookUrl = bookUrl,
readTime = totalTime, readTime = totalTime,
lastRead = lastRead, lastRead = lastRead,
) )
@@ -248,6 +310,7 @@ class ReadRecordRepository(
} else { } else {
dao.update( dao.update(
existingRecord.copy( existingRecord.copy(
bookUrl = existingRecord.bookUrl ?: bookUrl,
readTime = totalTime, readTime = totalTime,
lastRead = lastRead lastRead = lastRead
) )
@@ -288,9 +351,11 @@ class ReadRecordRepository(
targetRecord.bookName, targetRecord.bookName,
targetRecord.bookAuthor targetRecord.bookAuthor
) ?: targetRecord ) ?: targetRecord
val targetBookUrl = target.bookUrl ?: source.bookUrl
dao.insert( dao.insert(
target.copy( target.copy(
bookUrl = targetBookUrl,
readTime = target.readTime + source.readTime, readTime = target.readTime + source.readTime,
lastRead = max(target.lastRead, source.lastRead) lastRead = max(target.lastRead, source.lastRead)
) )
@@ -311,12 +376,14 @@ class ReadRecordRepository(
if (existingTargetDetail == null) { if (existingTargetDetail == null) {
dao.insertDetail( dao.insertDetail(
detail.copy( detail.copy(
bookAuthor = targetRecord.bookAuthor bookAuthor = targetRecord.bookAuthor,
bookUrl = targetBookUrl,
) )
) )
} else { } else {
dao.insertDetail( dao.insertDetail(
existingTargetDetail.copy( existingTargetDetail.copy(
bookUrl = targetBookUrl ?: existingTargetDetail.bookUrl,
readTime = existingTargetDetail.readTime + detail.readTime, readTime = existingTargetDetail.readTime + detail.readTime,
readWords = existingTargetDetail.readWords + detail.readWords, readWords = existingTargetDetail.readWords + detail.readWords,
firstReadTime = min(existingTargetDetail.firstReadTime, detail.firstReadTime), firstReadTime = min(existingTargetDetail.firstReadTime, detail.firstReadTime),
@@ -333,11 +400,22 @@ class ReadRecordRepository(
sourceRecord.bookAuthor sourceRecord.bookAuthor
) )
sourceSessions.forEach { session -> sourceSessions.forEach { session ->
dao.updateSession(session.copy(bookAuthor = targetRecord.bookAuthor)) dao.updateSession(
session.copy(
bookAuthor = targetRecord.bookAuthor,
bookUrl = targetBookUrl,
)
)
} }
dao.deleteReadRecord(source) dao.deleteReadRecord(source)
updateReadRecordTotal(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor) updateReadRecordTotal(targetRecord.deviceId, targetRecord.bookName, targetRecord.bookAuthor)
} }
private fun List<ReadRecordSession>.resolvedBookUrl(): String? =
asSequence()
.mapNotNull { it.bookUrl?.takeIf(String::isNotBlank) }
.distinct()
.singleOrNull()
} }
@@ -410,7 +410,7 @@ class ReadSettingsRepository(
textBottomJustify = this[Keys.TextBottomJustify] ?: true, textBottomJustify = this[Keys.TextBottomJustify] ?: true,
adaptSpecialStyle = this[Keys.AdaptSpecialStyle] ?: true, adaptSpecialStyle = this[Keys.AdaptSpecialStyle] ?: true,
useZhLayout = this[Keys.UseZhLayout] ?: false, useZhLayout = this[Keys.UseZhLayout] ?: false,
showBrightnessView = this[Keys.ShowBrightnessView] ?: "1", showBrightnessView = getStringCompat(PreferKey.showBrightnessView, "1"),
brightnessVwPos = this[Keys.BrightnessVwPos] ?: "1", brightnessVwPos = this[Keys.BrightnessVwPos] ?: "1",
readBrightness = this[Keys.ReadBrightness] ?: 100, readBrightness = this[Keys.ReadBrightness] ?: 100,
brightnessAuto = this[Keys.BrightnessAuto] ?: false, brightnessAuto = this[Keys.BrightnessAuto] ?: false,
@@ -27,6 +27,17 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
} }
) )
internal fun normalizeLegacyPreferenceValue(key: String, value: Any): Any =
when {
key == PreferKey.showBrightnessView && value is Boolean -> if (value) "1" else "0"
else -> value
}
internal fun Preferences.getStringCompat(key: String, defaultValue: String): String {
val value = asMap().entries.firstOrNull { it.key.name == key }?.value ?: return defaultValue
return normalizeLegacyPreferenceValue(key, value) as? String ?: defaultValue
}
/** /**
* 设置仓储 * 设置仓储
* 以 DataStore 为唯一写入源,读取以 DataStore 为准。 * 以 DataStore 为唯一写入源,读取以 DataStore 为准。
@@ -110,15 +121,15 @@ class SettingsRepository(private val context: Context) {
suspend fun batchPutFromMap(map: Map<String, *>) { suspend fun batchPutFromMap(map: Map<String, *>) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
map.forEach { (key, value) -> map.forEach { (key, value) ->
when (value) { when (val normalizedValue = value?.let { normalizeLegacyPreferenceValue(key, it) }) {
is String -> preferences[stringPreferencesKey(key)] = value is String -> preferences[stringPreferencesKey(key)] = normalizedValue
is Int -> preferences[intPreferencesKey(key)] = value is Int -> preferences[intPreferencesKey(key)] = normalizedValue
is Boolean -> preferences[booleanPreferencesKey(key)] = value is Boolean -> preferences[booleanPreferencesKey(key)] = normalizedValue
is Long -> preferences[longPreferencesKey(key)] = value is Long -> preferences[longPreferencesKey(key)] = normalizedValue
is Float -> preferences[floatPreferencesKey(key)] = value is Float -> preferences[floatPreferencesKey(key)] = normalizedValue
is Set<*> -> { is Set<*> -> {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
preferences[stringSetPreferencesKey(key)] = value as Set<String> preferences[stringSetPreferencesKey(key)] = normalizedValue as Set<String>
} }
} }
} }
@@ -18,6 +18,7 @@ import io.legado.app.data.repository.BackupRestoreRepository
import io.legado.app.data.repository.BookCacheCleanupRepository import io.legado.app.data.repository.BookCacheCleanupRepository
import io.legado.app.data.repository.BookDomainRepositoryImpl import io.legado.app.data.repository.BookDomainRepositoryImpl
import io.legado.app.data.repository.BookGroupRepository import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.BookPrivacyRepository
import io.legado.app.data.repository.BookRepository import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.BookSourceCallbackRepository import io.legado.app.data.repository.BookSourceCallbackRepository
import io.legado.app.data.repository.BookSourceRepository import io.legado.app.data.repository.BookSourceRepository
@@ -179,8 +180,11 @@ val appModule = module {
single { get<AppDatabase>().bookGroupDao } single { get<AppDatabase>().bookGroupDao }
single { get<AppDatabase>().bookSourceDao } single { get<AppDatabase>().bookSourceDao }
singleOf(::BookPrivacyRepository)
singleOf(::ReadRecordRepository) singleOf(::ReadRecordRepository)
single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) } single<HomeDashboardGateway> {
HomeDashboardRepository(get(), get(), get(), get())
}
singleOf(::BookRepository) singleOf(::BookRepository)
singleOf(::BookGroupRepository) singleOf(::BookGroupRepository)
singleOf(::BookSourceRepository) singleOf(::BookSourceRepository)
@@ -230,7 +234,9 @@ val appModule = module {
single<AiChatGateway> { AiChatRepository(get()) } single<AiChatGateway> { AiChatRepository(get()) }
single<AiMemoryGateway> { AiMemoryRepository(get()) } single<AiMemoryGateway> { AiMemoryRepository(get()) }
single<AiTextGateway> { AiTextRepositoryImpl() } single<AiTextGateway> { AiTextRepositoryImpl() }
single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get()) } single<AiToolGateway> {
AiToolRepository(get(), get(), get(), get(), get(), get(), get())
}
single<AppStartupGateway> { AppStartupRepository(get()) } single<AppStartupGateway> { AppStartupRepository(get()) }
single<BackupRestoreGateway> { BackupRestoreRepository() } single<BackupRestoreGateway> { BackupRestoreRepository() }
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) } single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
@@ -4,6 +4,7 @@ import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType import io.legado.app.constant.BookType
import io.legado.app.data.dao.BookChapterDao import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookSource import io.legado.app.data.entities.BookSource
@@ -68,6 +69,7 @@ enum class BatchChangeSourcePreviewStatus {
class ChangeBookSourceUseCase( class ChangeBookSourceUseCase(
private val bookDao: BookDao, private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao, private val bookChapterDao: BookChapterDao,
private val readRecordDao: ReadRecordDao,
) { ) {
fun applyMigration( fun applyMigration(
@@ -97,6 +99,7 @@ class ChangeBookSourceUseCase(
bookChapterDao.delByBook(oldBook.bookUrl) bookChapterDao.delByBook(oldBook.bookUrl)
bookDao.delete(oldBook) bookDao.delete(oldBook)
bookDao.insert(newBook) bookDao.insert(newBook)
readRecordDao.replaceBookUrl(oldBookUrl, newBook.bookUrl)
if (options.migrateChapters) { if (options.migrateChapters) {
bookChapterDao.insert(*chapters.toTypedArray()) bookChapterDao.insert(*chapters.toTypedArray())
ReadBook.onChapterListUpdated(newBook) ReadBook.onChapterListUpdated(newBook)
@@ -214,17 +214,28 @@ object Restore : KoinComponent {
} }
fileToListT<ReadRecord>(path, "readRecord.json")?.let { fileToListT<ReadRecord>(path, "readRecord.json")?.let {
it.forEach { readRecord -> it.forEach { readRecord ->
if (readRecord.deviceId != androidId) { val restoredRecord = readRecord.copy(
bookUrl = resolveBookUrl(
readRecord.bookUrl,
readRecord.bookName,
readRecord.bookAuthor,
)
)
if (restoredRecord.deviceId != androidId) {
try { try {
appDb.readRecordDao.insert(readRecord) appDb.readRecordDao.insert(restoredRecord)
} catch (_: SQLiteConstraintException) { } catch (_: SQLiteConstraintException) {
} }
} else { } else {
val time = appDb.readRecordDao val time = appDb.readRecordDao
.getReadTime(readRecord.deviceId, readRecord.bookName, readRecord.bookAuthor) .getReadTime(
if (time == null || time < readRecord.readTime) { restoredRecord.deviceId,
restoredRecord.bookName,
restoredRecord.bookAuthor,
)
if (time == null || time < restoredRecord.readTime) {
try { try {
appDb.readRecordDao.insert(readRecord) appDb.readRecordDao.insert(restoredRecord)
} catch (_: SQLiteConstraintException) { } catch (_: SQLiteConstraintException) {
} }
} }
@@ -234,7 +245,15 @@ object Restore : KoinComponent {
fileToListT<ReadRecordDetail>(path, "readRecordDetail.json")?.let { fileToListT<ReadRecordDetail>(path, "readRecordDetail.json")?.let {
it.forEach { detail -> it.forEach { detail ->
try { try {
appDb.readRecordDao.insertDetail(detail) appDb.readRecordDao.insertDetail(
detail.copy(
bookUrl = resolveBookUrl(
detail.bookUrl,
detail.bookName,
detail.bookAuthor,
)
)
)
} catch (_: SQLiteConstraintException) { } catch (_: SQLiteConstraintException) {
} }
} }
@@ -242,7 +261,15 @@ object Restore : KoinComponent {
fileToListT<ReadRecordSession>(path, "readRecordSession.json")?.let { fileToListT<ReadRecordSession>(path, "readRecordSession.json")?.let {
it.forEach { session -> it.forEach { session ->
try { try {
appDb.readRecordDao.insertSession(session) appDb.readRecordDao.insertSession(
session.copy(
bookUrl = resolveBookUrl(
session.bookUrl,
session.bookName,
session.bookAuthor,
)
)
)
} catch (_: SQLiteConstraintException) { } catch (_: SQLiteConstraintException) {
} }
} }
@@ -410,6 +437,15 @@ object Restore : KoinComponent {
return map return map
} }
private suspend fun resolveBookUrl(
existingBookUrl: String?,
bookName: String,
bookAuthor: String,
): String? {
if (!existingBookUrl.isNullOrBlank()) return existingBookUrl
return appDb.readRecordDao.findMatchingBookUrls(bookName, bookAuthor).singleOrNull()
}
private inline fun <reified T> fileToListT(path: String, fileName: String): List<T>? { private inline fun <reified T> fileToListT(path: String, fileName: String): List<T>? {
try { try {
val file = File(path, fileName) val file = File(path, fileName)
@@ -68,11 +68,12 @@ data class Asset(
val downloadCount: Int, val downloadCount: Int,
val id: Int, val id: Int,
val name: String, val name: String,
val state: String, val state: String?,
val url: String val url: String
) { ) {
val isValid: Boolean val isValid: Boolean
get() = (contentType == "application/vnd.android.package-archive") && (state == "uploaded") get() = name.endsWith(".apk", ignoreCase = true) &&
(state == null || state == "uploaded")
fun assetToAppReleaseInfo(preRelease: Boolean, note: String, version: String): AppReleaseInfo { fun assetToAppReleaseInfo(preRelease: Boolean, note: String, version: String): AppReleaseInfo {
val instant = Instant.parse(createdAt) val instant = Instant.parse(createdAt)
@@ -14,6 +14,9 @@ import kotlinx.coroutines.CoroutineScope
object AppUpdateGitHub : AppUpdate.AppUpdateInterface { object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
private const val RELEASES_API =
"https://gitea.yamby.cn/api/v1/repos/yusheng/QieKan-3.0/releases"
private val checkVariant: AppVariant private val checkVariant: AppVariant
get() = when (AppConfig.updateToVariant) { get() = when (AppConfig.updateToVariant) {
"official_version" -> AppVariant.OFFICIAL "official_version" -> AppVariant.OFFICIAL
@@ -24,9 +27,9 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
private suspend fun getLatestRelease(): List<AppReleaseInfo> { private suspend fun getLatestRelease(): List<AppReleaseInfo> {
val url = if (checkVariant == AppVariant.OFFICIAL) val url = if (checkVariant == AppVariant.OFFICIAL)
"https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest" "$RELEASES_API/latest"
else else
"https://api.github.com/repos/HapeLee/legado-with-MD3/releases" RELEASES_API
val res = okHttpClient.newCallResponse { url(url) } val res = okHttpClient.newCallResponse { url(url) }
if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})") if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
@@ -65,7 +68,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
} }
suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? { suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
val url = "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/tags/$tag" val url = "$RELEASES_API/tags/$tag"
val res = okHttpClient.newCallResponse { url(url) } val res = okHttpClient.newCallResponse { url(url) }
if (!res.isSuccessful) return null if (!res.isSuccessful) return null
@@ -124,6 +124,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
ReadBook.book = book ReadBook.book = book
readRecord.bookName = book.name readRecord.bookName = book.name
readRecord.bookAuthor = book.author readRecord.bookAuthor = book.author
readRecord.bookUrl = book.bookUrl
readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0 readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) { simulatedChapterSize = if (book.readSimulating()) {
@@ -330,6 +331,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
deviceId = "", deviceId = "",
bookName = currentBookName, bookName = currentBookName,
bookAuthor = currentBookAuthor, bookAuthor = currentBookAuthor,
bookUrl = book?.bookUrl,
startTime = readStartTime, startTime = readStartTime,
endTime = readStartTime, endTime = readStartTime,
words = durChapterIndex.toLong() words = durChapterIndex.toLong()
@@ -92,6 +92,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
ReadManga.book = book ReadManga.book = book
readRecord.bookName = book.name readRecord.bookName = book.name
readRecord.bookAuthor = book.author readRecord.bookAuthor = book.author
readRecord.bookUrl = book.bookUrl
readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0 readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl) chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) { simulatedChapterSize = if (book.readSimulating()) {
@@ -162,6 +163,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
deviceId = "", deviceId = "",
bookName = currentBookName, bookName = currentBookName,
bookAuthor = currentBookAuthor, bookAuthor = currentBookAuthor,
bookUrl = book?.bookUrl,
startTime = readStartTime, startTime = readStartTime,
endTime = readStartTime, endTime = readStartTime,
words = durChapterIndex.toLong() words = durChapterIndex.toLong()
@@ -55,7 +55,7 @@ fun AboutScreen(
state: AboutUiState, state: AboutUiState,
onIntent: (AboutIntent) -> Unit, onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
versionName: String = appInfo.versionName, versionName: String = "${appInfo.versionName} (${appInfo.versionCode})",
) { ) {
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) { if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
MiuixAboutScreen( MiuixAboutScreen(
@@ -145,13 +145,13 @@ private fun MaterialAboutScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }) { FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_web_outline), painter = painterResource(R.drawable.ic_web_outline),
contentDescription = stringResource(R.string.back) contentDescription = stringResource(R.string.back)
) )
} }
FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }) { FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_github), painter = painterResource(R.drawable.ic_github),
contentDescription = stringResource(R.string.back) contentDescription = stringResource(R.string.back)
@@ -172,7 +172,7 @@ private fun MaterialAboutScreen(
SettingItemWithDivider { SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.contributors), title = stringResource(R.string.contributors),
onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) } onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }
) )
} }
SettingItemWithDivider { SettingItemWithDivider {
@@ -84,7 +84,7 @@ fun MiuixAboutScreen(
state: AboutUiState, state: AboutUiState,
onIntent: (AboutIntent) -> Unit, onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
versionName: String = appInfo.versionName, versionName: String = "${appInfo.versionName} (${appInfo.versionCode})",
) { ) {
val topAppBarScrollBehavior = MiuixScrollBehavior() val topAppBarScrollBehavior = MiuixScrollBehavior()
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
@@ -395,7 +395,7 @@ private fun AboutContent(
endActions = { endActions = {
ValueText("GitHub") ValueText("GitHub")
}, },
onClick = { onIntent(AboutIntent.OpenUrl("https://github.com/HapeLee/legado-with-MD3")) }, onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) },
) )
} }
@@ -115,7 +115,7 @@ fun GroupEditContent(
val sortEntryValues = remember(sortOptions) { val sortEntryValues = remember(sortOptions) {
Array(sortOptions.size) { (it - 1).toString() } Array(sortOptions.size) { (it - 1).toString() }
} }
val canSetPrivate = group == null || group.groupId > 0 val canSetPrivate = group == null || group.groupId > 0 || group.groupId == Long.MIN_VALUE
val selectImage = rememberLauncherForActivityResult(SelectImageContract()) { result -> val selectImage = rememberLauncherForActivityResult(SelectImageContract()) { result ->
result.uri?.let { uri -> result.uri?.let { uri ->
@@ -53,7 +53,7 @@ class ReadRecordOverviewViewModel(
_referenceDate, _referenceDate,
repository.getAllRecordDetails(""), repository.getAllRecordDetails(""),
repository.getLatestReadRecords(""), repository.getLatestReadRecords(""),
bookRepository.getAllBooks() bookRepository.getVisibleBooks()
) { period, refDate, details, latestRecords, allBooks -> ) { period, refDate, details, latestRecords, allBooks ->
getReadRecordOverviewUseCase(period, refDate, details, latestRecords, allBooks) getReadRecordOverviewUseCase(period, refDate, details, latestRecords, allBooks)
}.stateIn( }.stateIn(
@@ -20,6 +20,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.LocalNavAnimatedContentScope import androidx.navigation3.ui.LocalNavAnimatedContentScope
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
import io.legado.app.data.repository.BookRepository
import io.legado.app.model.Download import io.legado.app.model.Download
import io.legado.app.ui.about.AboutEffect import io.legado.app.ui.about.AboutEffect
import io.legado.app.ui.about.AboutScreen import io.legado.app.ui.about.AboutScreen
@@ -74,11 +75,10 @@ import io.legado.app.utils.startActivityForBook
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import io.legado.app.utils.toggleSystemBar import io.legado.app.utils.toggleSystemBar
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@@ -559,13 +559,12 @@ fun MainActivity.mainEntryProvider(
} }
entry<MainRouteReadRecord> { entry<MainRouteReadRecord> {
val bookRepository = koinInject<BookRepository>()
ReadRecordScreen( ReadRecordScreen(
onBackClick = { onNavigateBack() }, onBackClick = { onNavigateBack() },
onBookClick = { name, author -> onBookClick = { name, author ->
lifecycleScope.launch { lifecycleScope.launch {
val book = withContext(IO) { val book = bookRepository.getVisibleBook(name, author)
io.legado.app.data.appDb.bookDao.getBook(name, author)
}
if (book != null) this@mainEntryProvider.startActivityForBook(book) if (book != null) this@mainEntryProvider.startActivityForBook(book)
else { else {
onNavigateToRoute(MainRouteSearch(key = name)) onNavigateToRoute(MainRouteSearch(key = name))
@@ -579,13 +578,12 @@ fun MainActivity.mainEntryProvider(
} }
entry<MainRouteReadRecordOverview> { entry<MainRouteReadRecordOverview> {
val bookRepository = koinInject<BookRepository>()
ReadRecordOverviewScreen( ReadRecordOverviewScreen(
onBackClick = { onNavigateBack() }, onBackClick = { onNavigateBack() },
onBookClick = { name, author -> onBookClick = { name, author ->
lifecycleScope.launch { lifecycleScope.launch {
val book = withContext(IO) { val book = bookRepository.getVisibleBook(name, author)
io.legado.app.data.appDb.bookDao.getBook(name, author)
}
if (book != null) this@mainEntryProvider.startActivityForBook(book) if (book != null) this@mainEntryProvider.startActivityForBook(book)
else { else {
onNavigateToRoute(MainRouteSearch(key = name)) onNavigateToRoute(MainRouteSearch(key = name))
@@ -55,6 +55,7 @@ import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.image.cover.BookshelfCover import io.legado.app.ui.widget.components.image.cover.BookshelfCover
import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.text.hiddenAttributeUnderline
import io.legado.app.utils.toTimeAgo import io.legado.app.utils.toTimeAgo
/** /**
@@ -83,6 +84,7 @@ fun BookshelfItem(
titleMaxLines: Int = 2, titleMaxLines: Int = 2,
coverShadow: Boolean = false, coverShadow: Boolean = false,
titleColor: Color? = null, titleColor: Color? = null,
titleHidden: Boolean = false,
descAnnotated: AnnotatedString? = null, descAnnotated: AnnotatedString? = null,
coverWidth: Int = 84, coverWidth: Int = 84,
onClick: () -> Unit, onClick: () -> Unit,
@@ -151,6 +153,7 @@ fun BookshelfItem(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth() .fillMaxWidth()
.hiddenAttributeUnderline(titleHidden, Color.White)
.background( .background(
Brush.verticalGradient( Brush.verticalGradient(
colors = listOf( colors = listOf(
@@ -173,6 +176,10 @@ fun BookshelfItem(
textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start, textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.hiddenAttributeUnderline(
titleHidden,
LegadoTheme.colorScheme.onSurfaceVariant
)
.padding(start = 4.dp, end = 4.dp, bottom = 4.dp) .padding(start = 4.dp, end = 4.dp, bottom = 4.dp)
) )
} }
@@ -237,7 +244,12 @@ fun BookshelfItem(
}, },
maxLines = BookshelfConfig.bookshelfTitleMaxLines, maxLines = BookshelfConfig.bookshelfTitleMaxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier
.weight(1f)
.hiddenAttributeUnderline(
titleHidden,
LegadoTheme.colorScheme.onSurfaceVariant
)
) )
titleEnd?.let { titleEnd?.let {
Box(modifier = Modifier.padding(top = 4.dp, start = 4.dp)) { Box(modifier = Modifier.padding(top = 4.dp, start = 4.dp)) {
@@ -436,6 +448,7 @@ fun BookGroupItemGrid(
) )
}, },
title = group.groupName, title = group.groupName,
titleHidden = group.isPrivate,
modifier = modifier, modifier = modifier,
titleSmallFont = titleSmallFont, titleSmallFont = titleSmallFont,
titleCenter = titleCenter, titleCenter = titleCenter,
@@ -491,6 +504,7 @@ fun BookGroupItemList(
isCompact = BookshelfConfig.bookshelfGroupListStyle == 1 || isCompact, isCompact = BookshelfConfig.bookshelfGroupListStyle == 1 || isCompact,
cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) }, cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) },
title = group.groupName, title = group.groupName,
titleHidden = group.isPrivate,
subTitle = countText, subTitle = countText,
descAnnotated = descAnnotated, descAnnotated = descAnnotated,
titleSmallFont = titleSmallFont, titleSmallFont = titleSmallFont,
@@ -549,6 +563,10 @@ fun BookGroupItemHorizontalCovers(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
.hiddenAttributeUnderline(
group.isPrivate,
LegadoTheme.colorScheme.onSurfaceVariant
)
) )
if (countText != null) { if (countText != null) {
AppText( AppText(
@@ -689,6 +707,7 @@ fun BookItem(
) )
}, },
title = book.name, title = book.name,
titleHidden = bookUi.isHidden,
subTitle = if (layoutMode == 0 && isCompact) { subTitle = if (layoutMode == 0 && isCompact) {
stringResource(R.string.author_read, book.author, unreadCount) stringResource(R.string.author_read, book.author, unreadCount)
} else { } else {
@@ -50,7 +50,7 @@ data class BookShelfItem(
/** /**
* 将 DTO 转换为专为 Compose 设计的 UI 状态 * 将 DTO 转换为专为 Compose 设计的 UI 状态
*/ */
fun toUiItem(): BookUiItem { fun toUiItem(privateGroupMask: Long = 0L): BookUiItem {
val tagList = mutableListOf<String>() val tagList = mutableListOf<String>()
kind?.splitNotBlank(",", "\n")?.filter { it.isNotBlank() }?.let { kind?.splitNotBlank(",", "\n")?.filter { it.isNotBlank() }?.let {
tagList.addAll(it) tagList.addAll(it)
@@ -61,7 +61,8 @@ data class BookShelfItem(
return BookUiItem( return BookUiItem(
book = this, book = this,
displayTags = tagList.toImmutableList() displayTags = tagList.toImmutableList(),
isHidden = privateGroupMask != 0L && (group and privateGroupMask) != 0L
) )
} }
} }
@@ -72,7 +73,8 @@ data class BookShelfItem(
@Stable @Stable
data class BookUiItem( data class BookUiItem(
val book: BookShelfItem, val book: BookShelfItem,
val displayTags: ImmutableList<String> val displayTags: ImmutableList<String>,
val isHidden: Boolean = false
) { ) {
fun matches(key: String): Boolean { fun matches(key: String): Boolean {
return book.name.contains(key, true) || return book.name.contains(key, true) ||
@@ -65,6 +65,7 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Wifi import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.ViewCarousel import androidx.compose.material.icons.outlined.ViewCarousel
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -73,6 +74,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -238,7 +240,9 @@ fun BookshelfScreen(
) )
val folderGridState = rememberLazyGridState() val folderGridState = rememberLazyGridState()
val standaloneSearchGridState = rememberLazyGridState() val standaloneSearchGridState = rememberLazyGridState()
val groupGridStates = mutableMapOf<Long, LazyGridState>() // Pager may keep composing an old keyed page briefly after the visible group list changes.
// Retain states for removed groups until those pages are disposed to avoid a missing map key.
val groupGridStates = remember { mutableMapOf<Long, LazyGridState>() }
uiState.groups.forEach { group -> uiState.groups.forEach { group ->
key(group.groupId) { key(group.groupId) {
groupGridStates[group.groupId] = rememberLazyGridState() groupGridStates[group.groupId] = rememberLazyGridState()
@@ -507,6 +511,19 @@ fun BookshelfScreen(
}, },
leadingIcon = { Icon(Icons.Outlined.ViewCarousel, null) } leadingIcon = { Icon(Icons.Outlined.ViewCarousel, null) }
) )
RoundDropdownMenuItem(
text = stringResource(R.string.privacy_mode),
onClick = {
viewModel.setPrivacyMode(!uiState.privacyModeEnabled)
},
leadingIcon = { Icon(Icons.Default.VisibilityOff, null) },
trailingIcon = {
Switch(
checked = uiState.privacyModeEnabled,
onCheckedChange = viewModel::setPrivacyMode
)
}
)
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.add_url), text = stringResource(R.string.add_url),
onClick = { onClick = {
@@ -578,6 +595,9 @@ fun BookshelfScreen(
AppTabRow( AppTabRow(
tabTitles = tabTitles, tabTitles = tabTitles,
hiddenTabIndices = uiState.groups.mapIndexedNotNull { index, group ->
index.takeIf { group.isPrivate }
}.toSet(),
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
onTabSelected = { index -> onTabSelected = { index ->
scope.launch { pagerState.animateScrollToPage(index) } scope.launch { pagerState.animateScrollToPage(index) }
@@ -608,6 +628,7 @@ fun BookshelfScreen(
uiState.groups.forEachIndexed { index, group -> uiState.groups.forEachIndexed { index, group ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = group.groupName, text = group.groupName,
hidden = group.isPrivate,
onClick = { onClick = {
if (uiState.isSearch) { if (uiState.isSearch) {
viewModel.changeGroup(group.groupId) viewModel.changeGroup(group.groupId)
@@ -641,7 +662,9 @@ fun BookshelfScreen(
it.groupId == BookGroup.IdAll it.groupId == BookGroup.IdAll
} }
val hiddenGroups = uiState.allGroups.filter { val hiddenGroups = uiState.allGroups.filter {
!it.show && it.groupId != BookGroup.IdAll !it.show &&
it.groupId != BookGroup.IdAll &&
(!uiState.privacyModeEnabled || !it.isPrivate)
} }
if (allGroup != null || hiddenGroups.isNotEmpty()) { if (allGroup != null || hiddenGroups.isNotEmpty()) {
@@ -656,6 +679,7 @@ fun BookshelfScreen(
allGroup?.let { group -> allGroup?.let { group ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = group.groupName, text = group.groupName,
hidden = group.isPrivate,
onClick = { onClick = {
viewModel.changeGroup(group.groupId) viewModel.changeGroup(group.groupId)
dismiss() dismiss()
@@ -675,6 +699,7 @@ fun BookshelfScreen(
hiddenGroups.forEach { group -> hiddenGroups.forEach { group ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = group.groupName, text = group.groupName,
hidden = group.isPrivate,
onClick = { onClick = {
viewModel.changeGroup(group.groupId) viewModel.changeGroup(group.groupId)
dismiss() dismiss()
@@ -999,6 +1024,7 @@ fun BookshelfScreen(
uiState.groups.forEach { group -> uiState.groups.forEach { group ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = group.groupName, text = group.groupName,
hidden = group.isPrivate,
onClick = { onClick = {
val targetIndex = val targetIndex =
uiState.groups.indexOfFirst { it.groupId == group.groupId } uiState.groups.indexOfFirst { it.groupId == group.groupId }
@@ -1140,7 +1166,10 @@ private fun BookshelfOverlays(
GroupSelectSheet( GroupSelectSheet(
show = activeOverlay == BookshelfOverlay.GroupSelectSheet, show = activeOverlay == BookshelfOverlay.GroupSelectSheet,
groups = groups.filter { it.groupId > 0 }, groups = groups.filter {
(it.groupId > 0 || it.groupId == Long.MIN_VALUE) &&
(!uiState.privacyModeEnabled || !it.isPrivate)
},
currentGroupId = 0L, currentGroupId = 0L,
onDismissRequest = { viewModel.dismissOverlay() }, onDismissRequest = { viewModel.dismissOverlay() },
onConfirm = { groupId -> onConfirm = { groupId ->
@@ -58,6 +58,7 @@ data class BookshelfUiState(
val bookGroupStyle: Int = 0, val bookGroupStyle: Int = 0,
val bookshelfSort: Int = 0, val bookshelfSort: Int = 0,
val bookshelfSortOrder: Int = 1, val bookshelfSortOrder: Int = 1,
val privacyModeEnabled: Boolean = true,
val title: String = "", val title: String = "",
val subtitle: String? = null, val subtitle: String? = null,
val currentGroupName: String? = null, val currentGroupName: String? = null,
@@ -10,6 +10,7 @@ import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.EventBus import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb import io.legado.app.data.appDb
import io.legado.app.data.entities.Book import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup import io.legado.app.data.entities.BookGroup
@@ -18,6 +19,7 @@ import io.legado.app.data.repository.BookGroupRepository
import io.legado.app.data.repository.BookRepository import io.legado.app.data.repository.BookRepository
import io.legado.app.data.repository.BookSourceRepository import io.legado.app.data.repository.BookSourceRepository
import io.legado.app.data.repository.BookshelfRepository import io.legado.app.data.repository.BookshelfRepository
import io.legado.app.data.repository.SettingsRepository
import io.legado.app.data.repository.UploadRepository import io.legado.app.data.repository.UploadRepository
import io.legado.app.domain.usecase.AddBookUseCase import io.legado.app.domain.usecase.AddBookUseCase
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
@@ -81,6 +83,7 @@ class BookshelfViewModel(
private val bookGroupRepository: BookGroupRepository, private val bookGroupRepository: BookGroupRepository,
private val bookSourceRepository: BookSourceRepository, private val bookSourceRepository: BookSourceRepository,
private val bookshelfRepository: BookshelfRepository, private val bookshelfRepository: BookshelfRepository,
private val settingsRepository: SettingsRepository,
private val uploadRepository: UploadRepository, private val uploadRepository: UploadRepository,
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase, private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase, private val updateBooksGroupUseCase: UpdateBooksGroupUseCase,
@@ -110,6 +113,13 @@ class BookshelfViewModel(
val sortOrder: Int val sortOrder: Int
) )
private data class GroupBooksConfig(
val groups: List<BookGroup>,
val sortConfig: BookshelfSortConfig,
val privacyModeEnabled: Boolean,
val privateGroupMask: Long
)
private fun readSortConfig() = BookshelfSortConfig( private fun readSortConfig() = BookshelfSortConfig(
sort = BookshelfConfig.bookshelfSort, sort = BookshelfConfig.bookshelfSort,
sortOrder = BookshelfConfig.bookshelfSortOrder sortOrder = BookshelfConfig.bookshelfSortOrder
@@ -142,7 +152,40 @@ class BookshelfViewModel(
protected val _eventChannel = Channel<BaseRuleEvent>() protected val _eventChannel = Channel<BaseRuleEvent>()
val events = _eventChannel.receiveAsFlow() val events = _eventChannel.receiveAsFlow()
val groupsFlow: SharedFlow<List<BookGroup>> = bookGroupRepository.flowShow() val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val privacyModeFlow: StateFlow<Boolean> = settingsRepository
.getBoolean(PreferKey.bookshelfPrivacyMode, true)
.stateIn(viewModelScope, SharingStarted.Eagerly, true)
private val privateGroupMaskFlow: StateFlow<Long> = allGroupsFlow
.map { groups ->
groups.asSequence()
.filter {
(it.groupId > 0L || it.groupId == Long.MIN_VALUE) && it.isPrivate
}
.fold(0L) { mask, group -> mask or group.groupId }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0L)
private val allBooksIncludingPrivateFlow = bookRepository.flowBookShelfIncludingPrivate()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
private val displayedAllBookCountFlow = combine(
bookRepository.flowAllBookShelfCount(),
allBooksIncludingPrivateFlow,
privacyModeFlow
) { publicCount, allBooks, privacyModeEnabled ->
if (privacyModeEnabled) publicCount else allBooks.size
}
val groupsFlow: SharedFlow<List<BookGroup>> = combine(
bookGroupRepository.flowShow(),
privacyModeFlow
) { groups, privacyModeEnabled ->
if (privacyModeEnabled) groups.filterNot { it.isPrivate } else groups
}
.onEach { .onEach {
if (it.isNotEmpty()) { if (it.isNotEmpty()) {
isInitialLoadingFlow.value = false isInitialLoadingFlow.value = false
@@ -150,8 +193,21 @@ class BookshelfViewModel(
} }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll() private fun displayedBooksFlow(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) groupId: Long,
privacyModeEnabled: Boolean
): Flow<List<BookShelfItem>> {
return if (
!privacyModeEnabled &&
(groupId == BookGroup.IdAll || groupId > 0L || groupId == Long.MIN_VALUE)
) {
allBooksIncludingPrivateFlow.map { books ->
if (groupId == BookGroup.IdAll) books else books.filter { (it.group and groupId) != 0L }
}
} else {
bookRepository.flowBookShelfByGroup(groupId)
}
}
private data class GroupPreviewState( private data class GroupPreviewState(
val previews: ImmutableMap<Long, ImmutableList<BookUiItem>>, val previews: ImmutableMap<Long, ImmutableList<BookUiItem>>,
@@ -180,38 +236,44 @@ class BookshelfViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfGroupSelectorState()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfGroupSelectorState())
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val booksFlow: Flow<List<BookUiItem>> = groupIdFlow val booksFlow: Flow<List<BookUiItem>> = combine(
.flatMapLatest { groupId -> groupIdFlow,
privacyModeFlow
) { groupId, privacyModeEnabled -> groupId to privacyModeEnabled }
.flatMapLatest { (groupId, privacyModeEnabled) ->
combine( combine(
bookRepository.flowBookShelfByGroup(groupId), displayedBooksFlow(groupId, privacyModeEnabled),
groupsFlow, allGroupsFlow,
sortConfigFlow sortConfigFlow,
) { list, groups, sortConfig -> privateGroupMaskFlow
) { list, groups, sortConfig, privateGroupMask ->
bookshelfRepository.sortBooks( bookshelfRepository.sortBooks(
list, list,
groups.find { it.groupId == groupId }, groups.find { it.groupId == groupId },
sortConfig.sort, sortConfig.sort,
sortConfig.sortOrder sortConfig.sortOrder
).map { it.toUiItem() } ).map { it.toUiItem(privateGroupMask) }
} }
}.distinctUntilChanged().flowOn(Dispatchers.Default) }.distinctUntilChanged().flowOn(Dispatchers.Default)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
private val allGroupBooksImmutableFlow: Flow<ImmutableMap<Long, ImmutableList<BookUiItem>>> = private val allGroupBooksImmutableFlow: Flow<ImmutableMap<Long, ImmutableList<BookUiItem>>> =
combine(groupsFlow, sortConfigFlow) { groups, sortConfig -> combine(groupsFlow, sortConfigFlow, privacyModeFlow, privateGroupMaskFlow) {
groups to sortConfig groups, sortConfig, privacyModeEnabled, privateGroupMask ->
}.flatMapLatest { (groups, sortConfig) -> GroupBooksConfig(groups, sortConfig, privacyModeEnabled, privateGroupMask)
}.flatMapLatest { config ->
val groups = config.groups
if (groups.isEmpty()) { if (groups.isEmpty()) {
flowOf(persistentMapOf()) flowOf(persistentMapOf())
} else { } else {
val flows = groups.map { group -> val flows = groups.map { group ->
bookRepository.flowBookShelfByGroup(group.groupId).map { books -> displayedBooksFlow(group.groupId, config.privacyModeEnabled).map { books ->
group.groupId to bookshelfRepository.sortBooks( group.groupId to bookshelfRepository.sortBooks(
books, books,
group, group,
sortConfig.sort, config.sortConfig.sort,
sortConfig.sortOrder config.sortConfig.sortOrder
).map { it.toUiItem() }.toImmutableList() ).map { it.toUiItem(config.privateGroupMask) }.toImmutableList()
} }
} }
combine(flows) { results -> combine(flows) { results ->
@@ -256,7 +318,7 @@ class BookshelfViewModel(
groupsFlow, groupsFlow,
bookGroupStyleFlow, bookGroupStyleFlow,
bookRepository.flowSystemGroupCounts(), bookRepository.flowSystemGroupCounts(),
bookRepository.flowAllBookShelfCount() displayedAllBookCountFlow
) { groups, bookGroupStyle, systemCounts, totalCount -> ) { groups, bookGroupStyle, systemCounts, totalCount ->
DataForPreviews( DataForPreviews(
groups, groups,
@@ -276,7 +338,9 @@ class BookshelfViewModel(
flowOf(GroupPreviewState(persistentMapOf(), persistentMapOf(), allBookCount)) flowOf(GroupPreviewState(persistentMapOf(), persistentMapOf(), allBookCount))
} else { } else {
val groupFlows = groups.map { group -> val groupFlows = groups.map { group ->
val countFlow: Flow<Int> = if (group.groupId > 0) { val countFlow: Flow<Int> = if (
group.groupId > 0L || group.groupId == Long.MIN_VALUE
) {
bookRepository.flowUserGroupBookCount(group.groupId) bookRepository.flowUserGroupBookCount(group.groupId)
} else { } else {
flowOf(systemCountsMap[group.groupId] ?: 0) flowOf(systemCountsMap[group.groupId] ?: 0)
@@ -372,7 +436,7 @@ class BookshelfViewModel(
GroupPreviewState(persistentMapOf(), persistentMapOf(), 0) GroupPreviewState(persistentMapOf(), persistentMapOf(), 0)
) )
private val dataStateFlow = combine( private val dataCoreFlow = combine(
booksFlow, booksFlow,
groupsFlow, groupsFlow,
allGroupsFlow, allGroupsFlow,
@@ -380,14 +444,21 @@ class BookshelfViewModel(
internalStateFlow internalStateFlow
) { books, groups, allGroups, previews, internal -> ) { books, groups, allGroups, previews, internal ->
BookshelfDataCore(books, groups, allGroups, previews, internal) BookshelfDataCore(books, groups, allGroups, previews, internal)
}.combine(allGroupBooksImmutableFlow) { core, allGroupBooks -> }
private val dataStateFlow = combine(
dataCoreFlow,
allGroupBooksImmutableFlow,
privacyModeFlow
) { core, allGroupBooks, privacyModeEnabled ->
BookshelfDataState( BookshelfDataState(
books = core.books, books = core.books,
groups = core.groups.map { it.toBookGroupUi() }, groups = core.groups.map { it.toBookGroupUi() },
allGroups = core.allGroups.map { it.toBookGroupUi() }, allGroups = core.allGroups.map { it.toBookGroupUi() },
previews = core.previews, previews = core.previews,
internal = core.internal, internal = core.internal,
allGroupBooks = allGroupBooks allGroupBooks = allGroupBooks,
privacyModeEnabled = privacyModeEnabled
) )
} }
@@ -405,7 +476,8 @@ class BookshelfViewModel(
val allGroups: List<BookGroupUi>, val allGroups: List<BookGroupUi>,
val previews: GroupPreviewState, val previews: GroupPreviewState,
val internal: InternalState, val internal: InternalState,
val allGroupBooks: ImmutableMap<Long, ImmutableList<BookUiItem>> val allGroupBooks: ImmutableMap<Long, ImmutableList<BookUiItem>>,
val privacyModeEnabled: Boolean
) )
val uiState: StateFlow<BookshelfUiState> = combine( val uiState: StateFlow<BookshelfUiState> = combine(
@@ -459,6 +531,7 @@ class BookshelfViewModel(
bookGroupStyle = interaction.bookGroupStyle, bookGroupStyle = interaction.bookGroupStyle,
bookshelfSort = internal.sortConfig.sort, bookshelfSort = internal.sortConfig.sort,
bookshelfSortOrder = internal.sortConfig.sortOrder, bookshelfSortOrder = internal.sortConfig.sortOrder,
privacyModeEnabled = data.privacyModeEnabled,
title = title, title = title,
subtitle = when { subtitle = when {
interaction.isEditMode -> { interaction.isEditMode -> {
@@ -506,6 +579,14 @@ class BookshelfViewModel(
viewModelScope.launch { viewModelScope.launch {
groupPreviewsFlow.collect { groupPreviewsStateFlow.value = it } groupPreviewsFlow.collect { groupPreviewsStateFlow.value = it }
} }
viewModelScope.launch {
combine(privacyModeFlow, allGroupsFlow, groupIdFlow) {
privacyModeEnabled, groups, groupId ->
privacyModeEnabled && groups.any { it.groupId == groupId && it.isPrivate }
}.distinctUntilChanged().filter { it }.collect {
changeGroup(BookGroup.IdAll)
}
}
viewModelScope.launch { viewModelScope.launch {
combine(booksFlow, selectedGroupCanReorderFlow) { books, canReorderBooks -> combine(booksFlow, selectedGroupCanReorderFlow) { books, canReorderBooks ->
books to canReorderBooks books to canReorderBooks
@@ -584,6 +665,12 @@ class BookshelfViewModel(
clearSelection() clearSelection()
} }
fun setPrivacyMode(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.putBoolean(PreferKey.bookshelfPrivacyMode, enabled)
}
}
fun showOverlay(overlay: BookshelfOverlay) { fun showOverlay(overlay: BookshelfOverlay) {
activeOverlayFlow.value = overlay activeOverlayFlow.value = overlay
} }
@@ -466,9 +466,11 @@ private fun CoverTextOverlay(
val startX = viewWidth * 0.84f val startX = viewWidth * 0.84f
val fm = paint.fontMetrics val fm = paint.fontMetrics
val charHeight = fm.bottom - fm.top val charHeight = fm.bottom - fm.top
var startY = viewHeight * 0.16f - (author.length * charHeight) val visibleAuthor = author.takeLast(
startY = startY.coerceAtLeast(viewHeight * 0.2f) ((viewHeight * 0.35f) / charHeight).toInt().coerceAtLeast(1)
author.forEach { char -> )
var startY = viewHeight * 0.92f - (visibleAuthor.length - 1) * charHeight
visibleAuthor.forEach { char ->
nativeCanvas.drawText(char.toString(), startX, startY, paint) nativeCanvas.drawText(char.toString(), startX, startY, paint)
startY += charHeight startY += charHeight
} }
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.text.hiddenAttributeUnderline
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Check import top.yukonga.miuix.kmp.icon.basic.Check
import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -50,6 +51,7 @@ fun RoundDropdownMenuItem(
leadingIcon: @Composable (() -> Unit)? = null, leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null,
enabled: Boolean = true, enabled: Boolean = true,
hidden: Boolean = false,
contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
) { ) {
@@ -88,7 +90,9 @@ fun RoundDropdownMenuItem(
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
MiuixText( MiuixText(
modifier = Modifier.widthIn(max = 200.dp), modifier = Modifier
.widthIn(max = 200.dp)
.hiddenAttributeUnderline(hidden, textColor),
text = text, text = text,
fontSize = MiuixTheme.textStyles.body1.fontSize, fontSize = MiuixTheme.textStyles.body1.fontSize,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -157,7 +161,9 @@ fun RoundDropdownMenuItem(
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
Text( Text(
modifier = Modifier.widthIn(max = 200.dp), modifier = Modifier
.widthIn(max = 200.dp)
.hiddenAttributeUnderline(hidden, contentColor),
text = text, text = text,
style = LegadoTheme.typography.labelLargeEmphasized, style = LegadoTheme.typography.labelLargeEmphasized,
color = contentColor color = contentColor
@@ -12,6 +12,7 @@ import androidx.compose.ui.unit.dp
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.ThemeResolver import io.legado.app.ui.theme.ThemeResolver
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.text.hiddenAttributeUnderline
import top.yukonga.miuix.kmp.basic.TabRowDefaults import top.yukonga.miuix.kmp.basic.TabRowDefaults
import top.yukonga.miuix.kmp.basic.TabRowWithContour import top.yukonga.miuix.kmp.basic.TabRowWithContour
@@ -21,11 +22,12 @@ fun AppTabRow(
selectedTabIndex: Int, selectedTabIndex: Int,
onTabSelected: (Int) -> Unit, onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isScrollable: Boolean = true isScrollable: Boolean = true,
hiddenTabIndices: Set<Int> = emptySet()
) { ) {
val composeEngine = LegadoTheme.composeEngine val composeEngine = LegadoTheme.composeEngine
if (ThemeResolver.isMiuixEngine(composeEngine)) { if (ThemeResolver.isMiuixEngine(composeEngine) && hiddenTabIndices.isEmpty()) {
TabRowWithContour( TabRowWithContour(
tabs = tabTitles, tabs = tabTitles,
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,
@@ -50,7 +52,8 @@ fun AppTabRow(
AppTab( AppTab(
selected = selectedTabIndex == index, selected = selectedTabIndex == index,
onClick = { onTabSelected(index) }, onClick = { onTabSelected(index) },
title = title title = title,
hidden = index in hiddenTabIndices
) )
} }
} }
@@ -65,7 +68,8 @@ fun AppTabRow(
AppTab( AppTab(
selected = selectedTabIndex == index, selected = selectedTabIndex == index,
onClick = { onTabSelected(index) }, onClick = { onTabSelected(index) },
title = title title = title,
hidden = index in hiddenTabIndices
) )
} }
} }
@@ -77,7 +81,8 @@ fun AppTabRow(
private fun AppTab( private fun AppTab(
selected: Boolean, selected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
title: String title: String,
hidden: Boolean
) { ) {
Tab( Tab(
selected = selected, selected = selected,
@@ -88,7 +93,13 @@ private fun AppTab(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = LegadoTheme.typography.labelLargeEmphasized, style = LegadoTheme.typography.labelLargeEmphasized,
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier
.padding(horizontal = 8.dp)
.hiddenAttributeUnderline(
hidden,
if (selected) LegadoTheme.colorScheme.primary
else LegadoTheme.colorScheme.onSurfaceVariant
),
color = if (selected) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant color = if (selected) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.onSurfaceVariant
) )
} }
@@ -0,0 +1,25 @@
package io.legado.app.ui.widget.components.text
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.unit.dp
fun Modifier.hiddenAttributeUnderline(
hidden: Boolean,
color: Color
): Modifier = if (!hidden) {
this
} else {
drawBehind {
val strokeWidth = 1.dp.toPx()
drawLine(
color = color,
start = androidx.compose.ui.geometry.Offset(0f, size.height - strokeWidth),
end = androidx.compose.ui.geometry.Offset(size.width, size.height - strokeWidth),
strokeWidth = strokeWidth,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 3.dp.toPx()))
)
}
}
+7 -6
View File
@@ -1,7 +1,7 @@
<resources> <resources>
<!--App--> <!--App-->
<string name="app_name">阅读</string> <string name="app_name">且看</string>
<string name="app_name_a">阅读·A</string> <string name="app_name_a">且看·A</string>
<string name="receiving_shared_label">阅读 · 搜索</string> <string name="receiving_shared_label">阅读 · 搜索</string>
<string name="tip_perm_request_storage">阅读需要访问存储卡权限,请前往“设置”—“应用权限”,打开所需权限</string> <string name="tip_perm_request_storage">阅读需要访问存储卡权限,请前往“设置”—“应用权限”,打开所需权限</string>
@@ -134,7 +134,7 @@
<string name="change_origin">换源</string> <string name="change_origin">换源</string>
<string name="about_description">全新设计的阅读 3.0</string> <string name="about_description">全新设计的阅读 3.0</string>
<string name="app_share_description"> <string name="app_share_description">
阅读 3.0 下载地址:\nhttps://github.com/gedoor/legado/releases 且看 3.0 下载地址:\nhttps://gitea.yamby.cn/yusheng/QieKan-3.0/releases
</string> </string>
<string name="version_name">版本 %s</string> <string name="version_name">版本 %s</string>
<string name="pt_background_verification">后台校验书源</string> <string name="pt_background_verification">后台校验书源</string>
@@ -1295,9 +1295,10 @@
<string name="file_manage_summary">管理私有文件夹的文件</string> <string name="file_manage_summary">管理私有文件夹的文件</string>
<string name="create_folder">创建文件夹</string> <string name="create_folder">创建文件夹</string>
<string name="allow_drop_down_refresh">允许下拉刷新</string> <string name="allow_drop_down_refresh">允许下拉刷新</string>
<string name="private_group">设为隐分组</string> <string name="private_group">设为隐分组</string>
<string name="disable_private_group">关闭隐分组</string> <string name="disable_private_group">关闭隐分组</string>
<string name="private_group_desc">开启后,本分组内的书籍将从公开书架隐藏</string> <string name="private_group_desc">开启后,本分组及其中书籍将在隐私模式下隐藏</string>
<string name="privacy_mode">隐私模式</string>
<string name="text_underline">文字下划线</string> <string name="text_underline">文字下划线</string>
<string name="select_new_source">选中新增源</string> <string name="select_new_source">选中新增源</string>
<string name="select_update_source">选中更新源</string> <string name="select_update_source">选中更新源</string>
+7 -6
View File
@@ -1,7 +1,7 @@
<resources> <resources>
<!--App--> <!--App-->
<string name="app_name">閲讀</string> <string name="app_name">且看</string>
<string name="app_name_a">閲讀·A</string> <string name="app_name_a">且看·A</string>
<string name="receiving_shared_label">閲讀·搜尋</string> <string name="receiving_shared_label">閲讀·搜尋</string>
<string name="tip_perm_request_storage">閲讀需要訪問存儲卡權限,請前往「設定」—「應用程式權限」—開啟所需要的權限</string> <string name="tip_perm_request_storage">閲讀需要訪問存儲卡權限,請前往「設定」—「應用程式權限」—開啟所需要的權限</string>
@@ -99,7 +99,7 @@
\u3000\u3000這是一款使用 Kotlin 全新開發的開源的閲讀應用程式,歡迎你的加入。 \u3000\u3000這是一款使用 Kotlin 全新開發的開源的閲讀應用程式,歡迎你的加入。
</string> </string>
<string name="app_share_description"> <string name="app_share_description">
閲讀3.0下載地址:\nhttps://github.com/gedoor/legado/releases 且看 3.0 下載地址:\nhttps://gitea.yamby.cn/yusheng/QieKan-3.0/releases
</string> </string>
<string name="version_name">Version %s</string> <string name="version_name">Version %s</string>
<string name="pt_background_verification">後臺校驗書源</string> <string name="pt_background_verification">後臺校驗書源</string>
@@ -1145,9 +1145,10 @@
<string name="file_manage_summary">管理私有文件夹的文件</string> <string name="file_manage_summary">管理私有文件夹的文件</string>
<string name="create_folder">创建文件夹</string> <string name="create_folder">创建文件夹</string>
<string name="allow_drop_down_refresh">允许下拉刷新</string> <string name="allow_drop_down_refresh">允许下拉刷新</string>
<string name="private_group">設為隱分組</string> <string name="private_group">設為隱分組</string>
<string name="disable_private_group">關閉隱分組</string> <string name="disable_private_group">關閉隱分組</string>
<string name="private_group_desc">開啟後,本分組內嘅書籍將從公開書架隱藏</string> <string name="private_group_desc">開啟後,本分組及其中書籍會在私隱模式下隱藏</string>
<string name="privacy_mode">私隱模式</string>
<string name="text_underline">文字下划线</string> <string name="text_underline">文字下划线</string>
<string name="select_new_source">选中新增源</string> <string name="select_new_source">选中新增源</string>
<string name="select_update_source">选中更新源</string> <string name="select_update_source">选中更新源</string>
+7 -6
View File
@@ -1,7 +1,7 @@
<resources> <resources>
<!--App--> <!--App-->
<string name="app_name">閱讀</string> <string name="app_name">且看</string>
<string name="app_name_a">閱讀·A</string> <string name="app_name_a">且看·A</string>
<string name="receiving_shared_label">閱讀·搜尋</string> <string name="receiving_shared_label">閱讀·搜尋</string>
<string name="tip_perm_request_storage">閱讀需要存取記憶卡權限,請前往「設定」—「應用程式權限」—打開所需權限</string> <string name="tip_perm_request_storage">閱讀需要存取記憶卡權限,請前往「設定」—「應用程式權限」—打開所需權限</string>
@@ -101,7 +101,7 @@
\u3000\u3000這是一款使用Kotlin全新開發的開源的閱讀軟體,歡迎您的加入。 \u3000\u3000這是一款使用Kotlin全新開發的開源的閱讀軟體,歡迎您的加入。
</string> </string>
<string name="app_share_description"> <string name="app_share_description">
閱讀3.0下載網址:\nhttps://github.com/gedoor/legado/releases 且看 3.0 下載網址:\nhttps://gitea.yamby.cn/yusheng/QieKan-3.0/releases
</string> </string>
<string name="version_name">Version %s</string> <string name="version_name">Version %s</string>
<string name="pt_background_verification">後臺校驗書源</string> <string name="pt_background_verification">後臺校驗書源</string>
@@ -1148,9 +1148,10 @@
<string name="file_manage_summary">管理私有文件夹的文件</string> <string name="file_manage_summary">管理私有文件夹的文件</string>
<string name="create_folder">创建文件夹</string> <string name="create_folder">创建文件夹</string>
<string name="allow_drop_down_refresh">允许下拉刷新</string> <string name="allow_drop_down_refresh">允许下拉刷新</string>
<string name="private_group">設為隱分組</string> <string name="private_group">設為隱分組</string>
<string name="disable_private_group">關閉隱分組</string> <string name="disable_private_group">關閉隱分組</string>
<string name="private_group_desc">開啟後,本分組內的書籍將從公開書架隱藏</string> <string name="private_group_desc">開啟後,本分組及其中書籍將在隱私模式下隱藏</string>
<string name="privacy_mode">隱私模式</string>
<string name="text_underline">文字下划线</string> <string name="text_underline">文字下划线</string>
<string name="select_new_source">选中新增源</string> <string name="select_new_source">选中新增源</string>
<string name="select_update_source">选中更新源</string> <string name="select_update_source">选中更新源</string>
+2 -2
View File
@@ -5,10 +5,10 @@
<string name="email" translatable="false">gekunfei@live.com</string> <string name="email" translatable="false">gekunfei@live.com</string>
<string name="contributors_url" translatable="false">https://github.com/gedoor/legado/graphs/contributors</string> <string name="contributors_url" translatable="false">https://github.com/gedoor/legado/graphs/contributors</string>
<string name="github_url" translatable="false">https://github.com/HapeLee/legado-with-MD3</string> <string name="github_url" translatable="false">https://gitea.yamby.cn/yusheng/QieKan-3.0</string>
<string name="legado_url" translatable="false">https://gedoor.github.io/</string> <string name="legado_url" translatable="false">https://gedoor.github.io/</string>
<string name="http_ip" translatable="false">http://%1$s:%2$d</string> <string name="http_ip" translatable="false">http://%1$s:%2$d</string>
<string name="git_hub" translatable="false">GitHub</string> <string name="git_hub" translatable="false">Gitea</string>
<string name="diy_edit_source_group_title" translatable="false">【%s】</string> <string name="diy_edit_source_group_title" translatable="false">【%s】</string>
<string name="vip_title" translatable="false">🔒%s</string> <string name="vip_title" translatable="false">🔒%s</string>
<string name="payed_title" translatable="false">🔓%s</string> <string name="payed_title" translatable="false">🔓%s</string>
+7 -6
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!--App--> <!--App-->
<string name="app_name">Legado</string> <string name="app_name">且看</string>
<string name="app_name_a">Legado·A</string> <string name="app_name_a">且看·A</string>
<string name="receiving_shared_label">Legado·search</string> <string name="receiving_shared_label">Legado·search</string>
<string name="tip_perm_request_storage">Legado needs storage access to find and read books. Please go "App Settings" to allow "Storage Permission".</string> <string name="tip_perm_request_storage">Legado needs storage access to find and read books. Please go "App Settings" to allow "Storage Permission".</string>
@@ -155,7 +155,7 @@
\u3000\u3000 This is an open source reading software newly developed by Kotlin, welcome to join us. \u3000\u3000 This is an open source reading software newly developed by Kotlin, welcome to join us.
</string> </string>
<string name="app_share_description"> <string name="app_share_description">
Legado (YueDu 3.0) download link\n https://github.com/gedoor/legado/releases QieKan 3.0 download link\n https://gitea.yamby.cn/yusheng/QieKan-3.0/releases
</string> </string>
<string name="version_name">Version %s</string> <string name="version_name">Version %s</string>
<string name="pt_background_verification">Background-verification</string> <string name="pt_background_verification">Background-verification</string>
@@ -1336,9 +1336,10 @@
<string name="file_manage_summary">Managing files in the application\'s private folder</string> <string name="file_manage_summary">Managing files in the application\'s private folder</string>
<string name="create_folder">Create Folder</string> <string name="create_folder">Create Folder</string>
<string name="allow_drop_down_refresh">Allow drop-down refresh</string> <string name="allow_drop_down_refresh">Allow drop-down refresh</string>
<string name="private_group">Set as private group</string> <string name="private_group">Set as hidden group</string>
<string name="disable_private_group">Disable private group</string> <string name="disable_private_group">Disable hidden group</string>
<string name="private_group_desc">After enabling, books in this group are hidden from the public bookshelf</string> <string name="private_group_desc">The group and its books are hidden while privacy mode is enabled</string>
<string name="privacy_mode">Privacy mode</string>
<string name="text_underline">Text underline</string> <string name="text_underline">Text underline</string>
<string name="select_new_source">Select new source</string> <string name="select_new_source">Select new source</string>
<string name="select_update_source">Select update source</string> <string name="select_update_source">Select update source</string>
@@ -0,0 +1,69 @@
package io.legado.app.data.repository
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookGroup
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BookPrivacyStateTest {
private val hiddenGroup = BookGroup(groupId = 32L, isPrivate = true)
private val visibleBook = Book(
bookUrl = "visible-url",
name = "MS攻略",
author = "公开作者",
group = 0L,
)
private val hiddenBook = Book(
bookUrl = "hidden-url",
name = "秘密书",
author = "隐藏作者",
group = 32L,
)
@Test
fun `privacy mode only exposes records linked to visible books`() {
val state = BookPrivacyState.create(
books = listOf(visibleBook, hiddenBook),
groups = listOf(hiddenGroup),
privacyModeEnabled = true,
)
assertTrue(state.isReadRecordVisible("visible-url", "旧标题"))
assertTrue(state.isReadRecordVisible(null, "MS 攻略", "公开作者"))
assertTrue(state.isReadRecordVisible("stale-url", "MS 攻略", "公开作者"))
assertFalse(state.isReadRecordVisible("hidden-url", "任意标题"))
assertFalse(state.isReadRecordVisible(null, "秘密书"))
assertFalse(state.isReadRecordVisible("stale-url", "秘密书"))
assertFalse(state.isReadRecordVisible(null, "无法关联的历史别名"))
}
@Test
fun `author disambiguates visible and hidden books with the same title`() {
val state = BookPrivacyState.create(
books = listOf(
visibleBook.copy(name = "同名书"),
hiddenBook.copy(name = "同名书"),
),
groups = listOf(hiddenGroup),
privacyModeEnabled = true,
)
assertTrue(state.isReadRecordVisible("stale-url", "同名书", "公开作者"))
assertFalse(state.isReadRecordVisible("stale-url", "同名书", "隐藏作者"))
assertFalse(state.isReadRecordVisible("stale-url", "同名书"))
}
@Test
fun `privacy mode off exposes all historical records`() {
val state = BookPrivacyState.create(
books = listOf(visibleBook, hiddenBook),
groups = listOf(hiddenGroup),
privacyModeEnabled = false,
)
assertTrue(state.isReadRecordVisible("hidden-url", "秘密书"))
assertTrue(state.isReadRecordVisible(null, "无法关联的历史别名"))
}
}
@@ -0,0 +1,35 @@
package io.legado.app.data.repository
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.datastore.preferences.core.stringPreferencesKey
import io.legado.app.constant.PreferKey
import org.junit.Assert.assertEquals
import org.junit.Test
class SettingsRepositoryCompatibilityTest {
@Test
fun normalizeLegacyBrightnessVisibilityBoolean() {
assertEquals("1", normalizeLegacyPreferenceValue(PreferKey.showBrightnessView, true))
assertEquals("0", normalizeLegacyPreferenceValue(PreferKey.showBrightnessView, false))
assertEquals("2", normalizeLegacyPreferenceValue(PreferKey.showBrightnessView, "2"))
}
@Test
fun readLegacyBrightnessVisibilityBooleanAsString() {
val enabled = mutablePreferencesOf(
booleanPreferencesKey(PreferKey.showBrightnessView) to true
)
val disabled = mutablePreferencesOf(
booleanPreferencesKey(PreferKey.showBrightnessView) to false
)
val vertical = mutablePreferencesOf(
stringPreferencesKey(PreferKey.showBrightnessView) to "2"
)
assertEquals("1", enabled.getStringCompat(PreferKey.showBrightnessView, "0"))
assertEquals("0", disabled.getStringCompat(PreferKey.showBrightnessView, "1"))
assertEquals("2", vertical.getStringCompat(PreferKey.showBrightnessView, "1"))
}
}
@@ -0,0 +1,33 @@
package io.legado.app.ui.main.bookshelf
import org.junit.Assert.assertTrue
import org.junit.Test
class BookShelfItemTest {
@Test
fun `marks book in highest-bit private group as hidden`() {
val book = BookShelfItem(
bookUrl = "book-url",
name = "book",
author = "author",
origin = "origin",
originName = "origin-name",
coverUrl = null,
customCoverUrl = null,
durChapterTitle = null,
durChapterTime = 0L,
durChapterPos = 0,
latestChapterTitle = null,
latestChapterTime = 0L,
lastCheckCount = 0,
totalChapterNum = 0,
durChapterIndex = 0,
type = 0,
group = Long.MIN_VALUE,
order = 0,
)
assertTrue(book.toUiItem(privateGroupMask = Long.MIN_VALUE).isHidden)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
VERSION_MAJOR=3 VERSION_MAJOR=3
VERSION_MINOR=26 VERSION_MINOR=26
VERSION_PATCH=13 VERSION_PATCH=14
VERSION_SUFFIX=1 VERSION_SUFFIX=1
# 1 = Pre, 0 = Release # 1 = Pre, 0 = Release