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"
minSdk = 26
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
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 {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
<resources>
<string name="app_name">阅读·D</string>
<string name="receiving_shared_label">阅读·D·搜索</string>
<string name="app_name">且看</string>
<string name="receiving_shared_label">且看·搜索</string>
</resources>
+2 -2
View File
@@ -1,4 +1,4 @@
<resources>
<string name="app_name">legado·D</string>
<string name="receiving_shared_label">legado·D·search</string>
<string name="app_name">且看</string>
<string name="receiving_shared_label">且看·搜索</string>
</resources>
@@ -11,6 +11,7 @@ object PreferKey {
const val showUnread = "showUnread"
const val showUnreadNew = "showUnreadNew"
const val bookGroupStyle = "bookGroupStyle"
const val bookshelfPrivacyMode = "bookshelfPrivacyMode"
const val useDefaultCover = "useDefaultCover"
const val loadCoverOnlyWifi = "loadCoverOnlyWifi"
const val coverShowName = "coverShowName"
@@ -90,7 +90,7 @@ val appDb by lazy {
}
@Database(
version = 93,
version = 94,
exportSchema = true,
entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -153,7 +153,8 @@ val appDb by lazy {
AutoMigration(from = 89, to = 90),
AutoMigration(from = 90, to = 91),
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() {
@@ -496,4 +496,35 @@ object DatabaseMigrations {
columnName = "enabledReview"
)
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
)
private const val USER_GROUP_ID_FILTER =
"(groupId > 0 OR groupId = ${Long.MIN_VALUE})"
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 =
"(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 =
"(`group` = 0 OR (`group` & $PRIVATE_GROUP_MASK) = 0)"
@@ -162,6 +165,21 @@ interface BookDao {
)
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")
fun flowAudio(): Flow<List<Book>>
@@ -235,7 +253,7 @@ interface BookDao {
@Query(
"""
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>>
@@ -276,7 +294,7 @@ interface BookDao {
@Query(
"""
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>>
@@ -314,7 +332,7 @@ interface BookDao {
)
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>>
@Query(
@@ -343,7 +361,7 @@ interface BookDao {
kind,
wordCount
FROM books
WHERE (`group` & :group) > 0
WHERE (`group` & :group) != 0
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>>
@Query("SELECT * FROM books WHERE (`group` & :group) > 0")
@Query("SELECT * FROM books WHERE (`group` & :group) != 0")
fun getBooksByGroup(group: Long): List<Book>
@Query("SELECT * FROM books WHERE `name` in (:names)")
@@ -703,6 +721,15 @@ interface BookDao {
@Query("SELECT * FROM books WHERE name = :name and author = :author")
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
where origin == bookSourceUrl and origin not like '${BookType.localTag}%'
and origin not like '${BookType.webDavTag}%'""")
@@ -774,7 +801,7 @@ interface BookDao {
@Query("update books set `group` = :newGroupId where `group` = :oldGroupId")
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)
@Query("delete from books where type & ${BookType.notShelf} > 0")
@@ -818,7 +845,7 @@ interface BookDao {
@Query(
"""
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)
"""
)
@@ -1096,7 +1123,7 @@ interface BookDao {
type, `group`, `order`, canUpdate,
ifnull(customIntro, intro) as intro, kind, wordCount
FROM books
WHERE (`group` & :groupId) > 0
WHERE (`group` & :groupId) != 0
AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER)
ORDER BY durChapterTime DESC
LIMIT 10
@@ -27,16 +27,16 @@ interface BookGroupDao {
"""
with const as (
SELECT
COALESCE(SUM(CASE WHEN groupId > 0 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 = 0 THEN groupId ELSE 0 END), 0) sumPublicGroupId,
COALESCE(SUM(CASE WHEN (groupId > 0 OR groupId = ${Long.MIN_VALUE}) AND isPrivate = 1 THEN groupId ELSE 0 END), 0) sumPrivateGroupId
FROM book_groups
)
SELECT book_groups.* FROM book_groups join const
where show > 0
and (
(groupId >= 0 and exists (
((groupId >= 0 or groupId = ${Long.MIN_VALUE}) and exists (
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)
))
or groupId = ${BookGroup.IdAll}
@@ -124,13 +124,13 @@ interface BookGroupDao {
)
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>>
@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
@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
@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")
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>
@Insert(onConflict = OnConflictStrategy.REPLACE)
@@ -5,6 +5,7 @@ import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import io.legado.app.data.entities.readRecord.HomeRecentBookRow
import io.legado.app.data.entities.readRecord.ReadRecord
@@ -44,17 +45,26 @@ interface ReadRecordDao {
"""
WITH recent AS (
SELECT
bookName,
bookAuthor,
MAX(lastRead) AS lastRead
FROM readRecord
GROUP BY bookName, bookAuthor
ORDER BY lastRead DESC
LIMIT :limit
base.bookName,
base.bookAuthor,
(
SELECT latest.bookUrl
FROM readRecord AS latest
WHERE latest.bookName = base.bookName
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
recent.bookName AS recordName,
recent.bookAuthor AS recordAuthor,
recent.recordBookUrl AS recordBookUrl,
book.bookUrl AS bookUrl,
book.origin AS origin,
book.coverUrl AS coverUrl,
@@ -63,7 +73,14 @@ interface ReadRecordDao {
book.totalChapterNum AS totalChapterNum,
book.durChapterIndex AS chapterIndex
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
FROM books AS candidate
WHERE candidate.name = recent.bookName
@@ -71,10 +88,11 @@ interface ReadRecordDao {
ORDER BY candidate.durChapterTime DESC, candidate.bookUrl ASC
LIMIT 1
)
)
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")
fun getReadTime(bookName: String): Long?
@@ -85,6 +103,33 @@ interface ReadRecordDao {
@Query("SELECT * FROM readRecord WHERE deviceId = :deviceId AND bookName = :bookName AND bookAuthor = :bookAuthor")
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)
suspend fun insert(vararg readRecord: ReadRecord)
@@ -3,6 +3,7 @@ package io.legado.app.data.entities.readRecord
data class HomeRecentBookRow(
val recordName: String,
val recordAuthor: String,
val recordBookUrl: String?,
val bookUrl: String?,
val origin: String?,
val coverUrl: String?,
@@ -9,6 +9,7 @@ data class ReadRecord(
var bookName: String = "",
@ColumnInfo(defaultValue = "")
var bookAuthor: String = "",
var bookUrl: String? = null,
@ColumnInfo(defaultValue = "0")
var readTime: Long = 0L,
@ColumnInfo(defaultValue = "0")
@@ -12,6 +12,7 @@ data class ReadRecordDetail(
val bookName: String = "",
@ColumnInfo(defaultValue = "")
val bookAuthor: String = "",
val bookUrl: String? = null,
val date: String = "",
// 当天阅读总时长
@@ -13,6 +13,7 @@ data class ReadRecordSession(
val bookName: String = "",
@ColumnInfo(defaultValue = "")
val bookAuthor: String = "",
val bookUrl: String? = null,
// 一次阅读的开始/结束
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.MD5Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
class AiToolRepository(
@@ -27,7 +28,8 @@ class AiToolRepository(
private val bookmarkDao: BookmarkDao,
private val readRecordDao: ReadRecordDao,
private val aiArtifactDao: AiArtifactDao,
private val aiMemoryGateway: AiMemoryGateway
private val aiMemoryGateway: AiMemoryGateway,
private val bookPrivacyRepository: BookPrivacyRepository,
) : AiToolGateway {
override fun availableTools(): List<AiToolDefinition> = tools
@@ -175,11 +177,15 @@ class AiToolRepository(
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 date = args.string("date")?.trim().orEmpty()
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()
.filter {
query.isBlank() ||
@@ -200,6 +206,7 @@ class AiToolRepository(
val dailyDetails = readRecordDao.allDetail
.asSequence()
.filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor) &&
(date.isBlank() || it.date == date) &&
(query.isBlank() ||
it.bookName.contains(query, ignoreCase = true) ||
@@ -222,7 +229,7 @@ class AiToolRepository(
.toList()
return GSON.toJson(
mapOf(
"totalReadTimeMillis" to readRecordDao.all.sumOf { it.readTime },
"totalReadTimeMillis" to visibleRecords.sumOf { it.readTime },
"recentRecords" to records,
"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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class BookRepository(
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao
private val bookChapterDao: BookChapterDao,
private val bookPrivacyRepository: BookPrivacyRepository,
) {
fun getAllBooks(): Flow<List<Book>> {
return bookDao.flowAll()
}
suspend fun getBookCoverByNameAndAuthor(bookName: String, bookAuthor: String): String? {
return withContext(Dispatchers.IO) {
bookDao.getBook(bookName, bookAuthor)?.getDisplayCover()
fun getVisibleBooks(): Flow<List<Book>> {
return combine(bookDao.flowAll(), bookPrivacyRepository.observe()) { books, privacyState ->
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? {
val bookUrl = getVisibleBook(bookName, bookAuthor)?.bookUrl ?: return null
return withContext(Dispatchers.IO) {
val book = bookDao.getBook(bookName, bookAuthor)
val bookUrl = book?.bookUrl
if (bookUrl.isNullOrEmpty()) return@withContext null
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? {
return withContext(Dispatchers.IO) {
bookDao.getBook(bookUrl)
@@ -50,6 +66,12 @@ class BookRepository(
return bookDao.flowBookShelfByGroup(groupId)
}
fun flowBookShelfIncludingPrivate(): Flow<List<BookShelfItem>> {
return bookDao.flowBookShelfIncludingPrivate().map { books ->
books.filterNot { it.isNotShelf }
}
}
fun flowSystemGroupCounts(): Flow<List<GroupBookCount>> {
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.HomeReadingBook
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
class HomeDashboardRepository(
private val readRecordDao: ReadRecordDao,
private val localPreferencesRepository: LocalPreferencesRepository,
private val readRecordRepository: ReadRecordRepository,
private val bookPrivacyRepository: BookPrivacyRepository,
) : HomeDashboardGateway {
override fun observeTotalReadBooks(): Flow<Int> =
readRecordDao.observeTotalReadBookCount()
readRecordRepository.getTotalReadBookCount()
override fun observeTotalReadTime(): Flow<Long> =
readRecordDao.getTotalReadTime().map { it ?: 0L }
readRecordRepository.getTotalReadTime()
override fun observeReadTime(date: String): Flow<Long> =
readRecordDao.observeReadTimeByDate(date).map { it ?: 0L }
readRecordRepository.getReadTime(date)
override fun observeRecentBooks(limit: Int): Flow<List<HomeReadingBook>> =
readRecordDao.observeRecentHomeBooks(limit).map { rows ->
rows.map { row ->
override fun observeRecentBooks(limit: Int): Flow<List<HomeReadingBook>> {
return combine(
readRecordDao.observeRecentHomeBooks(),
bookPrivacyRepository.observe(),
) { rows, privacyState ->
rows.asSequence()
.filter { row ->
privacyState.isReadRecordVisible(
row.recordBookUrl,
row.recordName,
row.recordAuthor,
)
}
.take(limit)
.map { row ->
HomeReadingBook(
bookUrl = row.bookUrl,
name = row.recordName,
@@ -51,6 +66,8 @@ class HomeDashboardRepository(
},
)
}
.toList()
}
}
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.ReadRecordTimelineDay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.util.Date
import kotlin.math.max
@@ -18,6 +20,7 @@ import kotlin.math.min
class ReadRecordRepository(
private val dao: ReadRecordDao,
private val database: AppDatabase,
private val bookPrivacyRepository: BookPrivacyRepository,
) {
private fun getCurrentDeviceId(): String = ""
@@ -25,37 +28,73 @@ class ReadRecordRepository(
* 获取总阅读时长流
*/
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>> {
return if (query.isBlank()) {
val records = if (query.isBlank()) {
dao.getAllReadRecordsSortedByLastRead()
} else {
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>> {
return if (query.isBlank()) {
val details = if (query.isBlank()) {
dao.getAllDetails()
} else {
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>> {
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>> {
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>> {
@@ -72,15 +111,27 @@ class ReadRecordRepository(
}
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> {
val privacyState = bookPrivacyRepository.observe().first()
return dao.getReadRecordsByNameExcludingAuthor(
targetRecord.deviceId,
targetRecord.bookName,
targetRecord.bookAuthor
)
).filter {
privacyState.isReadRecordVisible(it.bookUrl, it.bookName, it.bookAuthor)
}
}
/**
@@ -104,6 +155,7 @@ class ReadRecordRepository(
if (existingRecord != null) {
dao.update(
existingRecord.copy(
bookUrl = session.bookUrl ?: existingRecord.bookUrl,
readTime = existingRecord.readTime + durationDelta,
lastRead = session.endTime
)
@@ -114,6 +166,7 @@ class ReadRecordRepository(
deviceId = session.deviceId,
bookName = session.bookName,
bookAuthor = session.bookAuthor,
bookUrl = session.bookUrl,
readTime = durationDelta,
lastRead = session.endTime
)
@@ -135,17 +188,22 @@ class ReadRecordRepository(
dateString
)
if (existingDetail != null) {
existingDetail.readTime += durationDelta
existingDetail.readWords += wordsDelta
existingDetail.firstReadTime = min(existingDetail.firstReadTime, session.startTime)
existingDetail.lastReadTime = max(existingDetail.lastReadTime, session.endTime)
dao.insertDetail(existingDetail)
dao.insertDetail(
existingDetail.copy(
bookUrl = session.bookUrl ?: existingDetail.bookUrl,
readTime = existingDetail.readTime + durationDelta,
readWords = existingDetail.readWords + wordsDelta,
firstReadTime = min(existingDetail.firstReadTime, session.startTime),
lastReadTime = max(existingDetail.lastReadTime, session.endTime),
)
)
} else {
dao.insertDetail(
ReadRecordDetail(
deviceId = session.deviceId,
bookName = session.bookName,
bookAuthor = session.bookAuthor,
bookUrl = session.bookUrl,
date = dateString,
readTime = durationDelta,
readWords = wordsDelta,
@@ -204,6 +262,7 @@ class ReadRecordRepository(
)
dao.insertDetail(
existingDetail?.copy(
bookUrl = existingDetail.bookUrl ?: remainingSessions.resolvedBookUrl(),
readTime = totalTime,
readWords = totalWords,
firstReadTime = firstRead,
@@ -212,6 +271,7 @@ class ReadRecordRepository(
deviceId = session.deviceId,
bookName = session.bookName,
bookAuthor = session.bookAuthor,
bookUrl = remainingSessions.resolvedBookUrl(),
date = dateString,
readTime = totalTime,
readWords = totalWords,
@@ -233,6 +293,7 @@ class ReadRecordRepository(
} else {
val totalTime = allRemainingSessions.sumOf { it.endTime - it.startTime }
val lastRead = allRemainingSessions.maxOf { it.endTime }
val bookUrl = allRemainingSessions.resolvedBookUrl()
val existingRecord = dao.getReadRecord(deviceId, bookName, bookAuthor)
if (existingRecord == null) {
@@ -241,6 +302,7 @@ class ReadRecordRepository(
deviceId = deviceId,
bookName = bookName,
bookAuthor = bookAuthor,
bookUrl = bookUrl,
readTime = totalTime,
lastRead = lastRead,
)
@@ -248,6 +310,7 @@ class ReadRecordRepository(
} else {
dao.update(
existingRecord.copy(
bookUrl = existingRecord.bookUrl ?: bookUrl,
readTime = totalTime,
lastRead = lastRead
)
@@ -288,9 +351,11 @@ class ReadRecordRepository(
targetRecord.bookName,
targetRecord.bookAuthor
) ?: targetRecord
val targetBookUrl = target.bookUrl ?: source.bookUrl
dao.insert(
target.copy(
bookUrl = targetBookUrl,
readTime = target.readTime + source.readTime,
lastRead = max(target.lastRead, source.lastRead)
)
@@ -311,12 +376,14 @@ class ReadRecordRepository(
if (existingTargetDetail == null) {
dao.insertDetail(
detail.copy(
bookAuthor = targetRecord.bookAuthor
bookAuthor = targetRecord.bookAuthor,
bookUrl = targetBookUrl,
)
)
} else {
dao.insertDetail(
existingTargetDetail.copy(
bookUrl = targetBookUrl ?: existingTargetDetail.bookUrl,
readTime = existingTargetDetail.readTime + detail.readTime,
readWords = existingTargetDetail.readWords + detail.readWords,
firstReadTime = min(existingTargetDetail.firstReadTime, detail.firstReadTime),
@@ -333,11 +400,22 @@ class ReadRecordRepository(
sourceRecord.bookAuthor
)
sourceSessions.forEach { session ->
dao.updateSession(session.copy(bookAuthor = targetRecord.bookAuthor))
dao.updateSession(
session.copy(
bookAuthor = targetRecord.bookAuthor,
bookUrl = targetBookUrl,
)
)
}
dao.deleteReadRecord(source)
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,
adaptSpecialStyle = this[Keys.AdaptSpecialStyle] ?: true,
useZhLayout = this[Keys.UseZhLayout] ?: false,
showBrightnessView = this[Keys.ShowBrightnessView] ?: "1",
showBrightnessView = getStringCompat(PreferKey.showBrightnessView, "1"),
brightnessVwPos = this[Keys.BrightnessVwPos] ?: "1",
readBrightness = this[Keys.ReadBrightness] ?: 100,
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 为准。
@@ -110,15 +121,15 @@ class SettingsRepository(private val context: Context) {
suspend fun batchPutFromMap(map: Map<String, *>) {
dataStore.edit { preferences ->
map.forEach { (key, value) ->
when (value) {
is String -> preferences[stringPreferencesKey(key)] = value
is Int -> preferences[intPreferencesKey(key)] = value
is Boolean -> preferences[booleanPreferencesKey(key)] = value
is Long -> preferences[longPreferencesKey(key)] = value
is Float -> preferences[floatPreferencesKey(key)] = value
when (val normalizedValue = value?.let { normalizeLegacyPreferenceValue(key, it) }) {
is String -> preferences[stringPreferencesKey(key)] = normalizedValue
is Int -> preferences[intPreferencesKey(key)] = normalizedValue
is Boolean -> preferences[booleanPreferencesKey(key)] = normalizedValue
is Long -> preferences[longPreferencesKey(key)] = normalizedValue
is Float -> preferences[floatPreferencesKey(key)] = normalizedValue
is Set<*> -> {
@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.BookDomainRepositoryImpl
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.BookSourceCallbackRepository
import io.legado.app.data.repository.BookSourceRepository
@@ -179,8 +180,11 @@ val appModule = module {
single { get<AppDatabase>().bookGroupDao }
single { get<AppDatabase>().bookSourceDao }
singleOf(::BookPrivacyRepository)
singleOf(::ReadRecordRepository)
single<HomeDashboardGateway> { HomeDashboardRepository(get(), get()) }
single<HomeDashboardGateway> {
HomeDashboardRepository(get(), get(), get(), get())
}
singleOf(::BookRepository)
singleOf(::BookGroupRepository)
singleOf(::BookSourceRepository)
@@ -230,7 +234,9 @@ val appModule = module {
single<AiChatGateway> { AiChatRepository(get()) }
single<AiMemoryGateway> { AiMemoryRepository(get()) }
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<BackupRestoreGateway> { BackupRestoreRepository() }
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
@@ -4,6 +4,7 @@ import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType
import io.legado.app.data.dao.BookChapterDao
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.BookChapter
import io.legado.app.data.entities.BookSource
@@ -68,6 +69,7 @@ enum class BatchChangeSourcePreviewStatus {
class ChangeBookSourceUseCase(
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao,
private val readRecordDao: ReadRecordDao,
) {
fun applyMigration(
@@ -97,6 +99,7 @@ class ChangeBookSourceUseCase(
bookChapterDao.delByBook(oldBook.bookUrl)
bookDao.delete(oldBook)
bookDao.insert(newBook)
readRecordDao.replaceBookUrl(oldBookUrl, newBook.bookUrl)
if (options.migrateChapters) {
bookChapterDao.insert(*chapters.toTypedArray())
ReadBook.onChapterListUpdated(newBook)
@@ -214,17 +214,28 @@ object Restore : KoinComponent {
}
fileToListT<ReadRecord>(path, "readRecord.json")?.let {
it.forEach { readRecord ->
if (readRecord.deviceId != androidId) {
val restoredRecord = readRecord.copy(
bookUrl = resolveBookUrl(
readRecord.bookUrl,
readRecord.bookName,
readRecord.bookAuthor,
)
)
if (restoredRecord.deviceId != androidId) {
try {
appDb.readRecordDao.insert(readRecord)
appDb.readRecordDao.insert(restoredRecord)
} catch (_: SQLiteConstraintException) {
}
} else {
val time = appDb.readRecordDao
.getReadTime(readRecord.deviceId, readRecord.bookName, readRecord.bookAuthor)
if (time == null || time < readRecord.readTime) {
.getReadTime(
restoredRecord.deviceId,
restoredRecord.bookName,
restoredRecord.bookAuthor,
)
if (time == null || time < restoredRecord.readTime) {
try {
appDb.readRecordDao.insert(readRecord)
appDb.readRecordDao.insert(restoredRecord)
} catch (_: SQLiteConstraintException) {
}
}
@@ -234,7 +245,15 @@ object Restore : KoinComponent {
fileToListT<ReadRecordDetail>(path, "readRecordDetail.json")?.let {
it.forEach { detail ->
try {
appDb.readRecordDao.insertDetail(detail)
appDb.readRecordDao.insertDetail(
detail.copy(
bookUrl = resolveBookUrl(
detail.bookUrl,
detail.bookName,
detail.bookAuthor,
)
)
)
} catch (_: SQLiteConstraintException) {
}
}
@@ -242,7 +261,15 @@ object Restore : KoinComponent {
fileToListT<ReadRecordSession>(path, "readRecordSession.json")?.let {
it.forEach { session ->
try {
appDb.readRecordDao.insertSession(session)
appDb.readRecordDao.insertSession(
session.copy(
bookUrl = resolveBookUrl(
session.bookUrl,
session.bookName,
session.bookAuthor,
)
)
)
} catch (_: SQLiteConstraintException) {
}
}
@@ -410,6 +437,15 @@ object Restore : KoinComponent {
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>? {
try {
val file = File(path, fileName)
@@ -68,11 +68,12 @@ data class Asset(
val downloadCount: Int,
val id: Int,
val name: String,
val state: String,
val state: String?,
val url: String
) {
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 {
val instant = Instant.parse(createdAt)
@@ -14,6 +14,9 @@ import kotlinx.coroutines.CoroutineScope
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
get() = when (AppConfig.updateToVariant) {
"official_version" -> AppVariant.OFFICIAL
@@ -24,9 +27,9 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
private suspend fun getLatestRelease(): List<AppReleaseInfo> {
val url = if (checkVariant == AppVariant.OFFICIAL)
"https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest"
"$RELEASES_API/latest"
else
"https://api.github.com/repos/HapeLee/legado-with-MD3/releases"
RELEASES_API
val res = okHttpClient.newCallResponse { url(url) }
if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
@@ -65,7 +68,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
}
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) }
if (!res.isSuccessful) return null
@@ -124,6 +124,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
ReadBook.book = book
readRecord.bookName = book.name
readRecord.bookAuthor = book.author
readRecord.bookUrl = book.bookUrl
readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) {
@@ -330,6 +331,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
deviceId = "",
bookName = currentBookName,
bookAuthor = currentBookAuthor,
bookUrl = book?.bookUrl,
startTime = readStartTime,
endTime = readStartTime,
words = durChapterIndex.toLong()
@@ -92,6 +92,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
ReadManga.book = book
readRecord.bookName = book.name
readRecord.bookAuthor = book.author
readRecord.bookUrl = book.bookUrl
readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
simulatedChapterSize = if (book.readSimulating()) {
@@ -162,6 +163,7 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
deviceId = "",
bookName = currentBookName,
bookAuthor = currentBookAuthor,
bookUrl = book?.bookUrl,
startTime = readStartTime,
endTime = readStartTime,
words = durChapterIndex.toLong()
@@ -55,7 +55,7 @@ fun AboutScreen(
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit = {},
versionName: String = appInfo.versionName,
versionName: String = "${appInfo.versionName} (${appInfo.versionCode})",
) {
if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
MiuixAboutScreen(
@@ -145,13 +145,13 @@ private fun MaterialAboutScreen(
modifier = Modifier.fillMaxWidth(),
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(
painter = painterResource(R.drawable.ic_web_outline),
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(
painter = painterResource(R.drawable.ic_github),
contentDescription = stringResource(R.string.back)
@@ -172,7 +172,7 @@ private fun MaterialAboutScreen(
SettingItemWithDivider {
SettingItem(
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 {
@@ -84,7 +84,7 @@ fun MiuixAboutScreen(
state: AboutUiState,
onIntent: (AboutIntent) -> Unit,
onBack: () -> Unit = {},
versionName: String = appInfo.versionName,
versionName: String = "${appInfo.versionName} (${appInfo.versionCode})",
) {
val topAppBarScrollBehavior = MiuixScrollBehavior()
val lazyListState = rememberLazyListState()
@@ -395,7 +395,7 @@ private fun AboutContent(
endActions = {
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) {
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 ->
result.uri?.let { uri ->
@@ -53,7 +53,7 @@ class ReadRecordOverviewViewModel(
_referenceDate,
repository.getAllRecordDetails(""),
repository.getLatestReadRecords(""),
bookRepository.getAllBooks()
bookRepository.getVisibleBooks()
) { period, refDate, details, latestRecords, allBooks ->
getReadRecordOverviewUseCase(period, refDate, details, latestRecords, allBooks)
}.stateIn(
@@ -20,6 +20,7 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import androidx.navigation3.ui.NavDisplay
import io.legado.app.help.config.AppConfig
import io.legado.app.data.repository.BookRepository
import io.legado.app.model.Download
import io.legado.app.ui.about.AboutEffect
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.toggleSystemBar
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalSharedTransitionApi::class)
@@ -559,13 +559,12 @@ fun MainActivity.mainEntryProvider(
}
entry<MainRouteReadRecord> {
val bookRepository = koinInject<BookRepository>()
ReadRecordScreen(
onBackClick = { onNavigateBack() },
onBookClick = { name, author ->
lifecycleScope.launch {
val book = withContext(IO) {
io.legado.app.data.appDb.bookDao.getBook(name, author)
}
val book = bookRepository.getVisibleBook(name, author)
if (book != null) this@mainEntryProvider.startActivityForBook(book)
else {
onNavigateToRoute(MainRouteSearch(key = name))
@@ -579,13 +578,12 @@ fun MainActivity.mainEntryProvider(
}
entry<MainRouteReadRecordOverview> {
val bookRepository = koinInject<BookRepository>()
ReadRecordOverviewScreen(
onBackClick = { onNavigateBack() },
onBookClick = { name, author ->
lifecycleScope.launch {
val book = withContext(IO) {
io.legado.app.data.appDb.bookDao.getBook(name, author)
}
val book = bookRepository.getVisibleBook(name, author)
if (book != null) this@mainEntryProvider.startActivityForBook(book)
else {
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.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.text.hiddenAttributeUnderline
import io.legado.app.utils.toTimeAgo
/**
@@ -83,6 +84,7 @@ fun BookshelfItem(
titleMaxLines: Int = 2,
coverShadow: Boolean = false,
titleColor: Color? = null,
titleHidden: Boolean = false,
descAnnotated: AnnotatedString? = null,
coverWidth: Int = 84,
onClick: () -> Unit,
@@ -151,6 +153,7 @@ fun BookshelfItem(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.hiddenAttributeUnderline(titleHidden, Color.White)
.background(
Brush.verticalGradient(
colors = listOf(
@@ -173,6 +176,10 @@ fun BookshelfItem(
textAlign = if (titleCenter) TextAlign.Center else TextAlign.Start,
modifier = Modifier
.fillMaxWidth()
.hiddenAttributeUnderline(
titleHidden,
LegadoTheme.colorScheme.onSurfaceVariant
)
.padding(start = 4.dp, end = 4.dp, bottom = 4.dp)
)
}
@@ -237,7 +244,12 @@ fun BookshelfItem(
},
maxLines = BookshelfConfig.bookshelfTitleMaxLines,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.hiddenAttributeUnderline(
titleHidden,
LegadoTheme.colorScheme.onSurfaceVariant
)
)
titleEnd?.let {
Box(modifier = Modifier.padding(top = 4.dp, start = 4.dp)) {
@@ -436,6 +448,7 @@ fun BookGroupItemGrid(
)
},
title = group.groupName,
titleHidden = group.isPrivate,
modifier = modifier,
titleSmallFont = titleSmallFont,
titleCenter = titleCenter,
@@ -491,6 +504,7 @@ fun BookGroupItemList(
isCompact = BookshelfConfig.bookshelfGroupListStyle == 1 || isCompact,
cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) },
title = group.groupName,
titleHidden = group.isPrivate,
subTitle = countText,
descAnnotated = descAnnotated,
titleSmallFont = titleSmallFont,
@@ -549,6 +563,10 @@ fun BookGroupItemHorizontalCovers(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
.hiddenAttributeUnderline(
group.isPrivate,
LegadoTheme.colorScheme.onSurfaceVariant
)
)
if (countText != null) {
AppText(
@@ -689,6 +707,7 @@ fun BookItem(
)
},
title = book.name,
titleHidden = bookUi.isHidden,
subTitle = if (layoutMode == 0 && isCompact) {
stringResource(R.string.author_read, book.author, unreadCount)
} else {
@@ -50,7 +50,7 @@ data class BookShelfItem(
/**
* 将 DTO 转换为专为 Compose 设计的 UI 状态
*/
fun toUiItem(): BookUiItem {
fun toUiItem(privateGroupMask: Long = 0L): BookUiItem {
val tagList = mutableListOf<String>()
kind?.splitNotBlank(",", "\n")?.filter { it.isNotBlank() }?.let {
tagList.addAll(it)
@@ -61,7 +61,8 @@ data class BookShelfItem(
return BookUiItem(
book = this,
displayTags = tagList.toImmutableList()
displayTags = tagList.toImmutableList(),
isHidden = privateGroupMask != 0L && (group and privateGroupMask) != 0L
)
}
}
@@ -72,7 +73,8 @@ data class BookShelfItem(
@Stable
data class BookUiItem(
val book: BookShelfItem,
val displayTags: ImmutableList<String>
val displayTags: ImmutableList<String>,
val isHidden: Boolean = false
) {
fun matches(key: String): Boolean {
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.SelectAll
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.outlined.ViewCarousel
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -73,6 +74,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -238,7 +240,9 @@ fun BookshelfScreen(
)
val folderGridState = 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 ->
key(group.groupId) {
groupGridStates[group.groupId] = rememberLazyGridState()
@@ -507,6 +511,19 @@ fun BookshelfScreen(
},
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(
text = stringResource(R.string.add_url),
onClick = {
@@ -578,6 +595,9 @@ fun BookshelfScreen(
AppTabRow(
tabTitles = tabTitles,
hiddenTabIndices = uiState.groups.mapIndexedNotNull { index, group ->
index.takeIf { group.isPrivate }
}.toSet(),
selectedTabIndex = selectedTabIndex,
onTabSelected = { index ->
scope.launch { pagerState.animateScrollToPage(index) }
@@ -608,6 +628,7 @@ fun BookshelfScreen(
uiState.groups.forEachIndexed { index, group ->
RoundDropdownMenuItem(
text = group.groupName,
hidden = group.isPrivate,
onClick = {
if (uiState.isSearch) {
viewModel.changeGroup(group.groupId)
@@ -641,7 +662,9 @@ fun BookshelfScreen(
it.groupId == BookGroup.IdAll
}
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()) {
@@ -656,6 +679,7 @@ fun BookshelfScreen(
allGroup?.let { group ->
RoundDropdownMenuItem(
text = group.groupName,
hidden = group.isPrivate,
onClick = {
viewModel.changeGroup(group.groupId)
dismiss()
@@ -675,6 +699,7 @@ fun BookshelfScreen(
hiddenGroups.forEach { group ->
RoundDropdownMenuItem(
text = group.groupName,
hidden = group.isPrivate,
onClick = {
viewModel.changeGroup(group.groupId)
dismiss()
@@ -999,6 +1024,7 @@ fun BookshelfScreen(
uiState.groups.forEach { group ->
RoundDropdownMenuItem(
text = group.groupName,
hidden = group.isPrivate,
onClick = {
val targetIndex =
uiState.groups.indexOfFirst { it.groupId == group.groupId }
@@ -1140,7 +1166,10 @@ private fun BookshelfOverlays(
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,
onDismissRequest = { viewModel.dismissOverlay() },
onConfirm = { groupId ->
@@ -58,6 +58,7 @@ data class BookshelfUiState(
val bookGroupStyle: Int = 0,
val bookshelfSort: Int = 0,
val bookshelfSortOrder: Int = 1,
val privacyModeEnabled: Boolean = true,
val title: String = "",
val subtitle: 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.AppLog
import io.legado.app.constant.EventBus
import io.legado.app.constant.PreferKey
import io.legado.app.data.appDb
import io.legado.app.data.entities.Book
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.BookSourceRepository
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.domain.usecase.AddBookUseCase
import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
@@ -81,6 +83,7 @@ class BookshelfViewModel(
private val bookGroupRepository: BookGroupRepository,
private val bookSourceRepository: BookSourceRepository,
private val bookshelfRepository: BookshelfRepository,
private val settingsRepository: SettingsRepository,
private val uploadRepository: UploadRepository,
private val batchCacheDownloadUseCase: BatchCacheDownloadUseCase,
private val updateBooksGroupUseCase: UpdateBooksGroupUseCase,
@@ -110,6 +113,13 @@ class BookshelfViewModel(
val sortOrder: Int
)
private data class GroupBooksConfig(
val groups: List<BookGroup>,
val sortConfig: BookshelfSortConfig,
val privacyModeEnabled: Boolean,
val privateGroupMask: Long
)
private fun readSortConfig() = BookshelfSortConfig(
sort = BookshelfConfig.bookshelfSort,
sortOrder = BookshelfConfig.bookshelfSortOrder
@@ -142,7 +152,40 @@ class BookshelfViewModel(
protected val _eventChannel = Channel<BaseRuleEvent>()
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 {
if (it.isNotEmpty()) {
isInitialLoadingFlow.value = false
@@ -150,8 +193,21 @@ class BookshelfViewModel(
}
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private fun displayedBooksFlow(
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(
val previews: ImmutableMap<Long, ImmutableList<BookUiItem>>,
@@ -180,38 +236,44 @@ class BookshelfViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfGroupSelectorState())
@OptIn(ExperimentalCoroutinesApi::class)
val booksFlow: Flow<List<BookUiItem>> = groupIdFlow
.flatMapLatest { groupId ->
val booksFlow: Flow<List<BookUiItem>> = combine(
groupIdFlow,
privacyModeFlow
) { groupId, privacyModeEnabled -> groupId to privacyModeEnabled }
.flatMapLatest { (groupId, privacyModeEnabled) ->
combine(
bookRepository.flowBookShelfByGroup(groupId),
groupsFlow,
sortConfigFlow
) { list, groups, sortConfig ->
displayedBooksFlow(groupId, privacyModeEnabled),
allGroupsFlow,
sortConfigFlow,
privateGroupMaskFlow
) { list, groups, sortConfig, privateGroupMask ->
bookshelfRepository.sortBooks(
list,
groups.find { it.groupId == groupId },
sortConfig.sort,
sortConfig.sortOrder
).map { it.toUiItem() }
).map { it.toUiItem(privateGroupMask) }
}
}.distinctUntilChanged().flowOn(Dispatchers.Default)
@OptIn(ExperimentalCoroutinesApi::class)
private val allGroupBooksImmutableFlow: Flow<ImmutableMap<Long, ImmutableList<BookUiItem>>> =
combine(groupsFlow, sortConfigFlow) { groups, sortConfig ->
groups to sortConfig
}.flatMapLatest { (groups, sortConfig) ->
combine(groupsFlow, sortConfigFlow, privacyModeFlow, privateGroupMaskFlow) {
groups, sortConfig, privacyModeEnabled, privateGroupMask ->
GroupBooksConfig(groups, sortConfig, privacyModeEnabled, privateGroupMask)
}.flatMapLatest { config ->
val groups = config.groups
if (groups.isEmpty()) {
flowOf(persistentMapOf())
} else {
val flows = groups.map { group ->
bookRepository.flowBookShelfByGroup(group.groupId).map { books ->
displayedBooksFlow(group.groupId, config.privacyModeEnabled).map { books ->
group.groupId to bookshelfRepository.sortBooks(
books,
group,
sortConfig.sort,
sortConfig.sortOrder
).map { it.toUiItem() }.toImmutableList()
config.sortConfig.sort,
config.sortConfig.sortOrder
).map { it.toUiItem(config.privateGroupMask) }.toImmutableList()
}
}
combine(flows) { results ->
@@ -256,7 +318,7 @@ class BookshelfViewModel(
groupsFlow,
bookGroupStyleFlow,
bookRepository.flowSystemGroupCounts(),
bookRepository.flowAllBookShelfCount()
displayedAllBookCountFlow
) { groups, bookGroupStyle, systemCounts, totalCount ->
DataForPreviews(
groups,
@@ -276,7 +338,9 @@ class BookshelfViewModel(
flowOf(GroupPreviewState(persistentMapOf(), persistentMapOf(), allBookCount))
} else {
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)
} else {
flowOf(systemCountsMap[group.groupId] ?: 0)
@@ -372,7 +436,7 @@ class BookshelfViewModel(
GroupPreviewState(persistentMapOf(), persistentMapOf(), 0)
)
private val dataStateFlow = combine(
private val dataCoreFlow = combine(
booksFlow,
groupsFlow,
allGroupsFlow,
@@ -380,14 +444,21 @@ class BookshelfViewModel(
internalStateFlow
) { 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(
books = core.books,
groups = core.groups.map { it.toBookGroupUi() },
allGroups = core.allGroups.map { it.toBookGroupUi() },
previews = core.previews,
internal = core.internal,
allGroupBooks = allGroupBooks
allGroupBooks = allGroupBooks,
privacyModeEnabled = privacyModeEnabled
)
}
@@ -405,7 +476,8 @@ class BookshelfViewModel(
val allGroups: List<BookGroupUi>,
val previews: GroupPreviewState,
val internal: InternalState,
val allGroupBooks: ImmutableMap<Long, ImmutableList<BookUiItem>>
val allGroupBooks: ImmutableMap<Long, ImmutableList<BookUiItem>>,
val privacyModeEnabled: Boolean
)
val uiState: StateFlow<BookshelfUiState> = combine(
@@ -459,6 +531,7 @@ class BookshelfViewModel(
bookGroupStyle = interaction.bookGroupStyle,
bookshelfSort = internal.sortConfig.sort,
bookshelfSortOrder = internal.sortConfig.sortOrder,
privacyModeEnabled = data.privacyModeEnabled,
title = title,
subtitle = when {
interaction.isEditMode -> {
@@ -506,6 +579,14 @@ class BookshelfViewModel(
viewModelScope.launch {
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 {
combine(booksFlow, selectedGroupCanReorderFlow) { books, canReorderBooks ->
books to canReorderBooks
@@ -584,6 +665,12 @@ class BookshelfViewModel(
clearSelection()
}
fun setPrivacyMode(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.putBoolean(PreferKey.bookshelfPrivacyMode, enabled)
}
}
fun showOverlay(overlay: BookshelfOverlay) {
activeOverlayFlow.value = overlay
}
@@ -466,9 +466,11 @@ private fun CoverTextOverlay(
val startX = viewWidth * 0.84f
val fm = paint.fontMetrics
val charHeight = fm.bottom - fm.top
var startY = viewHeight * 0.16f - (author.length * charHeight)
startY = startY.coerceAtLeast(viewHeight * 0.2f)
author.forEach { char ->
val visibleAuthor = author.takeLast(
((viewHeight * 0.35f) / charHeight).toInt().coerceAtLeast(1)
)
var startY = viewHeight * 0.92f - (visibleAuthor.length - 1) * charHeight
visibleAuthor.forEach { char ->
nativeCanvas.drawText(char.toString(), startX, startY, paint)
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.ThemeResolver
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.basic.Check
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -50,6 +51,7 @@ fun RoundDropdownMenuItem(
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
hidden: Boolean = false,
contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding,
interactionSource: MutableInteractionSource? = null,
) {
@@ -88,7 +90,9 @@ fun RoundDropdownMenuItem(
contentAlignment = Alignment.CenterStart
) {
MiuixText(
modifier = Modifier.widthIn(max = 200.dp),
modifier = Modifier
.widthIn(max = 200.dp)
.hiddenAttributeUnderline(hidden, textColor),
text = text,
fontSize = MiuixTheme.textStyles.body1.fontSize,
fontWeight = FontWeight.Medium,
@@ -157,7 +161,9 @@ fun RoundDropdownMenuItem(
contentAlignment = Alignment.CenterStart
) {
Text(
modifier = Modifier.widthIn(max = 200.dp),
modifier = Modifier
.widthIn(max = 200.dp)
.hiddenAttributeUnderline(hidden, contentColor),
text = text,
style = LegadoTheme.typography.labelLargeEmphasized,
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.ThemeResolver
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.TabRowWithContour
@@ -21,11 +22,12 @@ fun AppTabRow(
selectedTabIndex: Int,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
isScrollable: Boolean = true
isScrollable: Boolean = true,
hiddenTabIndices: Set<Int> = emptySet()
) {
val composeEngine = LegadoTheme.composeEngine
if (ThemeResolver.isMiuixEngine(composeEngine)) {
if (ThemeResolver.isMiuixEngine(composeEngine) && hiddenTabIndices.isEmpty()) {
TabRowWithContour(
tabs = tabTitles,
selectedTabIndex = selectedTabIndex,
@@ -50,7 +52,8 @@ fun AppTabRow(
AppTab(
selected = selectedTabIndex == index,
onClick = { onTabSelected(index) },
title = title
title = title,
hidden = index in hiddenTabIndices
)
}
}
@@ -65,7 +68,8 @@ fun AppTabRow(
AppTab(
selected = selectedTabIndex == index,
onClick = { onTabSelected(index) },
title = title
title = title,
hidden = index in hiddenTabIndices
)
}
}
@@ -77,7 +81,8 @@ fun AppTabRow(
private fun AppTab(
selected: Boolean,
onClick: () -> Unit,
title: String
title: String,
hidden: Boolean
) {
Tab(
selected = selected,
@@ -88,7 +93,13 @@ private fun AppTab(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
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
)
}
@@ -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>
<!--App-->
<string name="app_name">阅读</string>
<string name="app_name_a">阅读·A</string>
<string name="app_name">且看</string>
<string name="app_name_a">且看·A</string>
<string name="receiving_shared_label">阅读 · 搜索</string>
<string name="tip_perm_request_storage">阅读需要访问存储卡权限,请前往“设置”—“应用权限”,打开所需权限</string>
@@ -134,7 +134,7 @@
<string name="change_origin">换源</string>
<string name="about_description">全新设计的阅读 3.0</string>
<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 name="version_name">版本 %s</string>
<string name="pt_background_verification">后台校验书源</string>
@@ -1295,9 +1295,10 @@
<string name="file_manage_summary">管理私有文件夹的文件</string>
<string name="create_folder">创建文件夹</string>
<string name="allow_drop_down_refresh">允许下拉刷新</string>
<string name="private_group">设为隐分组</string>
<string name="disable_private_group">关闭隐分组</string>
<string name="private_group_desc">开启后,本分组内的书籍将从公开书架隐藏</string>
<string name="private_group">设为隐分组</string>
<string name="disable_private_group">关闭隐分组</string>
<string name="private_group_desc">开启后,本分组及其中书籍将在隐私模式下隐藏</string>
<string name="privacy_mode">隐私模式</string>
<string name="text_underline">文字下划线</string>
<string name="select_new_source">选中新增源</string>
<string name="select_update_source">选中更新源</string>
+7 -6
View File
@@ -1,7 +1,7 @@
<resources>
<!--App-->
<string name="app_name">閲讀</string>
<string name="app_name_a">閲讀·A</string>
<string name="app_name">且看</string>
<string name="app_name_a">且看·A</string>
<string name="receiving_shared_label">閲讀·搜尋</string>
<string name="tip_perm_request_storage">閲讀需要訪問存儲卡權限,請前往「設定」—「應用程式權限」—開啟所需要的權限</string>
@@ -99,7 +99,7 @@
\u3000\u3000這是一款使用 Kotlin 全新開發的開源的閲讀應用程式,歡迎你的加入。
</string>
<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 name="version_name">Version %s</string>
<string name="pt_background_verification">後臺校驗書源</string>
@@ -1145,9 +1145,10 @@
<string name="file_manage_summary">管理私有文件夹的文件</string>
<string name="create_folder">创建文件夹</string>
<string name="allow_drop_down_refresh">允许下拉刷新</string>
<string name="private_group">設為隱分組</string>
<string name="disable_private_group">關閉隱分組</string>
<string name="private_group_desc">開啟後,本分組內嘅書籍將從公開書架隱藏</string>
<string name="private_group">設為隱分組</string>
<string name="disable_private_group">關閉隱分組</string>
<string name="private_group_desc">開啟後,本分組及其中書籍會在私隱模式下隱藏</string>
<string name="privacy_mode">私隱模式</string>
<string name="text_underline">文字下划线</string>
<string name="select_new_source">选中新增源</string>
<string name="select_update_source">选中更新源</string>
+7 -6
View File
@@ -1,7 +1,7 @@
<resources>
<!--App-->
<string name="app_name">閱讀</string>
<string name="app_name_a">閱讀·A</string>
<string name="app_name">且看</string>
<string name="app_name_a">且看·A</string>
<string name="receiving_shared_label">閱讀·搜尋</string>
<string name="tip_perm_request_storage">閱讀需要存取記憶卡權限,請前往「設定」—「應用程式權限」—打開所需權限</string>
@@ -101,7 +101,7 @@
\u3000\u3000這是一款使用Kotlin全新開發的開源的閱讀軟體,歡迎您的加入。
</string>
<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 name="version_name">Version %s</string>
<string name="pt_background_verification">後臺校驗書源</string>
@@ -1148,9 +1148,10 @@
<string name="file_manage_summary">管理私有文件夹的文件</string>
<string name="create_folder">创建文件夹</string>
<string name="allow_drop_down_refresh">允许下拉刷新</string>
<string name="private_group">設為隱分組</string>
<string name="disable_private_group">關閉隱分組</string>
<string name="private_group_desc">開啟後,本分組內的書籍將從公開書架隱藏</string>
<string name="private_group">設為隱分組</string>
<string name="disable_private_group">關閉隱分組</string>
<string name="private_group_desc">開啟後,本分組及其中書籍將在隱私模式下隱藏</string>
<string name="privacy_mode">隱私模式</string>
<string name="text_underline">文字下划线</string>
<string name="select_new_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="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="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="vip_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"?>
<resources>
<!--App-->
<string name="app_name">Legado</string>
<string name="app_name_a">Legado·A</string>
<string name="app_name">且看</string>
<string name="app_name_a">且看·A</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>
@@ -155,7 +155,7 @@
\u3000\u3000 This is an open source reading software newly developed by Kotlin, welcome to join us.
</string>
<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 name="version_name">Version %s</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="create_folder">Create Folder</string>
<string name="allow_drop_down_refresh">Allow drop-down refresh</string>
<string name="private_group">Set as private group</string>
<string name="disable_private_group">Disable private group</string>
<string name="private_group_desc">After enabling, books in this group are hidden from the public bookshelf</string>
<string name="private_group">Set as hidden group</string>
<string name="disable_private_group">Disable hidden group</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="select_new_source">Select new 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_MINOR=26
VERSION_PATCH=13
VERSION_PATCH=14
VERSION_SUFFIX=1
# 1 = Pre, 0 = Release