Compare commits

..

25 Commits
v6.2 ... v6.2.4

Author SHA1 Message Date
Koitharu
24f1546019 Fix pagination 2023-10-26 12:45:32 +03:00
ngocanhtve
1b0fed5c56 Translated using Weblate (Vietnamese)
Currently translated at 84.1% (419 of 498 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: ngocanhtve <ngocanh.tve@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-26 12:29:29 +03:00
Koitharu
3d32bd9d58 Fix warnings 2023-10-25 15:42:00 +03:00
Koitharu
590120433c Update dependencies 2023-10-25 15:42:00 +03:00
Koitharu
4bd7656681 Fix loading footer in lists 2023-10-25 15:41:59 +03:00
Koitharu
2c7438e64d Add error reporting to import local manga 2023-10-25 15:41:59 +03:00
InfinityDouki56
665bebaa7b Translated using Weblate (Filipino)
Currently translated at 88.9% (443 of 498 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-10-25 12:05:52 +03:00
return_null
6ed5994726 Translated using Weblate (Chinese (Simplified))
Currently translated at 98.5% (491 of 498 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-10-25 12:05:52 +03:00
Dpper
311ed865b7 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-10-25 12:05:52 +03:00
Bai
b59fb678fe Translated using Weblate (Turkish)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-10-25 12:05:52 +03:00
Koitharu
ed9ebdcc55 Handle kotatsu scheme links 2023-10-23 17:20:44 +03:00
Koitharu
74569615e3 Fix splash background 2023-10-18 10:55:01 +03:00
Koitharu
f3c320a90f Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2023-10-18 10:02:13 +03:00
gallegonovato
a3012ab458 Translated using Weblate (Spanish)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-18 09:59:52 +03:00
Макар Разин
6ec58879fd Translated using Weblate (Ukrainian)
Currently translated at 100.0% (498 of 498 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (498 of 498 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (498 of 498 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (498 of 498 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-10-18 09:59:52 +03:00
Koitharu
571cf08c53 Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2023-10-18 09:50:52 +03:00
Koitharu
fca53eee7a Improve downloads list 2023-10-18 09:40:31 +03:00
Zakhar Timoshenko
ed9e2eb4d2 ActionMode and NavBar colors fix 2023-10-17 18:04:34 +03:00
Koitharu
c0e94f8415 Show chapters list in downloads 2023-10-17 12:19:44 +03:00
Koitharu
e172d619a1 Fix description expanding 2023-10-17 11:13:16 +03:00
Koitharu
d6c64fc638 Action to open online version of saved manga 2023-10-17 11:06:16 +03:00
Koitharu
37404cb9a6 UI improvements 2023-10-17 10:32:30 +03:00
Koitharu
9d5271ff26 Fix default branch selection #527 #528 2023-10-17 10:24:36 +03:00
Koitharu
5f59432e48 Handle NPE during network requests 2023-10-17 10:01:58 +03:00
Koitharu
5c082b5cdb Update parsers 2023-10-17 09:59:26 +03:00
90 changed files with 711 additions and 388 deletions

View File

@@ -16,11 +16,12 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 587
versionName = '6.2'
versionCode = 591
versionName = '6.2.4'
generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp {
// arg("room.generateKotlin", "true") TODO: enable later
arg("room.schemaLocation", "$projectDir/schemas")
}
androidResources {
@@ -81,7 +82,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:a61e441e79') {
implementation('com.github.KotatsuApp:kotatsu-parsers:af1aca8725') {
exclude group: 'org.json', module: 'json'
}
@@ -98,7 +99,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
@@ -114,9 +115,9 @@ dependencies {
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation 'androidx.room:room-runtime:2.5.2'
implementation 'androidx.room:room-ktx:2.5.2'
ksp 'androidx.room:room-compiler:2.5.2'
implementation 'androidx.room:room-runtime:2.6.0'
implementation 'androidx.room:room-ktx:2.6.0'
ksp 'androidx.room:room-compiler:2.6.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
@@ -136,8 +137,8 @@ dependencies {
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.2'
implementation 'ch.acra:acra-dialog:5.11.2'
implementation 'ch.acra:acra-http:5.11.3'
implementation 'ch.acra:acra-dialog:5.11.3'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
@@ -152,7 +153,7 @@ dependencies {
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'androidx.room:room-testing:2.6.0'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'

View File

@@ -82,7 +82,7 @@ class AppBackupAgentTest {
assertEquals(history, historyRepository.getOne(SampleData.manga))
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
val allTags = database.getTagsDao().findTags(SampleData.tag.source.name).toMangaTags()
assertTrue(SampleData.tag in allTags)
}

View File

@@ -83,6 +83,16 @@
<data android:host="kotatsu.app" />
<data android:path="/manga" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
<data android:host="manga" />
<data android:host="kotatsu.app" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
@@ -327,6 +337,13 @@
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter>
</receiver>
<receiver
android:name="org.koitharu.kotatsu.core.ErrorReporterReceiver"
android:exported="false">
<intent-filter>
<action android:name="${applicationId}.action.REPORT_ERROR" />
</intent-filter>
</receiver>
<meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing"

View File

@@ -25,15 +25,15 @@ class BookmarksRepository @Inject constructor(
) {
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
return db.getBookmarksDao().observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
}
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
return db.getBookmarksDao().observe(manga.id).mapItems { it.toBookmark(manga) }
}
fun observeBookmarks(): Flow<Map<Manga, List<Bookmark>>> {
return db.bookmarksDao.observe().map { map ->
return db.getBookmarksDao().observe().map { map ->
val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
for ((k, v) in map) {
val manga = k.toManga()
@@ -46,9 +46,9 @@ class BookmarksRepository @Inject constructor(
suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction {
val tags = bookmark.manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
db.bookmarksDao.insert(bookmark.toEntity())
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
db.getBookmarksDao().insert(bookmark.toEntity())
}
}
@@ -56,11 +56,11 @@ class BookmarksRepository @Inject constructor(
val entity = bookmark.toEntity().copy(
imageUrl = imageUrl,
)
db.bookmarksDao.upsert(listOf(entity))
db.getBookmarksDao().upsert(listOf(entity))
}
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) {
check(db.bookmarksDao.delete(mangaId, chapterId, page) != 0) {
check(db.getBookmarksDao().delete(mangaId, chapterId, page) != 0) {
"Bookmark not found"
}
}
@@ -72,7 +72,7 @@ class BookmarksRepository @Inject constructor(
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
val entities = ArrayList<BookmarkEntity>(ids.size)
db.withTransaction {
val dao = db.bookmarksDao
val dao = db.getBookmarksDao()
for (pageId in ids) {
val e = dao.find(pageId)
if (e != null) {
@@ -92,7 +92,7 @@ class BookmarksRepository @Inject constructor(
db.withTransaction {
for (e in entities) {
try {
db.bookmarksDao.insert(e)
db.getBookmarksDao().insert(e)
} catch (e: SQLException) {
e.printStackTraceDebug()
}

View File

@@ -34,8 +34,8 @@ class BookmarksActivity :
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
val fragment = BookmarksFragment.newInstance()
replace(R.id.container, fragment)
setReorderingAllowed(true)
replace(R.id.container, BookmarksFragment::class.java, null)
}
}
}

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val e = intent?.getSerializableExtraCompat<Throwable>(EXTRA_ERROR) ?: return
e.report()
}
companion object {
private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT)
intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(EXTRA_ERROR, e)
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
}
}
}

View File

@@ -22,7 +22,7 @@ class BackupRepository @Inject constructor(
var offset = 0
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
while (true) {
val history = db.historyDao.findAll(offset, PAGE_SIZE)
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) {
break
}
@@ -42,7 +42,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
val categories = db.favouriteCategoriesDao.findAll()
val categories = db.getFavouriteCategoriesDao().findAll()
for (item in categories) {
entry.data.put(JsonSerializer(item).toJson())
}
@@ -53,7 +53,7 @@ class BackupRepository @Inject constructor(
var offset = 0
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
while (true) {
val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE)
val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE)
if (favourites.isEmpty()) {
break
}
@@ -73,7 +73,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray())
val all = db.bookmarksDao.findAll()
val all = db.getBookmarksDao().findAll()
for ((m, b) in all) {
val json = JSONObject()
val manga = JsonSerializer(m.manga).toJson()
@@ -122,9 +122,9 @@ class BackupRepository @Inject constructor(
val history = JsonDeserializer(item).toHistoryEntity()
result += runCatchingCancellable {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags)
db.historyDao.upsert(history)
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getHistoryDao().upsert(history)
}
}
}
@@ -136,7 +136,7 @@ class BackupRepository @Inject constructor(
for (item in entry.data.JSONIterator()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable {
db.favouriteCategoriesDao.upsert(category)
db.getFavouriteCategoriesDao().upsert(category)
}
}
return result
@@ -153,9 +153,9 @@ class BackupRepository @Inject constructor(
val favourite = JsonDeserializer(item).toFavouriteEntity()
result += runCatchingCancellable {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags)
db.favouritesDao.upsert(favourite)
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getFavouritesDao().upsert(favourite)
}
}
}
@@ -175,9 +175,9 @@ class BackupRepository @Inject constructor(
}
result += runCatchingCancellable {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags)
db.bookmarksDao.upsert(bookmarks)
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga, tags)
db.getBookmarksDao().upsert(bookmarks)
}
}
}

View File

@@ -66,29 +66,29 @@ const val DATABASE_VERSION = 17
)
abstract class MangaDatabase : RoomDatabase() {
abstract val historyDao: HistoryDao
abstract fun getHistoryDao(): HistoryDao
abstract val tagsDao: TagsDao
abstract fun getTagsDao(): TagsDao
abstract val mangaDao: MangaDao
abstract fun getMangaDao(): MangaDao
abstract val favouritesDao: FavouritesDao
abstract fun getFavouritesDao(): FavouritesDao
abstract val preferencesDao: PreferencesDao
abstract fun getPreferencesDao(): PreferencesDao
abstract val favouriteCategoriesDao: FavouriteCategoriesDao
abstract fun getFavouriteCategoriesDao(): FavouriteCategoriesDao
abstract val tracksDao: TracksDao
abstract fun getTracksDao(): TracksDao
abstract val trackLogsDao: TrackLogsDao
abstract fun getTrackLogsDao(): TrackLogsDao
abstract val suggestionDao: SuggestionDao
abstract fun getSuggestionDao(): SuggestionDao
abstract val bookmarksDao: BookmarksDao
abstract fun getBookmarksDao(): BookmarksDao
abstract val scrobblingDao: ScrobblingDao
abstract fun getScrobblingDao(): ScrobblingDao
abstract val sourcesDao: MangaSourcesDao
abstract fun getSourcesDao(): MangaSourcesDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration10To11 : Migration(10, 11) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `bookmarks` (
`manga_id` INTEGER NOT NULL,
@@ -20,7 +20,7 @@ class Migration10To11 : Migration(10, 11) {
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
""".trimIndent()
)
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
}
}
}

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration11To12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `scrobblings` (
`scrobbler` INTEGER NOT NULL,
@@ -21,7 +21,7 @@ class Migration11To12 : Migration(11, 12) {
)
""".trimIndent()
)
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
db.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
db.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
}
}

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration12To13 : Migration(12, 13) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
database.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE favourites ADD COLUMN `sort_key` INTEGER NOT NULL DEFAULT 0")
}
}
}

View File

@@ -5,11 +5,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration13To14 : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_brightness` REAL NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_contrast` REAL NOT NULL DEFAULT 0")
}
}

View File

@@ -5,5 +5,5 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration14To15 : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) = Unit
override fun migrate(db: SupportSQLiteDatabase) = Unit
}

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration15To16 : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -10,9 +10,9 @@ class Migration16To17(context: Context) : Migration(16, 17) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
database.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))")
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries
@@ -30,7 +30,7 @@ class Migration16To17(context: Context) : Migration(16, 17) {
continue
}
}
database.execSQL(
db.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (!isHidden).toInt(), sortKey),
)

View File

@@ -7,48 +7,48 @@ class Migration1To2 : Migration(1, 2) {
/**
* Adding foreign keys
*/
override fun migrate(database: SupportSQLiteDatabase) {
override fun migrate(db: SupportSQLiteDatabase) {
/* manga_tags */
database.execSQL(
db.execSQL(
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, tag_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " +
"FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
database.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
database.execSQL("DROP TABLE manga_tags")
database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_manga_id ON manga_tags_tmp (manga_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_manga_tags_tag_id ON manga_tags_tmp (tag_id)")
db.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
db.execSQL("DROP TABLE manga_tags")
db.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
/* favourites */
database.execSQL(
db.execSQL(
"CREATE TABLE IF NOT EXISTS favourites_tmp (manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, created_at INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, category_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " +
"FOREIGN KEY(category_id) REFERENCES favourite_categories(category_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
database.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
database.execSQL("DROP TABLE favourites")
database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_manga_id ON favourites_tmp (manga_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_favourites_category_id ON favourites_tmp (category_id)")
db.execSQL("INSERT INTO favourites_tmp (manga_id, category_id, created_at) SELECT manga_id, category_id, created_at FROM favourites")
db.execSQL("DROP TABLE favourites")
db.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
/* history */
database.execSQL(
db.execSQL(
"CREATE TABLE IF NOT EXISTS history_tmp (manga_id INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, chapter_id INTEGER NOT NULL, page INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
database.execSQL("DROP TABLE history")
database.execSQL("ALTER TABLE history_tmp RENAME TO history")
db.execSQL("INSERT INTO history_tmp (manga_id, created_at, updated_at, chapter_id, page) SELECT manga_id, created_at, updated_at, chapter_id, page FROM history")
db.execSQL("DROP TABLE history")
db.execSQL("ALTER TABLE history_tmp RENAME TO history")
/* preferences */
database.execSQL(
db.execSQL(
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
" PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )"
)
database.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
database.execSQL("DROP TABLE preferences")
database.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
db.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
db.execSQL("DROP TABLE preferences")
db.execSQL("ALTER TABLE preferences_tmp RENAME TO preferences")
}
}
}

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration2To3 : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0")
}
}
}

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration3To4 : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS tracks (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}
}

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration4To5 : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0")
}
}
}

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration5To6 : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS track_logs (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, manga_id INTEGER NOT NULL, chapters TEXT NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE)")
db.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)")
}
}
}

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration6To7 : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''")
}
}
}

View File

@@ -5,9 +5,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration7To8 : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
database.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
database.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0")
db.execSQL("CREATE TABLE IF NOT EXISTS suggestions (manga_id INTEGER NOT NULL, relevance REAL NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manga_id), FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)")
}
}
}

View File

@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
class Migration8To9 : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}")
}
}
}

View File

@@ -5,7 +5,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration9To10 : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
}
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor {
@@ -9,6 +10,10 @@ class GZipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request().newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip")
return chain.proceed(newRequest.build())
return try {
chain.proceed(newRequest.build())
} catch (e: NullPointerException) {
throw IOException(e)
}
}
}

View File

@@ -28,16 +28,16 @@ class MangaDataRepository @Inject constructor(
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
db.withTransaction {
storeManga(manga)
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert(entity.copy(mode = mode.id))
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
}
}
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
db.withTransaction {
storeManga(manga)
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert(
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.getPreferencesDao().upsert(
entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f,
@@ -48,25 +48,25 @@ class MangaDataRepository @Inject constructor(
}
suspend fun getReaderMode(mangaId: Long): ReaderMode? {
return db.preferencesDao.find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
return db.getPreferencesDao().find(mangaId)?.let { ReaderMode.valueOf(it.mode) }
}
suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? {
return db.preferencesDao.find(mangaId)?.getColorFilterOrNull()
return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull()
}
fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> {
return db.preferencesDao.observe(mangaId)
return db.getPreferencesDao().observe(mangaId)
.map { it?.getColorFilterOrNull() }
.distinctUntilChanged()
}
suspend fun findMangaById(mangaId: Long): Manga? {
return db.mangaDao.find(mangaId)?.toManga()
return db.getMangaDao().find(mangaId)?.toManga()
}
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? {
return db.mangaDao.findByPublicUrl(publicUrl)?.toManga()
return db.getMangaDao().findByPublicUrl(publicUrl)?.toManga()
}
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
@@ -79,13 +79,13 @@ class MangaDataRepository @Inject constructor(
suspend fun storeManga(manga: Manga) {
val tags = manga.tags.toEntities()
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
}
}
suspend fun findTags(source: MangaSource): Set<MangaTag> {
return db.tagsDao.findTags(source.name).toMangaTags()
return db.getTagsDao().findTags(source.name).toMangaTags()
}
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {

View File

@@ -23,14 +23,14 @@ class MangaLinkResolver @Inject constructor(
) {
suspend fun resolve(uri: Uri): Manga {
return if (uri.host == "kotatsu.app") {
return if (uri.scheme == "kotatsu" || uri.host == "kotatsu.app") {
resolveAppLink(uri)
} else {
resolveExternalLink(uri)
} ?: throw NotFoundException("Manga not found", uri.toString())
} ?: throw NotFoundException("Cannot resolve link", uri.toString())
}
suspend fun resolveAppLink(uri: Uri): Manga? {
private suspend fun resolveAppLink(uri: Uri): Manga? {
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName)
@@ -42,7 +42,7 @@ class MangaLinkResolver @Inject constructor(
)
}
suspend fun resolveExternalLink(uri: Uri): Manga? {
private suspend fun resolveExternalLink(uri: Uri): Manga? {
dataRepository.findMangaByPublicUrl(uri.toString())?.let {
return it
}

View File

@@ -128,6 +128,10 @@ class RemoteMangaRepository(
return details.await()
}
suspend fun peekDetails(manga: Manga): Manga? {
return cache.getDetails(source, manga.url)
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, manga.title)
return list.find { x -> x.id == manga.id }

View File

@@ -97,7 +97,8 @@ abstract class BaseActivity<B : ViewBinding> :
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
onBackPressed()
onBackPressedDispatcher.onBackPressed()
// TODO: navigateUpTo
true
} else super.onOptionsItemSelected(item)
@@ -126,10 +127,10 @@ abstract class BaseActivity<B : ViewBinding> :
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
getThemeColor(com.google.android.material.R.attr.colorSurface),
getThemeColor(R.attr.m3ColorBackground),
)
} else {
ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer)
ContextCompat.getColor(this, R.color.kotatsu_m3_background)
}
val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return

View File

@@ -26,6 +26,17 @@ fun <T> Flow<T>.onFirst(action: suspend (T) -> Unit): Flow<T> {
}
}
fun <T> Flow<T>.onEachWhile(action: suspend (T) -> Boolean): Flow<T> {
var isCalled = false
return onEach {
if (!isCalled) {
isCalled = action(it)
}
}.onCompletion {
isCalled = false
}
}
inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) }
}

View File

@@ -86,4 +86,6 @@ class DetailsInteractor @Inject constructor(
subject
}
}
suspend fun findLocal(seed: Manga) = localMangaRepository.getRemoteManga(seed)
}

View File

@@ -16,7 +16,7 @@ class ProgressUpdateUseCase @Inject constructor(
) {
suspend operator fun invoke(manga: Manga): Float {
val history = database.historyDao.find(manga.id) ?: return PROGRESS_NONE
val history = database.getHistoryDao().find(manga.id) ?: return PROGRESS_NONE
val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga) ?: manga
} else {
@@ -43,7 +43,7 @@ class ProgressUpdateUseCase @Inject constructor(
val ppc = 1f / chaptersCount
val result = ppc * chapterIndex + ppc * pagePercent
if (result != history.percent) {
database.historyDao.update(
database.getHistoryDao().update(
history.copy(
chapterId = chapter.id,
percent = result,

View File

@@ -137,7 +137,9 @@ class DetailsActivity :
this,
MenuInvalidator(viewBinding.toolbarChapters ?: this),
)
viewModel.favouriteCategories.observe(this, MenuInvalidator(this))
val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1
}

View File

@@ -81,7 +81,7 @@ class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener,
ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener {
OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener, View.OnLayoutChangeListener {
@Inject
lateinit var coil: ImageLoader
@@ -105,6 +105,7 @@ class DetailsFragment :
binding.buttonScrobblingMore.setOnClickListener(this)
binding.buttonRelatedMore.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.addOnLayoutChangeListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this
@@ -150,6 +151,22 @@ class DetailsFragment :
}
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
with(viewBinding ?: return) {
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
}
}
private fun onMangaUpdated(manga: Manga) {
with(requireViewBinding()) {
// Main
@@ -228,7 +245,6 @@ class DetailsFragment :
} else {
tv.text = description
}
requireViewBinding().buttonDescriptionMore.isVisible = tv.isTextTruncated
}
private fun onLocalSizeChanged(size: Long) {

View File

@@ -42,6 +42,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
@@ -88,6 +89,12 @@ class DetailsMenuProvider(
}
}
R.id.action_online -> {
viewModel.remoteManga.value?.let {
activity.startActivity(DetailsActivity.newIntent(activity, it))
}
}
R.id.action_related -> {
viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))

View File

@@ -33,7 +33,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.core.util.ext.onEachWhile
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.BranchComparator
@@ -94,6 +94,8 @@ class DetailsViewModel @Inject constructor(
val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val remoteManga = MutableStateFlow<Manga?>(null)
val newChaptersCount = details.flatMapLatest { d ->
if (d?.isLocal == false) {
interactor.observeNewChapters(mangaId)
@@ -213,6 +215,10 @@ class DetailsViewModel @Inject constructor(
progressUpdateUseCase(manga.toManga())
}
}
launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob
remoteManga.value = interactor.findLocal(manga.toManga())
}
}
fun reload() {
@@ -313,11 +319,15 @@ class DetailsViewModel @Inject constructor(
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(intent)
.onFirst {
.onEachWhile {
if (it.allChapters.isEmpty()) {
return@onEachWhile false
}
val manga = it.toManga()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
true
}.collect {
details.value = it
}

View File

@@ -19,6 +19,7 @@ data class DownloadState(
val eta: Long = -1L,
val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0),
val scheduledChapters: LongArray = LongArray(0),
val timestamp: Long = System.currentTimeMillis(),
) {
@@ -42,6 +43,7 @@ data class DownloadState(
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters)
.putLongArray(DATA_CHAPTERS_SRC, scheduledChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
.build()
@@ -64,10 +66,13 @@ data class DownloadState(
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (!scheduledChapters.contentEquals(other.scheduledChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
return percent == other.percent
if (percent != other.percent) return false
return true
}
override fun hashCode(): Int {
@@ -83,6 +88,7 @@ data class DownloadState(
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + scheduledChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
@@ -90,12 +96,14 @@ data class DownloadState(
return result
}
companion object {
private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_CHAPTERS_SRC = "chapters_src"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
@@ -119,5 +127,7 @@ data class DownloadState(
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0)
fun getScheduledChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS_SRC) ?: LongArray(0)
}
}

View File

@@ -1,18 +1,29 @@
package org.koitharu.kotatsu.download.ui.list
import android.transition.TransitionManager
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format
@@ -25,6 +36,9 @@ fun downloadItemAD(
) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) {
@@ -45,8 +59,13 @@ fun downloadItemAD(
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener)
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter
bind { payloads ->
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(binding.constraintLayout)
}
binding.textViewTitle.text = item.manga.title
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
@@ -57,6 +76,10 @@ fun downloadItemAD(
source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.isChecked = item.isExpanded
binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
binding.cardDetails.isVisible = item.isExpanded
chaptersAdapter.items = item.chapters
when (item.workState) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> {

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils
import androidx.work.WorkInfo
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
@@ -19,6 +21,8 @@ data class DownloadItemModel(
val progress: Int,
val eta: Long,
val timestamp: Date,
val chapters: List<DownloadChapter>,
val isExpanded: Boolean,
) : ListModel, Comparable<DownloadItemModel> {
val percent: Float
@@ -33,6 +37,9 @@ data class DownloadItemModel(
val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean
get() = chapters.isNotEmpty()
fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString(
eta,
@@ -51,17 +58,10 @@ data class DownloadItemModel(
return other is DownloadItemModel && other.id == id
}
override fun getChangePayload(previousState: ListModel): Any? {
return when (previousState) {
is DownloadItemModel -> {
if (workState == previousState.workState) {
Unit
} else {
null
}
}
else -> super.getChangePayload(previousState)
}
override fun getChangePayload(previousState: ListModel): Any? = when {
previousState !is DownloadItemModel -> super.getChangePayload(previousState)
workState != previousState.workState -> null
isExpanded != previousState.isExpanded -> ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
else -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
}
}

View File

@@ -82,7 +82,11 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
startActivity(DetailsActivity.newIntent(view.context, item.manga))
if (item.isExpandable) {
viewModel.expandCollapse(item)
} else {
startActivity(DetailsActivity.newIntent(view.context, item.manga))
}
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {

View File

@@ -8,15 +8,19 @@ import androidx.work.Data
import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -24,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -31,6 +36,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import java.util.LinkedList
import java.util.UUID
@@ -41,13 +47,18 @@ import javax.inject.Inject
class DownloadsViewModel @Inject constructor(
private val workScheduler: DownloadWorker.Scheduler,
private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex()
private val works = workScheduler.observeWorks()
.mapLatest { it.toDownloadsList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
private val expanded = MutableStateFlow(emptySet<UUID>())
private val works = combine(
workScheduler.observeWorks(),
expanded,
) { list, exp ->
list.toDownloadsList(exp)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -169,11 +180,21 @@ class DownloadsViewModel @Inject constructor(
it.id.mostSignificantBits
} ?: emptySet()
private suspend fun List<WorkInfo>.toDownloadsList(): List<DownloadItemModel> {
fun expandCollapse(item: DownloadItemModel) {
expanded.update {
if (item.id in it) {
it - item.id
} else {
it + item.id
}
}
}
private suspend fun List<WorkInfo>.toDownloadsList(exp: Set<UUID>): List<DownloadItemModel> {
if (isEmpty()) {
return emptyList()
}
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() }
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel(it.id in exp) }
list.sortByDescending { it.timestamp }
return list
}
@@ -213,11 +234,13 @@ class DownloadsViewModel @Inject constructor(
return destination
}
private suspend fun WorkInfo.toUiModel(): DownloadItemModel? {
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
val workData = if (outputData == Data.EMPTY) progress else outputData
val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null
val downloadedChapters = DownloadState.getDownloadedChapters(workData)
val scheduledChapters = DownloadState.getScheduledChapters(workData).toSet()
return DownloadItemModel(
id = id,
workState = state,
@@ -229,7 +252,19 @@ class DownloadsViewModel @Inject constructor(
progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData),
totalChapters = DownloadState.getDownloadedChapters(workData).size,
totalChapters = downloadedChapters.size,
isExpanded = isExpanded,
chapters = manga.chapters?.mapNotNull {
if (it.id in scheduledChapters) {
DownloadChapter(
number = it.number,
name = it.name,
isDownloaded = it.id in downloadedChapters,
)
} else {
null
}
}.orEmpty(),
)
}
@@ -261,8 +296,16 @@ class DownloadsViewModel @Inject constructor(
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
mangaDataRepository.findMangaById(mangaId)?.also { mangaCache[mangaId] = it } ?: return null
mangaDataRepository.findMangaById(mangaId)?.let {
tryLoad(it) ?: it
}?.also {
mangaCache[mangaId] = it
} ?: return null
}
}
}
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga)
}.getOrNull()
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.download.ui.list.chapters
import org.koitharu.kotatsu.list.ui.model.ListModel
data class DownloadChapter(
val number: Int,
val name: String,
val isDownloaded: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is DownloadChapter && other.name == name
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.download.ui.list.chapters
import androidx.core.content.ContextCompat
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.databinding.ItemChapterDownloadBinding
fun downloadChapterAD() = adapterDelegateViewBinding<DownloadChapter, DownloadChapter, ItemChapterDownloadBinding>(
{ layoutInflater, parent -> ItemChapterDownloadBinding.inflate(layoutInflater, parent, false) },
) {
val iconDone = ContextCompat.getDrawable(context, R.drawable.ic_check)
bind {
binding.textViewNumber.text = item.number.toString()
binding.textViewTitle.text = item.name
binding.textViewTitle.drawableEnd = if (item.isDownloaded) iconDone else null
}
}

View File

@@ -178,6 +178,9 @@ class DownloadWorker @AssistedInject constructor(
}
}
val chapters = getChapters(mangaDetails, includedIds)
publishState(
currentState.copy(scheduledChapters = LongArray(chapters.size) { i -> chapters[i].id }),
)
for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) {
publishState(

View File

@@ -30,7 +30,7 @@ class MangaSourcesRepository @Inject constructor(
) {
private val dao: MangaSourcesDao
get() = db.sourcesDao
get() = db.getSourcesDao()
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)

View File

@@ -31,27 +31,27 @@ class FavouritesRepository @Inject constructor(
) {
suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll()
val entities = db.getFavouritesDao().findAll()
return entities.toMangaList()
}
suspend fun getLastManga(limit: Int): List<Manga> {
val entities = db.favouritesDao.findLast(limit)
val entities = db.getFavouritesDao().findLast(limit)
return entities.toMangaList()
}
fun observeAll(order: ListSortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order)
return db.getFavouritesDao().observeAll(order)
.mapItems { it.toManga() }
}
suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId)
val entities = db.getFavouritesDao().findAll(categoryId)
return entities.toMangaList()
}
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order)
return db.getFavouritesDao().observeAll(categoryId, order)
.mapItems { it.toManga() }
}
@@ -61,25 +61,25 @@ class FavouritesRepository @Inject constructor(
}
fun observeCategories(): Flow<List<FavouriteCategory>> {
return db.favouriteCategoriesDao.observeAll().mapItems {
return db.getFavouriteCategoriesDao().observeAll().mapItems {
it.toFavouriteCategory()
}.distinctUntilChanged()
}
fun observeCategoriesForLibrary(): Flow<List<FavouriteCategory>> {
return db.favouriteCategoriesDao.observeAllForLibrary().mapItems {
return db.getFavouriteCategoriesDao().observeAllForLibrary().mapItems {
it.toFavouriteCategory()
}.distinctUntilChanged()
}
fun observeCategoriesWithCovers(): Flow<Map<FavouriteCategory, List<Cover>>> {
return db.favouriteCategoriesDao.observeAll()
return db.getFavouriteCategoriesDao().observeAll()
.map {
db.withTransaction {
val res = LinkedHashMap<FavouriteCategory, List<Cover>>()
for (entity in it) {
val cat = entity.toFavouriteCategory()
res[cat] = db.favouritesDao.findCovers(
res[cat] = db.getFavouritesDao().findCovers(
categoryId = cat.id,
order = cat.order,
)
@@ -90,16 +90,16 @@ class FavouritesRepository @Inject constructor(
}
fun observeCategory(id: Long): Flow<FavouriteCategory?> {
return db.favouriteCategoriesDao.observe(id)
return db.getFavouriteCategoriesDao().observe(id)
.map { it?.toFavouriteCategory() }
}
fun observeCategoriesIds(mangaId: Long): Flow<Set<Long>> {
return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
return db.getFavouritesDao().observeIds(mangaId).map { it.toSet() }
}
suspend fun getCategory(id: Long): FavouriteCategory {
return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory()
return db.getFavouriteCategoriesDao().find(id.toInt()).toFavouriteCategory()
}
suspend fun createCategory(
@@ -111,14 +111,14 @@ class FavouritesRepository @Inject constructor(
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
sortKey = db.getFavouriteCategoriesDao().getNextSortKey(),
categoryId = 0,
order = sortOrder.name,
track = isTrackerEnabled,
deletedAt = 0L,
isVisibleInLibrary = isVisibleOnShelf,
)
val id = db.favouriteCategoriesDao.insert(entity)
val id = db.getFavouriteCategoriesDao().insert(entity)
val category = entity.toFavouriteCategory(id)
channels.createChannel(category)
return category
@@ -131,22 +131,22 @@ class FavouritesRepository @Inject constructor(
isTrackerEnabled: Boolean,
isVisibleOnShelf: Boolean,
) {
db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled, isVisibleOnShelf)
db.getFavouriteCategoriesDao().update(id, title, sortOrder.name, isTrackerEnabled, isVisibleOnShelf)
}
suspend fun updateCategory(id: Long, isVisibleInLibrary: Boolean) {
db.favouriteCategoriesDao.updateLibVisibility(id, isVisibleInLibrary)
db.getFavouriteCategoriesDao().updateLibVisibility(id, isVisibleInLibrary)
}
suspend fun updateCategoryTracking(id: Long, isTrackingEnabled: Boolean) {
db.favouriteCategoriesDao.updateTracking(id, isTrackingEnabled)
db.getFavouriteCategoriesDao().updateTracking(id, isTrackingEnabled)
}
suspend fun removeCategories(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.deleteAll(id)
db.favouriteCategoriesDao.delete(id)
db.getFavouritesDao().deleteAll(id)
db.getFavouriteCategoriesDao().delete(id)
}
}
// run after transaction success
@@ -156,11 +156,11 @@ class FavouritesRepository @Inject constructor(
}
suspend fun setCategoryOrder(id: Long, order: ListSortOrder) {
db.favouriteCategoriesDao.updateOrder(id, order.name)
db.getFavouriteCategoriesDao().updateOrder(id, order.name)
}
suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao
val dao = db.getFavouriteCategoriesDao()
db.withTransaction {
for ((i, id) in orderedIds.withIndex()) {
dao.updateSortKey(id, i)
@@ -172,8 +172,8 @@ class FavouritesRepository @Inject constructor(
db.withTransaction {
for (manga in mangas) {
val tags = manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
val entity = FavouriteEntity(
mangaId = manga.id,
categoryId = categoryId,
@@ -181,7 +181,7 @@ class FavouritesRepository @Inject constructor(
sortKey = 0,
deletedAt = 0L,
)
db.favouritesDao.insert(entity)
db.getFavouritesDao().insert(entity)
}
}
}
@@ -189,7 +189,7 @@ class FavouritesRepository @Inject constructor(
suspend fun removeFromFavourites(ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(mangaId = id)
db.getFavouritesDao().delete(mangaId = id)
}
}
return ReversibleHandle { recoverToFavourites(ids) }
@@ -198,14 +198,14 @@ class FavouritesRepository @Inject constructor(
suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(categoryId = categoryId, mangaId = id)
db.getFavouritesDao().delete(categoryId = categoryId, mangaId = id)
}
}
return ReversibleHandle { recoverToCategory(categoryId, ids) }
}
private fun observeOrder(categoryId: Long): Flow<ListSortOrder> {
return db.favouriteCategoriesDao.observe(categoryId)
return db.getFavouriteCategoriesDao().observe(categoryId)
.filterNotNull()
.map { x -> ListSortOrder(x.order, ListSortOrder.NEWEST) }
.distinctUntilChanged()
@@ -214,7 +214,7 @@ class FavouritesRepository @Inject constructor(
private suspend fun recoverToFavourites(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.recover(mangaId = id)
db.getFavouritesDao().recover(mangaId = id)
}
}
}
@@ -222,7 +222,7 @@ class FavouritesRepository @Inject constructor(
private suspend fun recoverToCategory(categoryId: Long, ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.recover(mangaId = id, categoryId = categoryId)
db.getFavouritesDao().recover(mangaId = id, categoryId = categoryId)
}
}
}

View File

@@ -23,7 +23,7 @@ abstract class HistoryDao {
@Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity?>
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity>
@Transaction
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")

View File

@@ -38,36 +38,36 @@ class HistoryRepository @Inject constructor(
) {
suspend fun getList(offset: Int, limit: Int): List<Manga> {
val entities = db.historyDao.findAll(offset, limit)
val entities = db.getHistoryDao().findAll(offset, limit)
return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
}
suspend fun getLastOrNull(): Manga? {
val entity = db.historyDao.findAll(0, 1).firstOrNull() ?: return null
val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null
return entity.manga.toManga(entity.tags.toMangaTags())
}
fun observeLast(): Flow<Manga?> {
return db.historyDao.observeAll(1).map {
return db.getHistoryDao().observeAll(1).map {
val first = it.firstOrNull()
first?.manga?.toManga(first.tags.toMangaTags())
}
}
fun observeAll(): Flow<List<Manga>> {
return db.historyDao.observeAll().mapItems {
return db.getHistoryDao().observeAll().mapItems {
it.manga.toManga(it.tags.toMangaTags())
}
}
fun observeAll(limit: Int): Flow<List<Manga>> {
return db.historyDao.observeAll(limit).mapItems {
return db.getHistoryDao().observeAll(limit).mapItems {
it.manga.toManga(it.tags.toMangaTags())
}
}
fun observeAllWithHistory(order: ListSortOrder): Flow<List<MangaWithHistory>> {
return db.historyDao.observeAll(order).mapItems {
return db.getHistoryDao().observeAll(order).mapItems {
MangaWithHistory(
it.manga.toManga(it.tags.toMangaTags()),
it.history.toMangaHistory(),
@@ -76,13 +76,13 @@ class HistoryRepository @Inject constructor(
}
fun observeOne(id: Long): Flow<MangaHistory?> {
return db.historyDao.observe(id).map {
return db.getHistoryDao().observe(id).map {
it?.toMangaHistory()
}
}
fun observeHasItems(): Flow<Boolean> {
return db.historyDao.observeCount()
return db.getHistoryDao().observeCount()
.map { it > 0 }
.distinctUntilChanged()
}
@@ -93,12 +93,12 @@ class HistoryRepository @Inject constructor(
}
val tags = manga.tags.toEntities()
db.withTransaction {
val existing = db.mangaDao.find(manga.id)?.manga
val existing = db.getMangaDao().find(manga.id)?.manga
if (existing == null || existing.source == manga.source.name) {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
}
db.historyDao.upsert(
db.getHistoryDao().upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
@@ -119,29 +119,29 @@ class HistoryRepository @Inject constructor(
}
suspend fun getOne(manga: Manga): MangaHistory? {
return db.historyDao.find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
return db.getHistoryDao().find(manga.id)?.recoverIfNeeded(manga)?.toMangaHistory()
}
suspend fun getProgress(mangaId: Long): Float {
return db.historyDao.findProgress(mangaId) ?: PROGRESS_NONE
return db.getHistoryDao().findProgress(mangaId) ?: PROGRESS_NONE
}
suspend fun clear() {
db.historyDao.clear()
db.getHistoryDao().clear()
}
suspend fun delete(manga: Manga) {
db.historyDao.delete(manga.id)
db.getHistoryDao().delete(manga.id)
}
suspend fun deleteAfter(minDate: Long) {
db.historyDao.deleteAfter(minDate)
db.getHistoryDao().deleteAfter(minDate)
}
suspend fun delete(ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {
db.historyDao.delete(id)
db.getHistoryDao().delete(id)
}
}
return ReversibleHandle {
@@ -154,13 +154,13 @@ class HistoryRepository @Inject constructor(
* Useful for replacing saved manga on deleting it with remote source
*/
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.mangaDao.update(alternative.toEntity()) <= 0) {
db.historyDao.delete(manga.id)
if (alternative == null || db.getMangaDao().update(alternative.toEntity()) <= 0) {
db.getHistoryDao().delete(manga.id)
}
}
suspend fun getPopularTags(limit: Int): List<MangaTag> {
return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
return db.getHistoryDao().findPopularTags(limit).map { x -> x.toMangaTag() }
}
fun shouldSkip(manga: Manga): Boolean {
@@ -178,7 +178,7 @@ class HistoryRepository @Inject constructor(
private suspend fun recover(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.historyDao.recover(id)
db.getHistoryDao().recover(id)
}
}
}
@@ -192,7 +192,7 @@ class HistoryRepository @Inject constructor(
(chapters.size * percent).toInt(),
)?.id ?: return this
val newEntity = copy(chapterId = newChapterId)
db.historyDao.update(newEntity)
db.getHistoryDao().update(newEntity)
return newEntity
}
}

View File

@@ -26,4 +26,5 @@ enum class ListItemType {
CATEGORY_LARGE,
MANGA_SCROBBLING,
NAV_ITEM,
CHAPTER,
}

View File

@@ -59,6 +59,7 @@ class TypedListSpacingDecoration(
ListItemType.MANGA_NESTED_GROUP,
ListItemType.CATEGORY_LARGE,
ListItemType.NAV_ITEM,
ListItemType.CHAPTER,
null,
-> outRect.set(0)
@@ -77,6 +78,6 @@ class TypedListSpacingDecoration(
private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing)
private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
}

View File

@@ -40,13 +40,15 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
val mangaUri = root.toUri().toString()
val chapterFiles = getChaptersFiles()
val info = index?.getMangaInfo()
val cover = fileUri(
root,
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
)
val manga = info?.copy2(
source = MangaSource.LOCAL,
url = mangaUri,
coverUrl = fileUri(
root,
index.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
),
coverUrl = cover,
largeCoverUrl = cover,
chapters = info.chapters?.mapIndexed { i, c ->
c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL)
},

View File

@@ -67,10 +67,11 @@ sealed class LocalMangaInput(
@JvmStatic
protected fun Manga.copy2(
url: String = this.url,
coverUrl: String = this.coverUrl,
chapters: List<MangaChapter>? = this.chapters,
source: MangaSource = this.source,
url: String,
coverUrl: String,
largeCoverUrl: String,
chapters: List<MangaChapter>?,
source: MangaSource,
) = Manga(
id = id,
title = title,
@@ -91,8 +92,8 @@ sealed class LocalMangaInput(
@JvmStatic
protected fun MangaChapter.copy(
url: String = this.url,
source: MangaSource = this.source,
url: String,
source: MangaSource,
) = MangaChapter(
id = id,
name = name,

View File

@@ -41,14 +41,15 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
val index = entry?.let(zip::readText)?.let(::MangaIndex)
val info = index?.getMangaInfo()
if (info != null) {
val cover = zipUri(
root,
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
)
return@use info.copy2(
source = MangaSource.LOCAL,
url = fileUri,
coverUrl = zipUri(
root,
entryName = index.getCoverEntry()
?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
),
coverUrl = cover,
largeCoverUrl = cover,
chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL)
},

View File

@@ -24,6 +24,7 @@ import coil.request.ImageRequest
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
@@ -91,6 +92,7 @@ class ImportWorker @AssistedInject constructor(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setSilent(true)
.setAutoCancel(true)
result.onSuccess { manga ->
notification.setLargeIcon(
coil.execute(
@@ -110,10 +112,9 @@ class ImportWorker @AssistedInject constructor(
PendingIntent.FLAG_UPDATE_CURRENT,
false,
),
).setAutoCancel(true)
.setVisibility(
if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
)
).setVisibility(
if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
)
notification.setContentTitle(applicationContext.getString(R.string.import_completed))
.setContentText(applicationContext.getString(R.string.import_completed_hint))
.setSmallIcon(R.drawable.ic_stat_done)
@@ -123,6 +124,11 @@ class ImportWorker @AssistedInject constructor(
notification.setContentTitle(applicationContext.getString(R.string.error_occurred))
.setContentText(error.getDisplayMessage(applicationContext.resources))
.setSmallIcon(android.R.drawable.stat_notify_error)
.addAction(
R.drawable.ic_alert_outline,
applicationContext.getString(R.string.report),
ErrorReporterReceiver.getPendingIntent(applicationContext, error),
)
}
return notification.build()
}

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@@ -95,7 +96,6 @@ open class RemoteListViewModel @Inject constructor(
.onEach { filterState ->
loadingJob?.cancelAndJoin()
mangaList.value = null
hasNextPage.value = false
loadList(filterState, false)
}.catch { error ->
listError.value = error
@@ -134,12 +134,18 @@ open class RemoteListViewModel @Inject constructor(
sortOrder = filterState.sortOrder,
tags = filterState.tags,
)
if (!append) {
mangaList.value = list
} else if (list.isNotEmpty()) {
mangaList.value = mangaList.value?.plus(list) ?: list
val oldList = mangaList.getAndUpdate { oldList ->
if (!append || oldList.isNullOrEmpty()) {
list
} else {
oldList + list
}
}.orEmpty()
hasNextPage.value = if (append) {
list.isNotEmpty()
} else {
list.size > oldList.size || hasNextPage.value
}
hasNextPage.value = list.isNotEmpty() // TODO check if new ids added
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
@@ -148,6 +154,7 @@ open class RemoteListViewModel @Inject constructor(
if (!mangaList.value.isNullOrEmpty()) {
errorEvent.call(e)
}
hasNextPage.value = false
}
}.also { loadingJob = it }
}

View File

@@ -106,7 +106,7 @@ class AniListRepository @Inject constructor(
}
override suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.ANILIST.id, mangaId)
return db.getScrobblingDao().delete(ScrobblerService.ANILIST.id, mangaId)
}
override fun logout() {
@@ -223,7 +223,7 @@ class AniListRepository @Inject constructor(
comment = json.getString("notes"),
rating = scoreFormat.normalize(json.getDouble("score").toFloat()),
)
db.scrobblingDao.upsert(entity)
db.getScrobblingDao().upsert(entity)
}
private fun ScrobblerManga(json: JSONObject): ScrobblerManga {

View File

@@ -29,7 +29,7 @@ class AniListScrobbler @Inject constructor(
status: ScrobblingStatus?,
comment: String?,
) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId)
val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,

View File

@@ -67,24 +67,24 @@ abstract class Scrobbler(
}
suspend fun scrobble(mangaId: Long, chapter: MangaChapter) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return
val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) ?: return
repository.updateRate(entity.id, entity.mangaId, chapter)
}
suspend fun getScrobblingInfoOrNull(mangaId: Long): ScrobblingInfo? {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId) ?: return null
val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId) ?: return null
return entity.toScrobblingInfo()
}
abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?)
fun observeScrobblingInfo(mangaId: Long): Flow<ScrobblingInfo?> {
return db.scrobblingDao.observe(scrobblerService.id, mangaId)
return db.getScrobblingDao().observe(scrobblerService.id, mangaId)
.map { it?.toScrobblingInfo() }
}
fun observeAllScrobblingInfo(): Flow<List<ScrobblingInfo>> {
return db.scrobblingDao.observe(scrobblerService.id)
return db.getScrobblingDao().observe(scrobblerService.id)
.map { entities ->
coroutineScope {
entities.map {

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
@@ -18,6 +19,7 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -27,7 +29,6 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import javax.inject.Inject
@HiltViewModel
@@ -53,7 +54,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
get() = availableScrobblers[selectedScrobblerIndex.requireValue()]
val content: StateFlow<List<ListModel>> = combine(
scrobblerMangaList,
scrobblerMangaList.map { it.distinctBy { x -> x.id } },
listError,
hasNextPage,
) { list, error, isHasNextPage ->

View File

@@ -85,7 +85,7 @@ class MALRepository @Inject constructor(
}
override suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.MAL.id, mangaId)
return db.getScrobblingDao().delete(ScrobblerService.MAL.id, mangaId)
}
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
@@ -98,6 +98,7 @@ class MALRepository @Inject constructor(
.build()
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).await().parseJson()
check(response.has("data")) { "Invalid response: \"$response\"" }
val data = response.getJSONArray("data")
return data.mapJSONNotNull { jsonToManga(it) }
}
@@ -175,7 +176,7 @@ class MALRepository @Inject constructor(
comment = json.getString("comments"),
rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f),
)
db.scrobblingDao.upsert(entity)
db.getScrobblingDao().upsert(entity)
}
override fun logout() {

View File

@@ -30,7 +30,7 @@ class MALScrobbler @Inject constructor(
status: ScrobblingStatus?,
comment: String?,
) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId)
val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,

View File

@@ -84,7 +84,7 @@ class ShikimoriRepository @Inject constructor(
}
override suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.SHIKIMORI.id, mangaId)
return db.getScrobblingDao().delete(ScrobblerService.SHIKIMORI.id, mangaId)
}
override fun logout() {
@@ -192,7 +192,7 @@ class ShikimoriRepository @Inject constructor(
comment = json.getString("text"),
rating = (json.getDouble("score").toFloat() / 10f).coerceIn(0f, 1f),
)
db.scrobblingDao.upsert(entity)
db.getScrobblingDao().upsert(entity)
}
private fun ScrobblerManga(json: JSONObject) = ScrobblerManga(

View File

@@ -31,7 +31,7 @@ class ShikimoriScrobbler @Inject constructor(
status: ScrobblingStatus?,
comment: String?,
) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId)
val entity = db.getScrobblingDao().find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,

View File

@@ -40,9 +40,9 @@ class MangaSearchRepository @Inject constructor(
}
val skipNsfw = settings.isNsfwContentDisabled
return if (source != null) {
db.mangaDao.searchByTitle("%$query%", source.name, limit)
db.getMangaDao().searchByTitle("%$query%", source.name, limit)
} else {
db.mangaDao.searchByTitle("%$query%", limit)
db.getMangaDao().searchByTitle("%$query%", limit)
}.let {
if (skipNsfw) it.filterNot { x -> x.manga.isNsfw } else it
}
@@ -83,7 +83,7 @@ class MangaSearchRepository @Inject constructor(
if (query.isEmpty()) {
return emptyList()
}
val titles = db.suggestionDao.getTitles("$query%")
val titles = db.getSuggestionDao().getTitles("$query%")
if (titles.isEmpty()) {
return emptyList()
}
@@ -92,19 +92,20 @@ class MangaSearchRepository @Inject constructor(
suspend fun getTagsSuggestion(query: String, limit: Int, source: MangaSource?): List<MangaTag> {
return when {
query.isNotEmpty() && source != null -> db.tagsDao.findTags(source.name, "%$query%", limit)
query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit)
source != null -> db.tagsDao.findPopularTags(source.name, limit)
else -> db.tagsDao.findPopularTags(limit)
query.isNotEmpty() && source != null -> db.getTagsDao()
.findTags(source.name, "%$query%", limit)
query.isNotEmpty() -> db.getTagsDao().findTags("%$query%", limit)
source != null -> db.getTagsDao().findPopularTags(source.name, limit)
else -> db.getTagsDao().findPopularTags(limit)
}.toMangaTagsList()
}
suspend fun getTagsSuggestion(tags: Set<MangaTag>): List<MangaTag> {
val ids = tags.mapToSet { it.toEntity().id }
return if (ids.size == 1) {
db.tagsDao.findRelatedTags(ids.first())
db.getTagsDao().findRelatedTags(ids.first())
} else {
db.tagsDao.findRelatedTags(ids)
db.getTagsDao().findRelatedTags(ids)
}.mapNotNull { x ->
if (x.id in ids) null else x.toMangaTag()
}

View File

@@ -12,7 +12,7 @@ fun searchSuggestionQueryHintAD(
{ inflater, parent -> ItemSearchSuggestionQueryHintBinding.inflate(inflater, parent, false) },
) {
val viewClickListener = View.OnClickListener { v ->
val viewClickListener = View.OnClickListener { _ ->
listener.onQueryClick(item.query, true)
}

View File

@@ -11,7 +11,7 @@ class TagsAutoCompleteProvider @Inject constructor(
if (query.isEmpty()) {
return emptyList()
}
val tags = db.tagsDao.findTags(query = "$query%", limit = 6)
val tags = db.getTagsDao().findTags(query = "$query%", limit = 6)
val set = HashSet<String>()
val result = ArrayList<String>(tags.size)
for (tag in tags) {

View File

@@ -17,39 +17,39 @@ class SuggestionRepository @Inject constructor(
) {
fun observeAll(): Flow<List<Manga>> {
return db.suggestionDao.observeAll().mapItems {
return db.getSuggestionDao().observeAll().mapItems {
it.manga.toManga(it.tags.toMangaTags())
}
}
fun observeAll(limit: Int): Flow<List<Manga>> {
return db.suggestionDao.observeAll(limit).mapItems {
return db.getSuggestionDao().observeAll(limit).mapItems {
it.manga.toManga(it.tags.toMangaTags())
}
}
suspend fun getRandom(): Manga? {
return db.suggestionDao.getRandom()?.let {
return db.getSuggestionDao().getRandom()?.let {
it.manga.toManga(it.tags.toMangaTags())
}
}
suspend fun clear() {
db.suggestionDao.deleteAll()
db.getSuggestionDao().deleteAll()
}
suspend fun isEmpty(): Boolean {
return db.suggestionDao.count() == 0
return db.getSuggestionDao().count() == 0
}
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
db.withTransaction {
db.suggestionDao.deleteAll()
db.getSuggestionDao().deleteAll()
suggestions.forEach { (manga, relevance) ->
val tags = manga.tags.toEntities()
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags)
db.suggestionDao.upsert(
db.getTagsDao().upsert(tags)
db.getMangaDao().upsert(manga.toEntity(), tags)
db.getSuggestionDao().upsert(
SuggestionEntity(
mangaId = manga.id,
relevance = relevance,

View File

@@ -29,8 +29,7 @@ class SuggestionsActivity :
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
setReorderingAllowed(true)
val fragment = SuggestionsFragment.newInstance()
replace(R.id.container, fragment)
replace(R.id.container, SuggestionsFragment::class.java, null)
}
}
}

View File

@@ -120,11 +120,11 @@ class SyncController @Inject constructor(
private suspend fun MangaDatabase.gc(favourites: Boolean, history: Boolean) = withTransaction {
val deletedAt = System.currentTimeMillis() - defaultGcPeriod
if (history) {
historyDao.gc(deletedAt)
getHistoryDao().gc(deletedAt)
}
if (favourites) {
favouritesDao.gc(deletedAt)
favouriteCategoriesDao.gc(deletedAt)
getFavouritesDao().gc(deletedAt)
getFavouriteCategoriesDao().gc(deletedAt)
}
}

View File

@@ -20,11 +20,11 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
class TrackEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING)
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
@ColumnInfo(name = "last_chapter_id") val lastChapterId: Long,
@ColumnInfo(name = "chapters_new") val newChapters: Int,
@ColumnInfo(name = "last_check") val lastCheck: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.WARNING)
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
)

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.tracker.data
import androidx.room.Dao
import androidx.room.MapInfo
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
@@ -23,9 +23,8 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int?
@MapInfo(keyColumn = "manga_id", valueColumn = "chapters_new")
@Query("SELECT manga_id, chapters_new FROM tracks")
abstract fun observeNewChaptersMap(): Flow<Map<Long, Int>>
abstract fun observeNewChaptersMap(): Flow<Map<@MapColumn(columnName = "manga_id") Long, @MapColumn(columnName = "chapters_new") Int>>
@Query("SELECT chapters_new FROM tracks")
abstract fun observeNewChapters(): Flow<List<Int>>

View File

@@ -41,23 +41,23 @@ class TrackingRepository @Inject constructor(
private var isGcCalled = AtomicBoolean(false)
suspend fun getNewChaptersCount(mangaId: Long): Int {
return db.tracksDao.findNewChapters(mangaId) ?: 0
return db.getTracksDao().findNewChapters(mangaId) ?: 0
}
fun observeNewChaptersCount(mangaId: Long): Flow<Int> {
return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 }
return db.getTracksDao().observeNewChapters(mangaId).map { it ?: 0 }
}
fun observeUpdatedMangaCount(): Flow<Int> {
return db.tracksDao.observeNewChapters().map { list -> list.count { it > 0 } }
return db.getTracksDao().observeNewChapters().map { list -> list.count { it > 0 } }
.onStart { gcIfNotCalled() }
}
fun observeUpdatedManga(limit: Int = 0): Flow<List<Manga>> {
return if (limit == 0) {
db.tracksDao.observeUpdatedManga()
db.getTracksDao().observeUpdatedManga()
} else {
db.tracksDao.observeUpdatedManga(limit)
db.getTracksDao().observeUpdatedManga(limit)
}.mapItems { it.toManga() }
.distinctUntilChanged()
.onStart { gcIfNotCalled() }
@@ -65,7 +65,7 @@ class TrackingRepository @Inject constructor(
suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> {
val ids = mangaList.mapToSet { it.id }
val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }
val tracks = db.getTracksDao().findAll(ids).groupBy { it.mangaId }
val idSet = HashSet<Long>()
val result = ArrayList<MangaTracking>(mangaList.size)
for (item in mangaList) {
@@ -89,7 +89,7 @@ class TrackingRepository @Inject constructor(
@VisibleForTesting
suspend fun getTrack(manga: Manga): MangaTracking {
val track = db.tracksDao.find(manga.id)
val track = db.getTracksDao().find(manga.id)
return MangaTracking(
manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID,
@@ -99,14 +99,14 @@ class TrackingRepository @Inject constructor(
@VisibleForTesting
suspend fun deleteTrack(mangaId: Long) {
db.tracksDao.delete(mangaId)
db.getTracksDao().delete(mangaId)
}
fun observeTrackingLog(limit: Flow<Int>): Flow<List<TrackingLogItem>> {
return limit.flatMapLatest { limitValue ->
combine(
db.tracksDao.observeNewChaptersMap(),
db.trackLogsDao.observeAll(limitValue),
db.getTracksDao().observeNewChaptersMap(),
db.getTrackLogsDao().observeAll(limitValue),
) { counters, entities ->
val countersMap = counters.toMutableMap()
entities.map { x -> x.toTrackingLogItem(countersMap) }
@@ -116,21 +116,21 @@ class TrackingRepository @Inject constructor(
}
}
suspend fun getLogsCount() = db.trackLogsDao.count()
suspend fun getLogsCount() = db.getTrackLogsDao().count()
suspend fun clearLogs() = db.trackLogsDao.clear()
suspend fun clearLogs() = db.getTrackLogsDao().clear()
suspend fun clearCounters() = db.tracksDao.clearCounters()
suspend fun clearCounters() = db.getTracksDao().clearCounters()
suspend fun gc() {
db.tracksDao.gc()
db.trackLogsDao.gc()
db.getTracksDao().gc()
db.getTrackLogsDao().gc()
}
suspend fun saveUpdates(updates: MangaUpdates.Success) {
db.withTransaction {
val track = getOrCreateTrack(updates.manga.id).mergeWith(updates)
db.tracksDao.upsert(track)
db.getTracksDao().upsert(track)
if (updates.isValid && updates.newChapters.isNotEmpty()) {
updatePercent(updates)
val logEntity = TrackLogEntity(
@@ -138,7 +138,7 @@ class TrackingRepository @Inject constructor(
chapters = updates.newChapters.joinToString("\n") { x -> x.name },
createdAt = System.currentTimeMillis(),
)
db.trackLogsDao.insert(logEntity)
db.getTrackLogsDao().insert(logEntity)
}
}
}
@@ -146,10 +146,10 @@ class TrackingRepository @Inject constructor(
suspend fun clearUpdates(ids: Collection<Long>) {
when {
ids.isEmpty() -> return
ids.size == 1 -> db.tracksDao.clearCounter(ids.single())
ids.size == 1 -> db.getTracksDao().clearCounter(ids.single())
else -> db.withTransaction {
for (id in ids) {
db.tracksDao.clearCounter(id)
db.getTracksDao().clearCounter(id)
}
}
}
@@ -174,11 +174,11 @@ class TrackingRepository @Inject constructor(
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = lastChapterId,
)
db.tracksDao.upsert(entity)
db.getTracksDao().upsert(entity)
}
suspend fun getCategoriesCount(): IntArray {
val categories = db.favouriteCategoriesDao.findAll()
val categories = db.getFavouriteCategoriesDao().findAll()
return intArrayOf(
categories.count { it.track },
categories.size,
@@ -186,19 +186,19 @@ class TrackingRepository @Inject constructor(
}
suspend fun getAllFavouritesManga(): Map<FavouriteCategory, List<Manga>> {
val categories = db.favouriteCategoriesDao.findAll()
val categories = db.getFavouriteCategoriesDao().findAll()
return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity ->
categoryEntity.toFavouriteCategory() to
db.favouritesDao.findAllManga(categoryEntity.categoryId).toMangaList()
db.getFavouritesDao().findAllManga(categoryEntity.categoryId).toMangaList()
}
}
suspend fun getAllHistoryManga(): List<Manga> {
return db.historyDao.findAllManga().toMangaList()
return db.getHistoryDao().findAllManga().toMangaList()
}
private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity {
return db.tracksDao.find(mangaId) ?: TrackEntity(
return db.getTracksDao().find(mangaId) ?: TrackEntity(
mangaId = mangaId,
totalChapters = 0,
lastChapterId = 0L,
@@ -209,7 +209,7 @@ class TrackingRepository @Inject constructor(
}
private suspend fun updatePercent(updates: MangaUpdates.Success) {
val history = db.historyDao.find(updates.manga.id) ?: return
val history = db.getHistoryDao().find(updates.manga.id) ?: return
val chapters = updates.manga.chapters
if (chapters.isNullOrEmpty()) {
return
@@ -220,7 +220,7 @@ class TrackingRepository @Inject constructor(
}
val position = (chapters.size - updates.newChapters.size) * history.percent
val newPercent = position / chapters.size.toFloat()
db.historyDao.update(history.copy(percent = newPercent))
db.getHistoryDao().update(history.copy(percent = newPercent))
}
private fun TrackEntity.mergeWith(updates: MangaUpdates.Success): TrackEntity {

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.7" android:color="?attr/m3ColorBackground" />
<item android:alpha="0.7" android:color="?attr/m3ColorBottomMenuBackground" />
</selector>

View File

@@ -7,6 +7,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:scrollIndicators="top"
android:scrollbars="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior">

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="4dp">
<TextView
android:id="@+id/textView_number"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="?android:listPreferredItemPaddingStart"
android:background="@drawable/bg_badge_default"
android:ellipsize="none"
android:gravity="center"
android:singleLine="true"
android:textAlignment="center"
android:textColor="?attr/colorOnPrimary"
android:textSize="12sp"
tools:text="13" />
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="?android:listPreferredItemPaddingStart"
android:layout_marginEnd="?android:listPreferredItemPaddingEnd"
android:drawablePadding="4dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodySmall"
app:drawableTint="?colorControlNormal"
tools:drawableEnd="@drawable/ic_check"
tools:text="@tools:sample/lorem[15]" />
</LinearLayout>

View File

@@ -8,6 +8,7 @@
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="12dp">
@@ -24,7 +25,7 @@
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Medium"
tools:src="@tools:sample/backgrounds/scenic" />
<TextView
<CheckedTextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -32,11 +33,14 @@
android:layout_marginTop="12dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
app:drawableTint="?android:colorControlNormal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="parent"
tools:drawableEndCompat="@drawable/ic_expand_collapse"
tools:text="@tools:sample/lorem" />
<androidx.constraintlayout.widget.Barrier
@@ -100,6 +104,31 @@
app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_details"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_default="wrap"
app:layout_constraintHeight_max="280dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:shapeAppearance="?shapeAppearanceCornerMedium">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_chapters"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="200"
tools:listitem="@layout/item_chapter_download" />
</com.google.android.material.card.MaterialCardView>
<Button
android:id="@+id/button_pause"
style="?materialButtonOutlinedStyle"
@@ -110,7 +139,7 @@
android:text="@string/pause"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_resume"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:layout_constraintTop_toBottomOf="@id/card_details"
tools:visibility="visible" />
<Button
@@ -123,7 +152,7 @@
android:text="@string/resume"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/progressBar" />
app:layout_constraintTop_toBottomOf="@id/card_details" />
<Button
android:id="@+id/button_cancel"
@@ -135,7 +164,7 @@
android:text="@android:string/cancel"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:layout_constraintTop_toBottomOf="@id/card_details"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -43,6 +43,12 @@
android:title="@string/find_similar"
app:showAsAction="never" />
<item
android:id="@+id/action_online"
android:orderInCategory="50"
android:title="@string/online_variant"
app:showAsAction="never" />
<item
android:id="@+id/action_browser"
android:orderInCategory="50"

View File

@@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_import"
android:orderInCategory="50"
android:title="@string/_import"
app:showAsAction="never" />
<item
android:id="@+id/action_categories"
android:orderInCategory="50"
android:title="@string/settings"
app:showAsAction="never" />
<item
android:id="@+id/action_grid_size"
android:orderInCategory="50"
android:title="@string/grid_size"
app:showAsAction="never" />
<item
android:id="@+id/action_clear_history"
android:orderInCategory="50"
android:title="@string/clear_history"
app:showAsAction="never" />
</menu>

View File

@@ -487,10 +487,11 @@
<string name="keep_screen_on_summary">Ня выключаць экран падчас чытання мангі</string>
<string name="state_abandoned">Кінута</string>
<string name="categories">Катэгорыі</string>
<string name="list_options">Спіс опцый</string>
<string name="list_options">Параметры спісу</string>
<string name="suggest_new_sources">Прапаноўваць новыя крыніцы пасля абнаўлення праграмы</string>
<string name="enhanced_colors_summary">Памяншае паласы, але можа паўплываць на прадукцыйнасць</string>
<string name="by_relevance">Актуальнасць</string>
<string name="enhanced_colors">32-бітны каляровы рэжым</string>
<string name="suggest_new_sources_summary">Запыт на ўключэнне зноў дададзеных крыніц пасля абнаўлення праграмы</string>
<string name="suggest_new_sources_summary">Прапаноўваць крыніцы мангі, дададзеныя ў апошнім абнаўленні праграмы</string>
<string name="online_variant">Анлайн варыянт</string>
</resources>

View File

@@ -493,4 +493,5 @@
<string name="list_options">Lista de opciones</string>
<string name="enhanced_colors_summary">Reduce el banding, pero puede afectar al rendimiento</string>
<string name="by_relevance">Relevancia</string>
<string name="online_variant">Variante en línea</string>
</resources>

View File

@@ -493,4 +493,5 @@
<string name="categories">Mga Kategorya</string>
<string name="list_options">Opsyon sa Listahan</string>
<string name="by_relevance">Kaugnayan</string>
<string name="online_variant">Online na baryante</string>
</resources>

View File

@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Base.V31.Kotatsu" parent="Base.V27.Kotatsu">
<item name="android:windowSplashScreenAnimatedIcon">@drawable/avd_splash</item>
<item name="android:windowSplashScreenBackground">@android:color/system_neutral2_900</item>
<item name="android:windowSplashScreenAnimationDuration">800</item>
</style>
<!-- From ThemeOverlay.Material3.DynamicColors.Dark -->
<style name="Theme.Kotatsu.Monet">
<item name="isMaterial3DynamicColorApplied">true</item>

View File

@@ -493,4 +493,5 @@
<string name="by_relevance">Актуальность</string>
<string name="enhanced_colors">32-битный цветовой режим</string>
<string name="suggest_new_sources_summary">Предлагать источники манги, добавленные в последнем обновлении приложения</string>
<string name="online_variant">Онлайн вариант</string>
</resources>

View File

@@ -490,4 +490,8 @@
<string name="download_option_all_chapters">Çevirisi %s olan tüm bölümler</string>
<string name="color_black">Siyah</string>
<string name="this_month">Bu ay</string>
<string name="categories">Kategoriler</string>
<string name="list_options">Seçenekleri listele</string>
<string name="online_variant">Çevrimiçi varyant</string>
<string name="by_relevance">Alaka düzeyi</string>
</resources>

View File

@@ -58,7 +58,7 @@
<string name="clear_pages_cache">Очистити кеш сторінок</string>
<string name="text_file_sizes">Б|кБ|МБ|ГБ|ТБ</string>
<string name="standard">Стандартний</string>
<string name="webtoon">Манхва</string>
<string name="webtoon">Вебтун</string>
<string name="read_mode">Режим читання</string>
<string name="grid_size">Розмір сітки</string>
<string name="search_on_s">Пошук по %s</string>
@@ -479,18 +479,19 @@
<string name="directories">Каталоги</string>
<string name="main_screen_sections">Розділи головного екрана</string>
<string name="to_top">Вгору</string>
<string name="zoom_in">Збільшити</string>
<string name="zoom_in">Наблизити</string>
<string name="reader_zoom_buttons_summary">Чи показувати елементи керування масштабуванням у нижньому правому куті</string>
<string name="reader_zoom_buttons">Показати кнопки масштабування</string>
<string name="zoom_out">Зменшити</string>
<string name="zoom_out">Віддалити</string>
<string name="keep_screen_on">Тримати екран увімкненим</string>
<string name="keep_screen_on_summary">Не вимикати екран під час читання манги</string>
<string name="state_abandoned">Кинута</string>
<string name="categories">Категорії</string>
<string name="list_options">Список опцій</string>
<string name="suggest_new_sources">Пропонуйте нові джерела після оновлення застосунка</string>
<string name="list_options">Параметри списку</string>
<string name="suggest_new_sources">Пропонувати нові джерела після оновлення застосунку</string>
<string name="enhanced_colors_summary">Зменшує смуги, але може вплинути на продуктивність</string>
<string name="by_relevance">Актуальність</string>
<string name="enhanced_colors">32-бітний колірний режим</string>
<string name="suggest_new_sources_summary">Запропонувати ввімкнути щойно додані джерела після оновлення застосунка</string>
<string name="suggest_new_sources_summary">Пропонує включити нові джерела манґи після оновлення застосунку</string>
<string name="online_variant">Онлайн варіант</string>
</resources>

View File

@@ -3,6 +3,7 @@
<style name="Base.V31.Kotatsu" parent="Base.V27.Kotatsu">
<item name="android:windowSplashScreenAnimatedIcon">@drawable/avd_splash</item>
<item name="android:windowSplashScreenBackground">@android:color/system_neutral2_50</item>
<item name="android:windowSplashScreenAnimationDuration">800</item>
</style>

View File

@@ -1,18 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="new_chapters">
<item quantity="other">%1$d chương mới</item>
</plurals>
<plurals name="chapters">
<item quantity="other">%1$d chương</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="other">%1$d phút trước</item>
</plurals>
<plurals name="hours_ago">
<item quantity="other">%1$d giờ trước</item>
</plurals>
<plurals name="days_ago">
<item quantity="other">%1$d ngày trước</item>
</plurals>
</resources>
<plurals name="new_chapters">
<item quantity="other">%1$d chương mới</item>
</plurals>
<plurals name="chapters">
<item quantity="other">%1$d chương</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="other">%1$d phút trước</item>
</plurals>
<plurals name="hours_ago">
<item quantity="other">%1$d giờ trước</item>
</plurals>
<plurals name="days_ago">
<item quantity="other">%1$d ngày trước</item>
</plurals>
<plurals name="items">
<item quantity="other">%1$d mục</item>
</plurals>
<plurals name="months_ago">
<item quantity="other">%1$d tháng trước</item>
</plurals>
</resources>

View File

@@ -407,7 +407,7 @@
<string name="local_manga_processing">Xử lý các truyện được lưu</string>
<string name="text_delete_local_manga_batch">Xoá vĩnh viễn những thứ được chọn khỏi thiết bị\?</string>
<string name="percent_string_pattern">%1$s%%</string>
<string name="disable_battery_optimization_summary">Giúp kiểm tra chương mới dưới nền dễ dàng hơn</string>
<string name="disable_battery_optimization_summary">Giúp kiểm tra chương mới trong khi chạy dưới nền dễ dàng hơn</string>
<string name="show_on_shelf">Hiển thị trên kệ sách</string>
<string name="mirror_switching">Tự động chọn máy chủ dự phòng</string>
<string name="mirror_switching_summary">Tự động chuyển tên miền sang máy chủ dự phòng (nếu có) nếu gặp lỗi trên tên miền chính</string>

View File

@@ -68,7 +68,7 @@
<string name="downloads">下载</string>
<string name="by_name">名称</string>
<string name="popular">热门</string>
<string name="updated">更新</string>
<string name="updated">最近更新</string>
<string name="sort_order">排序</string>
<string name="filter">筛选</string>
<string name="theme">主题</string>
@@ -483,4 +483,9 @@
<string name="zoom_out">缩小</string>
<string name="keep_screen_on">保持亮屏</string>
<string name="keep_screen_on_summary">阅读漫画时不关闭屏幕</string>
<string name="list_options">列表选项</string>
<string name="suggest_new_sources">推荐新漫画源</string>
<string name="enhanced_colors_summary">减少色带,但可能会影响性能</string>
<string name="enhanced_colors">32位色彩模式</string>
<string name="suggest_new_sources_summary">应用更新后快速启用新增的漫画源</string>
</resources>

View File

@@ -499,4 +499,5 @@
<string name="list_options">List options</string>
<string name="by_relevance">Relevance</string>
<string name="categories">Categories</string>
<string name="online_variant">Online variant</string>
</resources>