Compare commits

...

49 Commits
v6.2 ... v6.2.6

Author SHA1 Message Date
Koitharu
06ec145802 Update parsers 2023-11-02 08:56:43 +02:00
Koitharu
6624778f7f Fix periodical backups 2023-11-02 08:50:51 +02:00
Koitharu
1af1f071ad Fix crashes 2023-11-01 17:25:55 +02:00
Koitharu
f87db4e6d3 Update dependencies 2023-11-01 16:38:10 +02:00
Crono
07bd66fb39 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (508 of 508 strings)

Co-authored-by: Crono <cronoreader@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2023-11-01 16:32:38 +02:00
Koitharu
4bb0d52217 Fix downloading 2023-10-28 16:39:43 +03:00
Koitharu
66de4bd49e Translated using Weblate (Russian)
Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Bai
ff12d63696 Translated using Weblate (Turkish)
Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
InfinityDouki56
c168a841f3 Translated using Weblate (Filipino)
Currently translated at 88.9% (451 of 507 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
pro maxime
8bfb676e6a Translated using Weblate (Arabic)
Currently translated at 36.0% (183 of 507 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: pro maxime <promaxime45@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-28 16:16:39 +03:00
gallegonovato
d5c0ce280e Translated using Weblate (Spanish)
Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Vinícius Saturnino
b34627c361 Translated using Weblate (Portuguese)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Vinícius Saturnino <saturninodepaulavinicius62@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Paulo Oliveira
cbc3be056a Translated using Weblate (Portuguese)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Paulo Oliveira <junior.literasas@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Koitharu
d9acc4ec18 Fix periodical backups to external directory 2023-10-28 16:14:47 +03:00
Koitharu
577cc848ee Scroll lists to top atomatically 2023-10-28 15:26:22 +03:00
Koitharu
8a64c88a07 (Temporary) remove chapters list from downloads 2023-10-28 14:44:58 +03:00
Koitharu
1cd7745e38 Update parsers 2023-10-28 13:26:02 +03:00
Koitharu
395b3f7200 Fix proguard rules 2023-10-27 17:27:40 +03:00
Koitharu
b8db4c81d8 Handle up navigation from reader 2023-10-27 16:44:40 +03:00
Koitharu
98bd42f3ae Remove deletions from sync process 2023-10-27 15:02:10 +03:00
Koitharu
db8835a7b8 Fix history restoring 2023-10-27 14:18:14 +03:00
Koitharu
afe50a9ed6 Fixes 2023-10-27 13:58:04 +03:00
Koitharu
beba818f57 Periodic backups 2023-10-26 17:24:11 +03:00
Koitharu
beb17ef442 Pause autoscroll while touch down 2023-10-26 16:13:30 +03:00
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
121 changed files with 1274 additions and 536 deletions

View File

@@ -16,11 +16,12 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 587 versionCode = 594
versionName = '6.2' versionName = '6.2.6'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
// arg("room.generateKotlin", "true") TODO: enable later
arg("room.schemaLocation", "$projectDir/schemas") arg("room.schemaLocation", "$projectDir/schemas")
} }
androidResources { androidResources {
@@ -81,11 +82,11 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:a61e441e79') { implementation('com.github.KotatsuApp:kotatsu-parsers:face1d5b26') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.10' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
@@ -98,7 +99,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-process:2.6.2' implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' 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.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
@@ -114,12 +115,12 @@ dependencies {
exclude group: 'com.google.j2objc', module: 'j2objc-annotations' exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
} }
implementation 'androidx.room:room-runtime:2.5.2' implementation 'androidx.room:room-runtime:2.6.0'
implementation 'androidx.room:room-ktx:2.5.2' implementation 'androidx.room:room-ktx:2.6.0'
ksp 'androidx.room:room-compiler:2.5.2' ksp 'androidx.room:room-compiler:2.6.0'
implementation 'com.squareup.okhttp3:okhttp:4.11.0' implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.6.0' implementation 'com.squareup.okio:okio:3.6.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
@@ -130,19 +131,19 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.4.0' implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.4.0' implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:cf089a264d' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:cf089a264d'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.2' implementation 'ch.acra:acra-http:5.11.3'
implementation 'ch.acra:acra-dialog:5.11.2' implementation 'ch.acra:acra-dialog:5.11.3'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230618' testImplementation 'org.json:json:20231013'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'androidx.test:runner:1.5.2' androidTestImplementation 'androidx.test:runner:1.5.2'
@@ -152,7 +153,7 @@ dependencies {
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' 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.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'

View File

@@ -18,3 +18,4 @@
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; } -keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }

View File

@@ -82,7 +82,7 @@ class AppBackupAgentTest {
assertEquals(history, historyRepository.getOne(SampleData.manga)) assertEquals(history, historyRepository.getOne(SampleData.manga))
assertEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id)) 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) assertTrue(SampleData.tag in allTags)
} }

View File

@@ -83,6 +83,16 @@
<data android:host="kotatsu.app" /> <data android:host="kotatsu.app" />
<data android:path="/manga" /> <data android:path="/manga" />
</intent-filter> </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>
<activity <activity
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity" android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
@@ -327,6 +337,13 @@
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" /> <action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter> </intent-filter>
</receiver> </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 <meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing" 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?> { 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>> { 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>>> { 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) val res = LinkedHashMap<Manga, List<Bookmark>>(map.size)
for ((k, v) in map) { for ((k, v) in map) {
val manga = k.toManga() val manga = k.toManga()
@@ -46,9 +46,9 @@ class BookmarksRepository @Inject constructor(
suspend fun addBookmark(bookmark: Bookmark) { suspend fun addBookmark(bookmark: Bookmark) {
db.withTransaction { db.withTransaction {
val tags = bookmark.manga.tags.toEntities() val tags = bookmark.manga.tags.toEntities()
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(bookmark.manga.toEntity(), tags) db.getMangaDao().upsert(bookmark.manga.toEntity(), tags)
db.bookmarksDao.insert(bookmark.toEntity()) db.getBookmarksDao().insert(bookmark.toEntity())
} }
} }
@@ -56,11 +56,11 @@ class BookmarksRepository @Inject constructor(
val entity = bookmark.toEntity().copy( val entity = bookmark.toEntity().copy(
imageUrl = imageUrl, imageUrl = imageUrl,
) )
db.bookmarksDao.upsert(listOf(entity)) db.getBookmarksDao().upsert(listOf(entity))
} }
suspend fun removeBookmark(mangaId: Long, chapterId: Long, page: Int) { 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" "Bookmark not found"
} }
} }
@@ -72,7 +72,7 @@ class BookmarksRepository @Inject constructor(
suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle { suspend fun removeBookmarks(ids: Set<Long>): ReversibleHandle {
val entities = ArrayList<BookmarkEntity>(ids.size) val entities = ArrayList<BookmarkEntity>(ids.size)
db.withTransaction { db.withTransaction {
val dao = db.bookmarksDao val dao = db.getBookmarksDao()
for (pageId in ids) { for (pageId in ids) {
val e = dao.find(pageId) val e = dao.find(pageId)
if (e != null) { if (e != null) {
@@ -92,7 +92,7 @@ class BookmarksRepository @Inject constructor(
db.withTransaction { db.withTransaction {
for (e in entities) { for (e in entities) {
try { try {
db.bookmarksDao.insert(e) db.getBookmarksDao().insert(e)
} catch (e: SQLException) { } catch (e: SQLException) {
e.printStackTraceDebug() e.printStackTraceDebug()
} }

View File

@@ -34,8 +34,8 @@ class BookmarksActivity :
val fm = supportFragmentManager val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) { if (fm.findFragmentById(R.id.container) == null) {
fm.commit { fm.commit {
val fragment = BookmarksFragment.newInstance() setReorderingAllowed(true)
replace(R.id.container, fragment) 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 var offset = 0
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
while (true) { while (true) {
val history = db.historyDao.findAll(offset, PAGE_SIZE) val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) { if (history.isEmpty()) {
break break
} }
@@ -42,7 +42,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpCategories(): BackupEntry { suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
val categories = db.favouriteCategoriesDao.findAll() val categories = db.getFavouriteCategoriesDao().findAll()
for (item in categories) { for (item in categories) {
entry.data.put(JsonSerializer(item).toJson()) entry.data.put(JsonSerializer(item).toJson())
} }
@@ -53,7 +53,7 @@ class BackupRepository @Inject constructor(
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE) val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
break break
} }
@@ -73,7 +73,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpBookmarks(): BackupEntry { suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray()) val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray())
val all = db.bookmarksDao.findAll() val all = db.getBookmarksDao().findAll()
for ((m, b) in all) { for ((m, b) in all) {
val json = JSONObject() val json = JSONObject()
val manga = JsonSerializer(m.manga).toJson() val manga = JsonSerializer(m.manga).toJson()
@@ -122,9 +122,9 @@ class BackupRepository @Inject constructor(
val history = JsonDeserializer(item).toHistoryEntity() val history = JsonDeserializer(item).toHistoryEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(manga, tags) db.getMangaDao().upsert(manga, tags)
db.historyDao.upsert(history) db.getHistoryDao().upsert(history)
} }
} }
} }
@@ -136,7 +136,7 @@ class BackupRepository @Inject constructor(
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity() val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.favouriteCategoriesDao.upsert(category) db.getFavouriteCategoriesDao().upsert(category)
} }
} }
return result return result
@@ -153,9 +153,9 @@ class BackupRepository @Inject constructor(
val favourite = JsonDeserializer(item).toFavouriteEntity() val favourite = JsonDeserializer(item).toFavouriteEntity()
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(manga, tags) db.getMangaDao().upsert(manga, tags)
db.favouritesDao.upsert(favourite) db.getFavouritesDao().upsert(favourite)
} }
} }
} }
@@ -175,9 +175,9 @@ class BackupRepository @Inject constructor(
} }
result += runCatchingCancellable { result += runCatchingCancellable {
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(manga, tags) db.getMangaDao().upsert(manga, tags)
db.bookmarksDao.upsert(bookmarks) db.getBookmarksDao().upsert(bookmarks)
} }
} }
} }

View File

@@ -29,7 +29,7 @@ class BackupZipOutput(val file: File) : Closeable {
} }
} }
private const val DIR_BACKUPS = "backups" const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run { val dir = context.run {

View File

@@ -66,29 +66,29 @@ const val DATABASE_VERSION = 17
) )
abstract class MangaDatabase : RoomDatabase() { 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( fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(

View File

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

View File

@@ -5,8 +5,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
class Migration11To12 : Migration(11, 12) { class Migration11To12 : Migration(11, 12) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL( db.execSQL(
""" """
CREATE TABLE IF NOT EXISTS `scrobblings` ( CREATE TABLE IF NOT EXISTS `scrobblings` (
`scrobbler` INTEGER NOT NULL, `scrobbler` INTEGER NOT NULL,
@@ -21,7 +21,7 @@ class Migration11To12 : Migration(11, 12) {
) )
""".trimIndent() """.trimIndent()
) )
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1") db.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 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) { class Migration12To13 : Migration(12, 13) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `show_in_lib` INTEGER NOT NULL DEFAULT 1") db.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") 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) { class Migration13To14 : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0") db.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") db.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") db.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") db.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") 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) { 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) { class Migration15To16 : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE preferences ADD COLUMN `cf_invert` INTEGER NOT NULL DEFAULT 0") 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) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE `sources` (`source` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `sort_key` INTEGER NOT NULL, PRIMARY KEY(`source`))") db.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`)") db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty() val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty() val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries val sources = MangaSource.entries
@@ -30,7 +30,7 @@ class Migration16To17(context: Context) : Migration(16, 17) {
continue continue
} }
} }
database.execSQL( db.execSQL(
"INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)", "INSERT INTO `sources` (`source`, `enabled`, `sort_key`) VALUES (?, ?, ?)",
arrayOf(name, (!isHidden).toInt(), sortKey), arrayOf(name, (!isHidden).toInt(), sortKey),
) )

View File

@@ -7,48 +7,48 @@ class Migration1To2 : Migration(1, 2) {
/** /**
* Adding foreign keys * Adding foreign keys
*/ */
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
/* manga_tags */ /* manga_tags */
database.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " + "CREATE TABLE IF NOT EXISTS manga_tags_tmp (manga_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, " +
"PRIMARY KEY(manga_id, tag_id), " + "PRIMARY KEY(manga_id, tag_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE, " + "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 )" "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)") db.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)") db.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") db.execSQL("INSERT INTO manga_tags_tmp (manga_id, tag_id) SELECT manga_id, tag_id FROM manga_tags")
database.execSQL("DROP TABLE manga_tags") db.execSQL("DROP TABLE manga_tags")
database.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags") db.execSQL("ALTER TABLE manga_tags_tmp RENAME TO manga_tags")
/* favourites */ /* 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, " + "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), " + "PRIMARY KEY(manga_id, category_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE , " + "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 )" "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)") db.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)") db.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") db.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") db.execSQL("DROP TABLE favourites")
database.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites") db.execSQL("ALTER TABLE favourites_tmp RENAME TO favourites")
/* history */ /* 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, " + "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), " + "PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "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") 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")
database.execSQL("DROP TABLE history") db.execSQL("DROP TABLE history")
database.execSQL("ALTER TABLE history_tmp RENAME TO history") db.execSQL("ALTER TABLE history_tmp RENAME TO history")
/* preferences */ /* preferences */
database.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," + "CREATE TABLE IF NOT EXISTS preferences_tmp (manga_id INTEGER NOT NULL, mode INTEGER NOT NULL," +
" PRIMARY KEY(manga_id), " + " PRIMARY KEY(manga_id), " +
"FOREIGN KEY(manga_id) REFERENCES manga(manga_id) ON UPDATE NO ACTION ON DELETE CASCADE )" "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") db.execSQL("INSERT INTO preferences_tmp (manga_id, mode) SELECT manga_id, mode FROM preferences")
database.execSQL("DROP TABLE preferences") db.execSQL("DROP TABLE preferences")
database.execSQL("ALTER TABLE preferences_tmp RENAME TO 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) { class Migration2To3 : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE history ADD COLUMN scroll REAL NOT NULL DEFAULT 0") 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) { class Migration3To4 : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: 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 )") 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) { class Migration4To5 : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN sort_key INTEGER NOT NULL DEFAULT 0") 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) { class Migration5To6 : Migration(5, 6) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: 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)") 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)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_track_logs_manga_id ON track_logs (manga_id)") 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) { class Migration6To7 : Migration(6, 7) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga ADD COLUMN public_url TEXT NOT NULL DEFAULT ''") 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) { class Migration7To8 : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE manga ADD COLUMN nsfw INTEGER NOT NULL DEFAULT 0") db.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 )") 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 )")
database.execSQL("CREATE INDEX IF NOT EXISTS index_suggestions_manga_id ON suggestions (manga_id)") 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) { class Migration8To9 : Migration(8, 9) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}") 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) { class Migration9To10 : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1") 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.Interceptor
import okhttp3.Response import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor { class GZipInterceptor : Interceptor {
@@ -9,6 +10,10 @@ class GZipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request().newBuilder() val newRequest = chain.request().newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip") 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) { suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
db.withTransaction { db.withTransaction {
storeManga(manga) storeManga(manga)
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id) val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert(entity.copy(mode = mode.id)) db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
} }
} }
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) { suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
db.withTransaction { db.withTransaction {
storeManga(manga) storeManga(manga)
val entity = db.preferencesDao.find(manga.id) ?: newEntity(manga.id) val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
db.preferencesDao.upsert( db.getPreferencesDao().upsert(
entity.copy( entity.copy(
cfBrightness = colorFilter?.brightness ?: 0f, cfBrightness = colorFilter?.brightness ?: 0f,
cfContrast = colorFilter?.contrast ?: 0f, cfContrast = colorFilter?.contrast ?: 0f,
@@ -48,25 +48,25 @@ class MangaDataRepository @Inject constructor(
} }
suspend fun getReaderMode(mangaId: Long): ReaderMode? { 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? { suspend fun getColorFilter(mangaId: Long): ReaderColorFilter? {
return db.preferencesDao.find(mangaId)?.getColorFilterOrNull() return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull()
} }
fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> { fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> {
return db.preferencesDao.observe(mangaId) return db.getPreferencesDao().observe(mangaId)
.map { it?.getColorFilterOrNull() } .map { it?.getColorFilterOrNull() }
.distinctUntilChanged() .distinctUntilChanged()
} }
suspend fun findMangaById(mangaId: Long): Manga? { suspend fun findMangaById(mangaId: Long): Manga? {
return db.mangaDao.find(mangaId)?.toManga() return db.getMangaDao().find(mangaId)?.toManga()
} }
suspend fun findMangaByPublicUrl(publicUrl: String): Manga? { 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 { suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
@@ -79,13 +79,13 @@ class MangaDataRepository @Inject constructor(
suspend fun storeManga(manga: Manga) { suspend fun storeManga(manga: Manga) {
val tags = manga.tags.toEntities() val tags = manga.tags.toEntities()
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.getTagsDao().upsert(tags)
db.mangaDao.upsert(manga.toEntity(), tags) db.getMangaDao().upsert(manga.toEntity(), tags)
} }
} }
suspend fun findTags(source: MangaSource): Set<MangaTag> { 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? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {

View File

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

View File

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

View File

@@ -354,6 +354,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val is32BitColorsEnabled: Boolean val is32BitColorsEnabled: Boolean
get() = prefs.getBoolean(KEY_32BIT_COLOR, false) get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
var periodicalBackupOutput: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
fun isTipEnabled(tip: String): Boolean { fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
} }
@@ -458,6 +468,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup" const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_READING_INDICATORS = "reading_indicators" const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"

View File

@@ -6,7 +6,6 @@ import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
@@ -96,10 +95,10 @@ abstract class BaseActivity<B : ViewBinding> :
insetsDelegate.onViewCreated(binding.root) insetsDelegate.onViewCreated(binding.root)
} }
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) { override fun onSupportNavigateUp(): Boolean {
onBackPressed() dispatchNavigateUp()
true return true
} else super.onOptionsItemSelected(item) }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
@@ -126,10 +125,10 @@ abstract class BaseActivity<B : ViewBinding> :
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors( ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color), 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 { } else {
ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer) ContextCompat.getColor(this, R.color.kotatsu_m3_background)
} }
val insets = ViewCompat.getRootWindowInsets(viewBinding.root) val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return ?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
@@ -150,6 +149,17 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = defaultStatusBarColor window.statusBarColor = defaultStatusBarColor
} }
protected open fun dispatchNavigateUp() {
val upIntent = parentActivityIntent
if (upIntent != null) {
if (!navigateUpTo(upIntent)) {
startActivity(upIntent)
}
} else {
finishAfterTransition()
}
}
private fun putDataToExtras(intent: Intent?) { private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data) intent?.putExtra(EXTRA_DATA, intent.data)
} }

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
class RecyclerScrollKeeper(
private val rv: RecyclerView,
) : AdapterDataObserver() {
private val scrollUpRunnable = Runnable {
(rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(0, 0)
}
fun attach() {
rv.adapter?.registerAdapterDataObserver(this)
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
if (positionStart == 0 && isScrolledToTop()) {
postScrollUp()
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
super.onItemRangeMoved(fromPosition, toPosition, itemCount)
if (toPosition == 0 && isScrolledToTop()) {
postScrollUp()
}
}
private fun postScrollUp() {
rv.postDelayed(scrollUpRunnable, 500L)
}
private fun isScrolledToTop(): Boolean {
return (rv.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() == 0
}
}

View File

@@ -26,7 +26,7 @@ class CompositeMutex<T : Any> : Set<T> {
} }
override fun isEmpty(): Boolean { override fun isEmpty(): Boolean {
return state.isEmpty return state.isEmpty()
} }
override fun iterator(): Iterator<T> { override fun iterator(): Iterator<T> {

View File

@@ -19,7 +19,7 @@ class CompositeMutex2<T : Any> : Set<T> {
} }
override fun isEmpty(): Boolean { override fun isEmpty(): Boolean {
return delegates.isEmpty return delegates.isEmpty()
} }
override fun iterator(): Iterator<T> { override fun iterator(): Iterator<T> {

View File

@@ -83,7 +83,7 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
e.printStackTraceDebug() e.printStackTraceDebug()
}.isSuccess }.isSuccess
fun SharedPreferences.observe() = callbackFlow<String?> { fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key) trySendBlocking(key)
} }

View File

@@ -23,7 +23,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
return null return null
} }
} }
disposeImageRequest() // disposeImageRequest()
return ImageRequest.Builder(context) return ImageRequest.Builder(context)
.data(data) .data(data)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)

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>> { inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<List<R>> {
return map { list -> list.map(transform) } return map { list -> list.map(transform) }
} }

View File

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

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.findChapter import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
@@ -13,15 +14,19 @@ class ProgressUpdateUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val database: MangaDatabase, private val database: MangaDatabase,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val networkState: NetworkState,
) { ) {
suspend operator fun invoke(manga: Manga): Float { 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) { val seed = if (manga.isLocal) {
localMangaRepository.getRemoteManga(manga) ?: manga localMangaRepository.getRemoteManga(manga) ?: manga
} else { } else {
manga manga
} }
if (!seed.isLocal && !networkState.value) {
return PROGRESS_NONE
}
val repo = mangaRepositoryFactory.create(seed.source) val repo = mangaRepositoryFactory.create(seed.source)
val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) { val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) {
repo.getDetails(seed) repo.getDetails(seed)
@@ -43,7 +48,7 @@ class ProgressUpdateUseCase @Inject constructor(
val ppc = 1f / chaptersCount val ppc = 1f / chaptersCount
val result = ppc * chapterIndex + ppc * pagePercent val result = ppc * chapterIndex + ppc * pagePercent
if (result != history.percent) { if (result != history.percent) {
database.historyDao.update( database.getHistoryDao().update(
history.copy( history.copy(
chapterId = chapter.id, chapterId = chapter.id,
percent = result, percent = result,

View File

@@ -137,7 +137,9 @@ class DetailsActivity :
this, this,
MenuInvalidator(viewBinding.toolbarChapters ?: 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) { viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1 viewBinding.buttonDropdown.isVisible = it.size > 1
} }

View File

@@ -81,7 +81,7 @@ class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(), BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener, View.OnClickListener,
ChipsView.OnChipClickListener, ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener { OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener, View.OnLayoutChangeListener {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@@ -105,6 +105,7 @@ class DetailsFragment :
binding.buttonScrobblingMore.setOnClickListener(this) binding.buttonScrobblingMore.setOnClickListener(this)
binding.buttonRelatedMore.setOnClickListener(this) binding.buttonRelatedMore.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this) binding.infoLayout.textViewSource.setOnClickListener(this)
binding.textViewDescription.addOnLayoutChangeListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this) binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance() binding.textViewDescription.movementMethod = LinkMovementMethod.getInstance()
binding.chipsTags.onChipClickListener = this 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) { private fun onMangaUpdated(manga: Manga) {
with(requireViewBinding()) { with(requireViewBinding()) {
// Main // Main
@@ -228,7 +245,6 @@ class DetailsFragment :
} else { } else {
tv.text = description tv.text = description
} }
requireViewBinding().buttonDescriptionMore.isVisible = tv.isTextTruncated
} }
private fun onLocalSizeChanged(size: Long) { 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_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable 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( menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline, 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 -> { R.id.action_related -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title)) 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.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.computeSize 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.core.util.ext.requireValue
import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.BranchComparator import org.koitharu.kotatsu.details.domain.BranchComparator
@@ -94,6 +94,8 @@ class DetailsViewModel @Inject constructor(
val favouriteCategories = interactor.observeIsFavourite(mangaId) val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val remoteManga = MutableStateFlow<Manga?>(null)
val newChaptersCount = details.flatMapLatest { d -> val newChaptersCount = details.flatMapLatest { d ->
if (d?.isLocal == false) { if (d?.isLocal == false) {
interactor.observeNewChapters(mangaId) interactor.observeNewChapters(mangaId)
@@ -147,15 +149,13 @@ class DetailsViewModel @Inject constructor(
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = manga val relatedManga: StateFlow<List<MangaItemModel>> = manga.mapLatest {
.mapLatest { if (it != null && settings.isRelatedMangaEnabled) {
if (it != null && settings.isRelatedMangaEnabled) { relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty() } else {
} else { emptyList()
emptyList()
}
} }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine( val branches: StateFlow<List<MangaBranch>> = combine(
details, details,
@@ -213,6 +213,10 @@ class DetailsViewModel @Inject constructor(
progressUpdateUseCase(manga.toManga()) progressUpdateUseCase(manga.toManga())
} }
} }
launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob
remoteManga.value = interactor.findRemote(manga.toManga())
}
} }
fun reload() { fun reload() {
@@ -313,11 +317,15 @@ class DetailsViewModel @Inject constructor(
private fun doLoad() = launchLoadingJob(Dispatchers.Default) { private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(intent) detailsLoadUseCase.invoke(intent)
.onFirst { .onEachWhile {
if (it.allChapters.isEmpty()) {
return@onEachWhile false
}
val manga = it.toManga() val manga = it.toManga()
// find default branch // find default branch
val hist = historyRepository.getOne(manga) val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist) selectedBranch.value = manga.getPreferredBranch(hist)
true
}.collect { }.collect {
details.value = it details.value = it
} }

View File

@@ -18,7 +18,7 @@ data class DownloadState(
val currentPage: Int = 0, val currentPage: Int = 0,
val eta: Long = -1L, val eta: Long = -1L,
val localManga: LocalManga? = null, val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0), val downloadedChapters: Int = 0,
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
) { ) {
@@ -41,61 +41,17 @@ data class DownloadState(
.putLong(DATA_ETA, eta) .putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp) .putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error) .putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters) .putInt(DATA_CHAPTERS, downloadedChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate) .putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused) .putBoolean(DATA_PAUSED, isPaused)
.build() .build()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadState
if (manga != other.manga) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (isStopped != other.isStopped) return false
if (error != other.error) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
return percent == other.percent
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + isStopped.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
result = 31 * result + percent.hashCode()
return result
}
companion object { companion object {
private const val DATA_MANGA_ID = "manga_id" private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max" private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter" private const val DATA_CHAPTERS = "chapter_cnt"
private const val DATA_ETA = "eta" private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp" private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error" private const val DATA_ERROR = "error"
@@ -118,6 +74,6 @@ data class DownloadState(
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L)) fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0) fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
} }
} }

View File

@@ -1,18 +1,23 @@
package org.koitharu.kotatsu.download.ui.list package org.koitharu.kotatsu.download.ui.list
import android.transition.TransitionManager
import android.view.View import android.view.View
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.ImageLoader import coil.ImageLoader
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.enqueueWith 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.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
@@ -25,6 +30,7 @@ fun downloadItemAD(
) { ) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern) val percentPattern = context.resources.getString(R.string.percent_string_pattern)
// val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
val clickListener = object : View.OnClickListener, View.OnLongClickListener { val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) { override fun onClick(v: View) {
@@ -47,16 +53,24 @@ fun downloadItemAD(
itemView.setOnLongClickListener(clickListener) itemView.setOnLongClickListener(clickListener)
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.manga.title if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply { TransitionManager.beginDelayedTransition(binding.constraintLayout)
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
transformations(TrimTransformation())
source(item.manga.source)
enqueueWith(coil)
} }
binding.textViewTitle.text = item.manga.title
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
transformations(TrimTransformation())
memoryCacheKey(item.coverCacheKey)
source(item.manga.source)
enqueueWith(coil)
}
}
// binding.textViewTitle.isChecked = item.isExpanded
// binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
when (item.workState) { when (item.workState) {
WorkInfo.State.ENQUEUED, WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> { WorkInfo.State.BLOCKED -> {
@@ -94,11 +108,11 @@ fun downloadItemAD(
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
if (item.totalChapters > 0) { if (item.chaptersDownloaded > 0) {
binding.textViewDetails.text = context.resources.getQuantityString( binding.textViewDetails.text = context.resources.getQuantityString(
R.plurals.chapters, R.plurals.chapters,
item.totalChapters, item.chaptersDownloaded,
item.totalChapters, item.chaptersDownloaded,
) )
binding.textViewDetails.isVisible = true binding.textViewDetails.isVisible = true
} else { } else {

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.memory.MemoryCache
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date import java.util.Date
@@ -15,12 +17,15 @@ data class DownloadItemModel(
val manga: Manga, val manga: Manga,
val error: String?, val error: String?,
val max: Int, val max: Int,
val totalChapters: Int,
val progress: Int, val progress: Int,
val eta: Long, val eta: Long,
val timestamp: Date, val timestamp: Date,
val chaptersDownloaded: Int,
val isExpanded: Boolean,
) : ListModel, Comparable<DownloadItemModel> { ) : ListModel, Comparable<DownloadItemModel> {
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1"))
val percent: Float val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f get() = if (max > 0) progress / max.toFloat() else 0f
@@ -33,6 +38,9 @@ data class DownloadItemModel(
val canResume: Boolean val canResume: Boolean
get() = workState == WorkInfo.State.RUNNING && isPaused get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean
get() = false // TODO
fun getEtaString(): CharSequence? = if (hasEta) { fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString( DateUtils.getRelativeTimeSpanString(
eta, eta,
@@ -51,17 +59,10 @@ data class DownloadItemModel(
return other is DownloadItemModel && other.id == id return other is DownloadItemModel && other.id == id
} }
override fun getChangePayload(previousState: ListModel): Any? { override fun getChangePayload(previousState: ListModel): Any? = when {
return when (previousState) { previousState !is DownloadItemModel -> super.getChangePayload(previousState)
is DownloadItemModel -> { workState != previousState.workState -> null
if (workState == previousState.workState) { isExpanded != previousState.isExpanded -> ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
Unit else -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
} else {
null
}
}
else -> super.getChangePayload(previousState)
}
} }
} }

View File

@@ -15,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -53,6 +54,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
addItemDecoration(decoration) addItemDecoration(decoration)
adapter = downloadsAdapter adapter = downloadsAdapter
selectionController.attachToRecyclerView(this) selectionController.attachToRecyclerView(this)
RecyclerScrollKeeper(this).attach()
} }
addMenuProvider(DownloadsMenuProvider(this, viewModel)) addMenuProvider(DownloadsMenuProvider(this, viewModel))
viewModel.items.observe(this) { viewModel.items.observe(this) {
@@ -82,7 +84,11 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) { if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return 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 { override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {

View File

@@ -8,15 +8,19 @@ import androidx.work.Data
import androidx.work.WorkInfo import androidx.work.WorkInfo
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository 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.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
@@ -31,6 +35,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date import java.util.Date
import java.util.LinkedList import java.util.LinkedList
import java.util.UUID import java.util.UUID
@@ -41,13 +46,18 @@ import javax.inject.Inject
class DownloadsViewModel @Inject constructor( class DownloadsViewModel @Inject constructor(
private val workScheduler: DownloadWorker.Scheduler, private val workScheduler: DownloadWorker.Scheduler,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() { ) : BaseViewModel() {
private val mangaCache = LongSparseArray<Manga>() private val mangaCache = LongSparseArray<Manga>()
private val cacheMutex = Mutex() private val cacheMutex = Mutex()
private val works = workScheduler.observeWorks() private val expanded = MutableStateFlow(emptySet<UUID>())
.mapLatest { it.toDownloadsList() } private val works = combine(
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) workScheduler.observeWorks(),
expanded,
) { list, exp ->
list.toDownloadsList(exp)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -169,11 +179,21 @@ class DownloadsViewModel @Inject constructor(
it.id.mostSignificantBits it.id.mostSignificantBits
} ?: emptySet() } ?: 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()) { if (isEmpty()) {
return emptyList() return emptyList()
} }
val list = mapNotNullTo(ArrayList(size)) { it.toUiModel() } val list = mapNotNullTo(ArrayList(size)) { it.toUiModel(it.id in exp) }
list.sortByDescending { it.timestamp } list.sortByDescending { it.timestamp }
return list return list
} }
@@ -213,7 +233,7 @@ class DownloadsViewModel @Inject constructor(
return destination 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 workData = if (outputData == Data.EMPTY) progress else outputData
val mangaId = DownloadState.getMangaId(workData) val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null if (mangaId == 0L) return null
@@ -229,7 +249,8 @@ class DownloadsViewModel @Inject constructor(
progress = DownloadState.getProgress(workData), progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData), eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData), timestamp = DownloadState.getTimestamp(workData),
totalChapters = DownloadState.getDownloadedChapters(workData).size, chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded,
) )
} }
@@ -261,8 +282,16 @@ class DownloadsViewModel @Inject constructor(
} }
return cacheMutex.withLock { return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) { 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

@@ -38,6 +38,7 @@ import okio.buffer
import okio.sink import okio.sink
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -46,7 +47,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.Throttler import org.koitharu.kotatsu.core.util.Throttler
import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfoById
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork import org.koitharu.kotatsu.core.util.ext.deleteWork
@@ -105,11 +105,12 @@ class DownloadWorker @AssistedInject constructor(
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
val mangaId = inputData.getLong(MANGA_ID, 0L) val mangaId = inputData.getLong(MANGA_ID, 0L)
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters()
lastPublishedState = DownloadState(manga, isIndeterminate = true) lastPublishedState = DownloadState(manga, isIndeterminate = true)
publishState(DownloadState(manga, isIndeterminate = true))
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga)
return try { return try {
downloadMangaImpl(chaptersIds, downloadedIds) downloadMangaImpl(manga, chaptersIds, downloadedIds)
Result.success(currentState.toWorkData()) Result.success(currentState.toWorkData())
} catch (e: CancellationException) { } catch (e: CancellationException) {
withContext(NonCancellable) { withContext(NonCancellable) {
@@ -147,10 +148,11 @@ class DownloadWorker @AssistedInject constructor(
} }
private suspend fun downloadMangaImpl( private suspend fun downloadMangaImpl(
subject: Manga,
includedIds: LongArray?, includedIds: LongArray?,
excludedIds: LongArray, excludedIds: Set<Long>,
) { ) {
var manga = currentState.manga var manga = subject
val chaptersToSkip = excludedIds.toMutableSet() val chaptersToSkip = excludedIds.toMutableSet()
withMangaLock(manga) { withMangaLock(manga) {
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
@@ -180,11 +182,7 @@ class DownloadWorker @AssistedInject constructor(
val chapters = getChapters(mangaDetails, includedIds) val chapters = getChapters(mangaDetails, includedIds)
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) { if (chaptersToSkip.remove(chapter.id)) {
publishState( publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
continue continue
} }
val pages = runFailsafe(pausingHandle) { val pages = runFailsafe(pausingHandle) {
@@ -222,11 +220,7 @@ class DownloadWorker @AssistedInject constructor(
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga()) localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug) }.onFailure(Throwable::printStackTraceDebug)
} }
publishState( publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
} }
publishState(currentState.copy(isIndeterminate = true, eta = -1L)) publishState(currentState.copy(isIndeterminate = true, eta = -1L))
output.mergeWithExisting() output.mergeWithExisting()
@@ -333,11 +327,9 @@ class DownloadWorker @AssistedInject constructor(
setProgress(state.toWorkData()) setProgress(state.toWorkData())
} }
private suspend fun getDoneChapters(): LongArray { private suspend fun getDoneChapters(manga: Manga) = runCatchingCancellable {
val work = WorkManager.getInstance(applicationContext).awaitWorkInfoById(id) localMangaRepository.getDetails(manga).chapters?.ids()
?: return LongArray(0) }.getOrNull().orEmpty()
return DownloadState.getDownloadedChapters(work.progress)
}
private fun getChapters( private fun getChapters(
manga: Manga, manga: Manga,

View File

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

View File

@@ -169,6 +169,7 @@ abstract class FavouritesDao {
ListSortOrder.NEWEST -> "favourites.created_at DESC" ListSortOrder.NEWEST -> "favourites.created_at DESC"
ListSortOrder.ALPHABETIC -> "manga.title ASC" ListSortOrder.ALPHABETIC -> "manga.title ASC"
ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC" ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC"
ListSortOrder.UPDATED, // for legacy support
ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC" ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
} }

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
@@ -23,6 +24,7 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
RecyclerScrollKeeper(binding.recyclerView).attach()
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel)) addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -183,7 +183,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> if (isSearchOpened()) { android.R.id.home -> if (isSearchOpened()) {
super.onOptionsItemSelected(item) closeSearchCallback.handleOnBackPressed()
true
} else { } else {
viewBinding.searchView.requestFocus() viewBinding.searchView.requestFocus()
true true

View File

@@ -52,6 +52,7 @@ import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.zipWithPrevious import org.koitharu.kotatsu.core.util.ext.zipWithPrevious
import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
@@ -147,6 +148,11 @@ class ReaderActivity :
} }
} }
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.manga?.toManga() ?: return null
return DetailsActivity.newIntent(this, manga)
}
override fun onUserInteraction() { override fun onUserInteraction() {
super.onUserInteraction() super.onUserInteraction()
scrollTimer.onUserInteraction() scrollTimer.onUserInteraction()
@@ -249,6 +255,7 @@ class ReaderActivity :
override fun dispatchTouchEvent(ev: MotionEvent): Boolean { override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
touchHelper.dispatchTouchEvent(ev) touchHelper.dispatchTouchEvent(ev)
scrollTimer.onTouchEvent(ev)
return super.dispatchTouchEvent(ev) return super.dispatchTouchEvent(ev)
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.view.MotionEvent
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
@@ -8,11 +9,14 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import kotlin.math.roundToLong import kotlin.math.roundToLong
@@ -33,6 +37,7 @@ class ScrollTimer @AssistedInject constructor(
private var delayMs: Long = 10L private var delayMs: Long = 10L
private var pageSwitchDelay: Long = 100L private var pageSwitchDelay: Long = 100L
private var resumeAt = 0L private var resumeAt = 0L
private var isTouchDown = MutableStateFlow(false)
var isEnabled: Boolean = false var isEnabled: Boolean = false
set(value) { set(value) {
@@ -55,6 +60,19 @@ class ScrollTimer @AssistedInject constructor(
resumeAt = System.currentTimeMillis() + INTERACTION_SKIP_MS resumeAt = System.currentTimeMillis() + INTERACTION_SKIP_MS
} }
fun onTouchEvent(event: MotionEvent) {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
isTouchDown.value = true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
isTouchDown.value = false
}
}
}
private fun onSpeedChanged(speed: Float) { private fun onSpeedChanged(speed: Float) {
if (speed <= 0f) { if (speed <= 0f) {
delayMs = 0L delayMs = 0L
@@ -108,12 +126,18 @@ class ScrollTimer @AssistedInject constructor(
} }
private fun isPaused(): Boolean { private fun isPaused(): Boolean {
return resumeAt > System.currentTimeMillis() return isTouchDown.value || resumeAt > System.currentTimeMillis()
} }
private suspend fun delayUntilResumed() { private suspend fun delayUntilResumed() {
while (isPaused()) { while (isPaused()) {
delay(resumeAt - System.currentTimeMillis()) val delayTime = resumeAt - System.currentTimeMillis()
if (delayTime > 0) {
delay(delayTime)
} else {
yield()
}
isTouchDown.first { !it }
} }
} }

View File

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

View File

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

View File

@@ -29,7 +29,7 @@ class AniListScrobbler @Inject constructor(
status: ScrobblingStatus?, status: ScrobblingStatus?,
comment: String?, 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" } requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate( repository.updateRate(
rateId = entity.id, rateId = entity.id,

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ class MALScrobbler @Inject constructor(
status: ScrobblingStatus?, status: ScrobblingStatus?,
comment: String?, 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" } requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate( repository.updateRate(
rateId = entity.id, rateId = entity.id,

View File

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

View File

@@ -31,7 +31,7 @@ class ShikimoriScrobbler @Inject constructor(
status: ScrobblingStatus?, status: ScrobblingStatus?,
comment: String?, 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" } requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate( repository.updateRate(
rateId = entity.id, rateId = entity.id,

View File

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

View File

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

View File

@@ -139,6 +139,7 @@ class AppearanceSettingsFragment :
private val deviceLocales = LocaleManagerCompat.getSystemLocales(context) private val deviceLocales = LocaleManagerCompat.getSystemLocales(context)
.map { it.language } .map { it.language }
.distinct()
override fun compare(a: Locale, b: Locale): Int { override fun compare(a: Locale, b: Locale): Int {
return if (a === b) { return if (a === b) {

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.databinding.DialogProgressBinding
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -28,7 +29,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private var backup: File? = null private var backup: File? = null
private val saveFileContract = registerForActivityResult( private val saveFileContract = registerForActivityResult(
ActivityResultContracts.CreateDocument("*/*"), ActivityResultContracts.CreateDocument("application/zip"),
) { uri -> ) { uri ->
val file = backup val file = backup
if (uri != null && file != null) { if (uri != null && file != null) {
@@ -81,7 +82,10 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
private fun onBackupDone(file: File) { private fun onBackupDone(file: File) {
this.backup = file this.backup = file
saveFileContract.launch(file.name) if (!saveFileContract.tryLaunch(file.name)) {
Toast.makeText(requireContext(), R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
dismiss()
}
} }
private fun saveBackup(file: File, output: Uri) { private fun saveBackup(file: File, output: Uri) {
@@ -91,7 +95,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
it.write(file.readBytes()) it.write(file.readBytes())
} }
} }
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show()
dismiss() dismiss()
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
throw e throw e

View File

@@ -43,7 +43,6 @@ class BackupViewModel @Inject constructor(
backup.finish() backup.finish()
progress.value = 1f progress.value = 1f
backup.close()
backup.file backup.file
} }
onBackupDone.call(file) onBackupDone.call(file)

View File

@@ -0,0 +1,98 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.backup.DIR_BACKUPS
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import java.io.File
import java.text.SimpleDateFormat
import javax.inject.Inject
@AndroidEntryPoint
class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups),
ActivityResultCallback<Uri?> {
@Inject
lateinit var scheduler: PeriodicalBackupWorker.Scheduler
private val outputSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(),
this,
)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindOutputSummary()
bindLastBackupInfo()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null)
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
settings.periodicalBackupOutput = result
bindOutputSummary()
}
}
private fun bindOutputSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
viewLifecycleScope.launch {
preference.summary = withContext(Dispatchers.Default) {
val value = settings.periodicalBackupOutput
value?.toUserFriendlyString(preference.context) ?: preference.context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}.path
}
}
}
private fun bindLastBackupInfo() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
viewLifecycleScope.launch {
val lastDate = withContext(Dispatchers.Default) {
scheduler.getLastSuccessfulBackup()
}
preference.summary = lastDate?.let {
val format = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.MEDIUM, SimpleDateFormat.SHORT)
preference.context.getString(R.string.last_successful_backup, format.format(it))
}
preference.isVisible = lastDate != null
}
}
private fun Uri.toUserFriendlyString(context: Context): String {
val df = DocumentFile.fromTreeUri(context, this)
if (df?.canWrite() != true) {
return context.getString(R.string.invalid_value_message)
}
return resolveFile(context)?.path ?: toString()
}
}

View File

@@ -0,0 +1,111 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.os.Build
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import androidx.work.workDataOf
import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
import java.util.Date
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltWorker
class PeriodicalBackupWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val repository: BackupRepository,
private val settings: AppSettings,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val resultData = workDataOf(DATA_TIMESTAMP to Date().time)
val file = BackupZipOutput(applicationContext).use { backup ->
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites())
backup.put(repository.dumpBookmarks())
backup.put(repository.dumpSettings())
backup.finish()
backup.file
}
val dirUri = settings.periodicalBackupOutput ?: return Result.success(resultData)
val target = DocumentFile.fromTreeUri(applicationContext, dirUri)
?.createFile("application/zip", file.nameWithoutExtension)
?.uri ?: return Result.failure()
applicationContext.contentResolver.openOutputStream(target, "wt")?.use { output ->
file.inputStream().copyTo(output)
} ?: return Result.failure()
file.deleteAwait()
return Result.success(resultData)
}
@Reusable
class Scheduler @Inject constructor(
private val workManager: WorkManager,
private val settings: AppSettings,
) : PeriodicWorkScheduler {
override suspend fun schedule() {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
constraints.setRequiresDeviceIdle(true)
}
val request = PeriodicWorkRequestBuilder<PeriodicalBackupWorker>(
settings.periodicalBackupFrequency,
TimeUnit.DAYS,
).setConstraints(constraints.build())
.keepResultsForAtLeast(20, TimeUnit.DAYS)
.addTag(TAG)
.build()
workManager
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
.await()
}
override suspend fun unschedule() {
workManager
.cancelUniqueWork(TAG)
.await()
}
override suspend fun isScheduled(): Boolean {
return workManager
.awaitUniqueWorkInfoByName(TAG)
.any { !it.state.isFinished }
}
suspend fun getLastSuccessfulBackup(): Date? {
return workManager
.awaitUniqueWorkInfoByName(TAG)
.lastOrNull { x -> x.state == WorkInfo.State.SUCCEEDED }
?.outputData
?.getLong(DATA_TIMESTAMP, 0)
?.let { if (it != 0L) Date(it) else null }
}
}
private companion object {
const val TAG = "backups"
const val DATA_TIMESTAMP = "ts"
}
}

View File

@@ -65,6 +65,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
bindPeriodicalBackupSummary()
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewModel.searchHistoryCount.observe(viewLifecycleOwner) { viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) { pref.summary = if (it < 0) {
@@ -200,6 +201,20 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
} }
} }
private fun bindPeriodicalBackupSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
val entries = resources.getStringArray(R.array.backup_frequency)
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
preference.summary = if (freq == 0L) {
getString(R.string.disabled)
} else {
val index = entryValues.indexOf(freq.toString())
entries.getOrNull(index)
}
}
}
private fun clearSearchHistory() { private fun clearSearchHistory() {
MaterialAlertDialogBuilder(context ?: return) MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_search_history) .setTitle(R.string.clear_search_history)

View File

@@ -5,12 +5,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
@@ -29,6 +32,7 @@ class UserDataSettingsViewModel @Inject constructor(
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar, private val cookieJar: MutableCookieJar,
private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -40,6 +44,20 @@ class UserDataSettingsViewModel @Inject constructor(
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java) val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val storageUsage = MutableStateFlow<StorageUsage?>(null) val storageUsage = MutableStateFlow<StorageUsage?>(null)
val periodicalBackupFrequency = settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
valueProducer = { isPeriodicalBackupEnabled },
).flatMapLatest { isEnabled ->
if (isEnabled) {
settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
valueProducer = { periodicalBackupFrequency },
)
} else {
flowOf(0)
}
}
private var storageUsageJob: Job? = null private var storageUsageJob: Job? = null
init { init {

View File

@@ -11,7 +11,7 @@ class TagsAutoCompleteProvider @Inject constructor(
if (query.isEmpty()) { if (query.isEmpty()) {
return emptyList() 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 set = HashSet<String>()
val result = ArrayList<String>(tags.size) val result = ArrayList<String>(tags.size)
for (tag in tags) { for (tag in tags) {

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupWorker
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import javax.inject.Inject import javax.inject.Inject
@@ -13,6 +14,7 @@ class WorkScheduleManager @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val suggestionScheduler: SuggestionsWorker.Scheduler, private val suggestionScheduler: SuggestionsWorker.Scheduler,
private val trackerScheduler: TrackWorker.Scheduler, private val trackerScheduler: TrackWorker.Scheduler,
private val periodicalBackupScheduler: PeriodicalBackupWorker.Scheduler,
) : SharedPreferences.OnSharedPreferenceChangeListener { ) : SharedPreferences.OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
@@ -30,6 +32,13 @@ class WorkScheduleManager @Inject constructor(
isEnabled = settings.isSuggestionsEnabled, isEnabled = settings.isSuggestionsEnabled,
force = key != AppSettings.KEY_SUGGESTIONS, force = key != AppSettings.KEY_SUGGESTIONS,
) )
AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY -> updateWorker(
scheduler = periodicalBackupScheduler,
isEnabled = settings.isPeriodicalBackupEnabled,
force = key != AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
)
} }
} }
@@ -38,6 +47,7 @@ class WorkScheduleManager @Inject constructor(
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, false) updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, false)
updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false)
updateWorkerImpl(periodicalBackupScheduler, settings.isPeriodicalBackupEnabled, false)
} }
} }

View File

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

View File

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

View File

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

View File

@@ -130,9 +130,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> { private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityHistory, TABLE_HISTORY) val uri = uri(authorityHistory, TABLE_HISTORY)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("updated_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo -> json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory)) operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory))
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)
@@ -145,9 +142,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> { private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES) val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo -> json.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues()) .withValues(jo.toContentValues())
@@ -159,9 +153,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertFavourites(json: JSONArray, timestamp: Long): Array<ContentProviderResult> { private fun upsertFavourites(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITES) val uri = uri(authorityFavourites, TABLE_FAVOURITES)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo -> json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites)) operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites))
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)

View File

@@ -20,11 +20,11 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
class TrackEntity( class TrackEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long, @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 = "chapters_total") val totalChapters: Int,
@ColumnInfo(name = "last_chapter_id") val lastChapterId: Long, @ColumnInfo(name = "last_chapter_id") val lastChapterId: Long,
@ColumnInfo(name = "chapters_new") val newChapters: Int, @ColumnInfo(name = "chapters_new") val newChapters: Int,
@ColumnInfo(name = "last_check") val lastCheck: Long, @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 @ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
) )

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <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> </selector>

View File

@@ -7,6 +7,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipToPadding="false" android:clipToPadding="false"
android:scrollIndicators="top"
android:scrollbars="vertical" android:scrollbars="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior"> 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"> android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/constraintLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingBottom="12dp"> android:paddingBottom="12dp">
@@ -24,7 +25,7 @@
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Medium" app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Medium"
tools:src="@tools:sample/backgrounds/scenic" /> tools:src="@tools:sample/backgrounds/scenic" />
<TextView <CheckedTextView
android:id="@+id/textView_title" android:id="@+id/textView_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -32,11 +33,14 @@
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:ellipsize="end" android:ellipsize="end"
android:gravity="center_vertical"
android:singleLine="true" android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall" android:textAppearance="?attr/textAppearanceTitleSmall"
app:drawableTint="?android:colorControlNormal"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover" app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:drawableEndCompat="@drawable/ic_expand_collapse"
tools:text="@tools:sample/lorem" /> tools:text="@tools:sample/lorem" />
<androidx.constraintlayout.widget.Barrier <androidx.constraintlayout.widget.Barrier

View File

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

Some files were not shown because too many files have changed in this diff Show More