Compare commits

...

42 Commits

Author SHA1 Message Date
Koitharu
be19c32fea Update prasers 2022-07-27 17:36:39 +03:00
Koitharu
8da0e98d23 Fix FragmentManager leak 2022-07-27 17:36:39 +03:00
Koitharu
73a2f05509 Fix FadingSnackbar text color 2022-07-27 17:36:39 +03:00
Koitharu
bb23f998e0 Fix crash on description selection 2022-07-27 17:36:35 +03:00
TheDawnOvO
75915ff366 Update activity_main.xml 2022-07-27 15:24:39 +03:00
Dpper
517e801580 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (323 of 323 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2022-07-27 08:47:39 +03:00
J. Lavoie
12474e23f9 Translated using Weblate (Finnish)
Currently translated at 96.2% (311 of 323 strings)

Translated using Weblate (French)

Currently translated at 100.0% (323 of 323 strings)

Translated using Weblate (Italian)

Currently translated at 99.0% (320 of 323 strings)

Translated using Weblate (German)

Currently translated at 97.8% (316 of 323 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-07-27 08:47:39 +03:00
kuragehime
00bdd859a7 Translated using Weblate (Japanese)
Currently translated at 100.0% (323 of 323 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-07-27 08:47:39 +03:00
Oğuz Ersen
3a3af9ea00 Translated using Weblate (Turkish)
Currently translated at 100.0% (323 of 323 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-07-27 08:47:39 +03:00
Koitharu
1803b1a2ee Remove unused WebViewClientCompat 2022-07-25 11:25:43 +03:00
Koitharu
4175c84363 Move create category button in bs to toolbar 2022-07-22 19:04:43 +03:00
Koitharu
1840d7b50e Fix get current page #165 2022-07-20 20:32:01 +03:00
Zakhar Timoshenko
37b69833b3 Update parsers 2022-07-20 19:52:56 +03:00
Dpper
093f766d1d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (321 of 321 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2022-07-20 18:34:50 +03:00
Sergio Varela
69d8459b1c Translated using Weblate (Spanish)
Currently translated at 100.0% (321 of 321 strings)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-07-20 18:34:50 +03:00
kuragehime
fa8a526642 Translated using Weblate (Japanese)
Currently translated at 100.0% (321 of 321 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-07-20 18:34:50 +03:00
Oğuz Ersen
1d35d951e6 Translated using Weblate (Turkish)
Currently translated at 100.0% (322 of 322 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (321 of 321 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-07-20 18:34:50 +03:00
J. Lavoie
3c0420f42f Translated using Weblate (Finnish)
Currently translated at 96.2% (310 of 322 strings)

Translated using Weblate (French)

Currently translated at 100.0% (322 of 322 strings)

Translated using Weblate (Italian)

Currently translated at 99.0% (319 of 322 strings)

Translated using Weblate (German)

Currently translated at 97.8% (315 of 322 strings)

Translated using Weblate (French)

Currently translated at 100.0% (321 of 321 strings)

Translated using Weblate (Italian)

Currently translated at 99.3% (319 of 321 strings)

Translated using Weblate (German)

Currently translated at 97.8% (314 of 321 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-07-20 18:34:50 +03:00
Koitharu
d000a825d3 Update parsers 2022-07-20 18:32:38 +03:00
Koitharu
23b28672d4 Fix sharing files from sdcard 2022-07-20 18:03:46 +03:00
Koitharu
a076c9f420 Make NotFoundException resolvable 2022-07-20 17:32:22 +03:00
Koitharu
bdc7a8f5ed Fallback to system dnf if DoH failed 2022-07-20 15:13:45 +03:00
Koitharu
bdcc3bb1f5 Update parsers and adjust ParseException reporting 2022-07-19 12:07:21 +03:00
Koitharu
18d45aa1a3 Chapters range selection 2022-07-19 10:29:39 +03:00
Koitharu
b5bb8efe0a Improve database tests 2022-07-19 10:01:23 +03:00
Koitharu
f18c18230b Update favourites categories empty state image 2022-07-18 18:29:29 +03:00
Koitharu
2fd1e998f4 Fix unmark chapter as new 2022-07-18 14:30:07 +03:00
Koitharu
c5a1980e0d Fix tests for api21 2022-07-18 14:12:24 +03:00
Koitharu
d470ca4b47 Test for update checking 2022-07-18 13:09:11 +03:00
Koitharu
35f450e444 Fix android tests 2022-07-18 12:57:41 +03:00
Koitharu
206fb4e584 Update test data 2022-07-18 12:42:15 +03:00
Koitharu
62088b36a4 Update app shortcuts using InvalidationTracker.Callback 2022-07-18 12:16:39 +03:00
Koitharu
aa5fd530d3 Fix database test 2022-07-18 11:34:00 +03:00
Koitharu
f0ee64bafa Unit test for BackupAgent 2022-07-18 11:27:56 +03:00
Koitharu
dfa413da6f Observe database updates using InvalidationTracker 2022-07-18 09:55:49 +03:00
Koitharu
9eb5e699e1 Unit tests for json (de)serialization 2022-07-18 09:19:29 +03:00
Koitharu
2d4c1b751e Update dependencies 2022-07-14 14:48:26 +03:00
Koitharu
91b17ef4a2 Fix global search parallelism 2022-07-14 14:23:04 +03:00
Koitharu
9b748f7334 Update parsers and version 2022-07-14 14:12:47 +03:00
Koitharu
2deaed2067 Optimize image loading in lists 2022-07-13 14:48:33 +03:00
Koitharu
fb608ed30a Fix findProgress function not suspend 2022-07-12 12:49:49 +03:00
Koitharu
8e43afe408 Prohibit empty names in favourite categories 2022-07-12 12:02:49 +03:00
93 changed files with 1512 additions and 1036 deletions

View File

@@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 415 versionCode 419
versionName '3.4.3' versionName '3.4.7'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -64,8 +64,11 @@ android {
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged' disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
} }
testOptions { testOptions {
unitTests.includeAndroidResources = true unitTests.includeAndroidResources true
unitTests.returnDefaultValues = false unitTests.returnDefaultValues false
kotlinOptions {
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
}
} }
} }
afterEvaluate { afterEvaluate {
@@ -76,11 +79,11 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.nv95:kotatsu-parsers:2d1907569b') { implementation('com.github.nv95:kotatsu-parsers:7588617316') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.activity:activity-ktx:1.5.0' implementation 'androidx.activity:activity-ktx:1.5.0'
@@ -96,7 +99,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04'
implementation 'com.google.android.material:material:1.7.0-alpha02' implementation 'com.google.android.material:material:1.7.0-alpha03'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.0'
@@ -116,21 +119,21 @@ dependencies {
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-mail:5.9.3' implementation 'ch.acra:acra-mail:5.9.5'
implementation 'ch.acra:acra-dialog:5.9.3' implementation 'ch.acra:acra-dialog:5.9.5'
debugImplementation 'org.jsoup:jsoup:1.15.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3' testImplementation 'org.json:json:20220320'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0' androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.3' androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'io.insert-koin:koin-test:3.2.0' androidTestImplementation 'io.insert-koin:koin-test:3.2.0'
androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0' androidTestImplementation 'io.insert-koin:koin-test-junit4:3.2.0'

View File

@@ -0,0 +1,8 @@
{
"id": 4,
"title": "Read later",
"sortKey": 1,
"order": "NEWEST",
"createdAt": 1335906000000,
"isTrackingEnabled": true
}

View File

@@ -0,0 +1,35 @@
{
"id": -2096681732556647985,
"title": "Странствия Эманон",
"url": "/stranstviia_emanon",
"publicUrl": "https://readmanga.io/stranstviia_emanon",
"rating": 0.9400894,
"isNsfw": true,
"coverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_p.jpg",
"tags": [
{
"title": "Сверхъестественное",
"key": "supernatural",
"source": "READMANGA_RU"
},
{
"title": "Сэйнэн",
"key": "seinen",
"source": "READMANGA_RU"
},
{
"title": "Повседневность",
"key": "slice_of_life",
"source": "READMANGA_RU"
},
{
"title": "Приключения",
"key": "adventure",
"source": "READMANGA_RU"
}
],
"state": "FINISHED",
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
"description": null,
"source": "READMANGA_RU"
}

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu
import android.app.Instrumentation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun Instrumentation.awaitForIdle() = suspendCoroutine<Unit> { cont ->
waitForIdle { cont.resume(Unit) }
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu
import androidx.test.platform.app.InstrumentationRegistry
import com.squareup.moshi.*
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okio.buffer
import okio.source
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.*
import kotlin.reflect.KClass
object SampleData {
private val moshi = Moshi.Builder()
.add(DateAdapter())
.add(KotlinJsonAdapterFactory())
.build()
val manga: Manga = loadAsset("manga/header.json", Manga::class)
val mangaDetails: Manga = loadAsset("manga/full.json", Manga::class)
val tag = mangaDetails.tags.elementAt(2)
val chapter = checkNotNull(mangaDetails.chapters)[2]
val favouriteCategory: FavouriteCategory = loadAsset("categories/simple.json", FavouriteCategory::class)
fun <T : Any> loadAsset(name: String, cls: KClass<T>): T {
val assets = InstrumentationRegistry.getInstrumentation().context.assets
return assets.open(name).use {
moshi.adapter(cls.java).fromJson(it.source().buffer())
} ?: throw RuntimeException("Cannot read asset from json \"$name\"")
}
private class DateAdapter : JsonAdapter<Date>() {
@FromJson
override fun fromJson(reader: JsonReader): Date? {
val ms = reader.nextLong()
return if (ms == 0L) {
null
} else {
Date(ms)
}
}
@ToJson
override fun toJson(writer: JsonWriter, value: Date?) {
writer.value(value?.time ?: 0L)
}
}
}

View File

@@ -3,11 +3,10 @@ package org.koitharu.kotatsu.core.db
import androidx.room.testing.MigrationTestHelper import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import java.io.IOException
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koitharu.kotatsu.core.db.migrations.* import kotlin.test.assertEquals
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MangaDatabaseTest { class MangaDatabaseTest {
@@ -18,38 +17,41 @@ class MangaDatabaseTest {
MangaDatabase::class.java, MangaDatabase::class.java,
) )
private val migrations = databaseMigrations
@Test @Test
@Throws(IOException::class) fun versions() {
fun migrateAll() { assertEquals(1, migrations.first().startVersion)
helper.createDatabase(TEST_DB, 1).apply { repeat(migrations.size) { i ->
// TODO execSQL("") assertEquals(i + 1, migrations[i].startVersion)
close() assertEquals(i + 2, migrations[i].endVersion)
} }
assertEquals(DATABASE_VERSION, migrations.last().endVersion)
}
@Test
fun migrateAll() {
helper.createDatabase(TEST_DB, 1).close()
for (migration in migrations) { for (migration in migrations) {
helper.runMigrationsAndValidate( helper.runMigrationsAndValidate(
TEST_DB, TEST_DB,
migration.endVersion, migration.endVersion,
true, true,
migration migration
) ).close()
}
}
@Test
fun prePopulate() {
val resources = InstrumentationRegistry.getInstrumentation().targetContext.resources
helper.createDatabase(TEST_DB, DATABASE_VERSION).use {
DatabasePrePopulateCallback(resources).onCreate(it)
} }
} }
private companion object { private companion object {
const val TEST_DB = "test-db" const val TEST_DB = "test-db"
val migrations = arrayOf(
Migration1To2(),
Migration2To3(),
Migration3To4(),
Migration4To5(),
Migration5To6(),
Migration6To7(),
Migration7To8(),
Migration8To9(),
Migration9To10(),
Migration10To11(),
)
} }
} }

View File

@@ -0,0 +1,65 @@
package org.koitharu.kotatsu.core.os
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.os.Build
import androidx.core.content.getSystemService
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.test.KoinTest
import org.koin.test.inject
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.awaitForIdle
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.history.domain.HistoryRepository
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class ShortcutsUpdaterTest : KoinTest {
private val historyRepository by inject<HistoryRepository>()
private val shortcutsUpdater by inject<ShortcutsUpdater>()
private val database by inject<MangaDatabase>()
@Before
fun setUp() {
database.clearAllTables()
}
@Test
fun testUpdateShortcuts() = runTest {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return@runTest
}
awaitUpdate()
assertTrue(getShortcuts().isEmpty())
historyRepository.addOrUpdate(
manga = SampleData.manga,
chapterId = SampleData.chapter.id,
page = 4,
scroll = 2,
percent = 0.3f
)
awaitUpdate()
val shortcuts = getShortcuts()
assertEquals(1, shortcuts.size)
}
private fun getShortcuts(): List<ShortcutInfo> {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val manager = checkNotNull(context.getSystemService<ShortcutManager>())
return manager.dynamicShortcuts.filterNot { it.id == "com.squareup.leakcanary.dynamic_shortcut" }
}
private suspend fun awaitUpdate() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.awaitForIdle()
shortcutsUpdater.await()
}
}

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.settings.backup
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.test.KoinTest
import org.koin.test.get
import org.koin.test.inject
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import kotlin.test.*
@RunWith(AndroidJUnit4::class)
class AppBackupAgentTest : KoinTest {
private val historyRepository by inject<HistoryRepository>()
private val favouritesRepository by inject<FavouritesRepository>()
private val backupRepository by inject<BackupRepository>()
private val database by inject<MangaDatabase>()
@Before
fun setUp() {
database.clearAllTables()
}
@Test
fun testBackupRestore() = runTest {
val category = favouritesRepository.createCategory(
title = SampleData.favouriteCategory.title,
sortOrder = SampleData.favouriteCategory.order,
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
)
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
historyRepository.addOrUpdate(
manga = SampleData.mangaDetails,
chapterId = SampleData.mangaDetails.chapters!![2].id,
page = 3,
scroll = 40,
percent = 0.2f,
)
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
val agent = AppBackupAgent()
val backup = agent.createBackupFile(get(), backupRepository)
database.clearAllTables()
assertTrue(favouritesRepository.getAllManga().isEmpty())
assertNull(historyRepository.getLastOrNull())
backup.inputStream().use {
agent.restoreBackupFile(it.fd, backup.length(), backupRepository)
}
assertEquals(category, favouritesRepository.getCategory(category.id))
assertEquals(history, historyRepository.getOne(SampleData.manga))
assertContentEquals(listOf(SampleData.manga), favouritesRepository.getManga(category.id))
val allTags = database.tagsDao.findTags(SampleData.tag.source.name).toMangaTags()
assertContains(allTags, SampleData.tag)
}
}

View File

@@ -1,29 +1,21 @@
package org.koitharu.kotatsu.tracker.domain package org.koitharu.kotatsu.tracker.domain
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okio.buffer
import okio.source
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koin.test.KoinTest import org.koin.test.KoinTest
import org.koin.test.inject import org.koin.test.inject
import org.koitharu.kotatsu.SampleData
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class TrackerTest : KoinTest { class TrackerTest : KoinTest {
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
private val mangaAdapter = moshi.adapter(Manga::class.java)
private val historyRegistry by inject<HistoryRepository>()
private val repository by inject<TrackingRepository>() private val repository by inject<TrackingRepository>()
private val dataRepository by inject<MangaDataRepository>() private val dataRepository by inject<MangaDataRepository>()
private val tracker by inject<Tracker>() private val tracker by inject<Tracker>()
@@ -166,22 +158,25 @@ class TrackerTest : KoinTest {
} }
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id)) assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
val chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) } var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
repository.syncWithHistory(mangaFull, chapter.id) repository.syncWithHistory(mangaFull, chapter.id)
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id)) assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
repository.syncWithHistory(mangaFull, chapter.id)
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
tracker.checkUpdates(mangaFull, commit = true).apply { tracker.checkUpdates(mangaFull, commit = true).apply {
assertTrue(isValid) assertTrue(isValid)
assert(newChapters.isEmpty()) assert(newChapters.isEmpty())
} }
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id)) assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))
} }
private suspend fun loadManga(name: String): Manga { private suspend fun loadManga(name: String): Manga {
val assets = InstrumentationRegistry.getInstrumentation().context.assets val manga = SampleData.loadAsset("manga/$name", Manga::class)
val manga = assets.open("manga/$name").use {
mangaAdapter.fromJson(it.source().buffer())
} ?: throw RuntimeException("Cannot read manga from json \"$name\"")
dataRepository.storeManga(manga) dataRepository.storeManga(manga)
return manga return manga
} }

View File

@@ -5,16 +5,18 @@ import android.content.Context
import android.os.StrictMode import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.strictmode.FragmentStrictMode import androidx.fragment.app.strictmode.FragmentStrictMode
import androidx.room.InvalidationTracker
import org.acra.ReportField import org.acra.ReportField
import org.acra.config.dialog import org.acra.config.dialog
import org.acra.config.mailSender import org.acra.config.mailSender
import org.acra.data.StringFormat import org.acra.data.StringFormat
import org.acra.ktx.initAcra import org.acra.ktx.initAcra
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.android.getKoin
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.bookmarks.bookmarksModule import org.koitharu.kotatsu.bookmarks.bookmarksModule
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.databaseModule import org.koitharu.kotatsu.core.db.databaseModule
import org.koitharu.kotatsu.core.github.githubModule import org.koitharu.kotatsu.core.github.githubModule
import org.koitharu.kotatsu.core.network.networkModule import org.koitharu.kotatsu.core.network.networkModule
@@ -27,7 +29,6 @@ import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.localModule import org.koitharu.kotatsu.local.localModule
import org.koitharu.kotatsu.main.mainModule import org.koitharu.kotatsu.main.mainModule
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.readerModule import org.koitharu.kotatsu.reader.readerModule
import org.koitharu.kotatsu.remotelist.remoteListModule import org.koitharu.kotatsu.remotelist.remoteListModule
@@ -36,7 +37,6 @@ import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.tracker.trackerModule import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.WidgetUpdater
import org.koitharu.kotatsu.widget.appWidgetModule import org.koitharu.kotatsu.widget.appWidgetModule
class KotatsuApp : Application() { class KotatsuApp : Application() {
@@ -48,11 +48,8 @@ class KotatsuApp : Application() {
} }
initKoin() initKoin()
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme) AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
registerActivityLifecycleCallbacks(get<AppProtectHelper>()) setupActivityLifecycleCallbacks()
registerActivityLifecycleCallbacks(get<ActivityRecreationHandle>()) setupDatabaseObservers()
val widgetUpdater = WidgetUpdater(applicationContext)
widgetUpdater.subscribeToFavourites(get())
widgetUpdater.subscribeToHistory(get())
} }
private fun initKoin() { private fun initKoin() {
@@ -112,6 +109,22 @@ class KotatsuApp : Application() {
} }
} }
private fun setupDatabaseObservers() {
val observers = getKoin().getAll<InvalidationTracker.Observer>()
val database = get<MangaDatabase>()
val tracker = database.invalidationTracker
observers.forEach {
tracker.addObserver(it)
}
}
private fun setupActivityLifecycleCallbacks() {
val callbacks = getKoin().getAll<ActivityLifecycleCallbacks>()
callbacks.forEach {
registerActivityLifecycleCallbacks(it)
}
}
private fun enableStrictMode() { private fun enableStrictMode() {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.text.Selection
import android.text.Spannable
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.annotation.AttrRes
import com.google.android.material.textview.MaterialTextView
class SelectableTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = android.R.attr.textViewStyle,
) : MaterialTextView(context, attrs, defStyleAttr) {
override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
fixSelectionRange()
return super.dispatchTouchEvent(event)
}
// https://stackoverflow.com/questions/22810147/error-when-selecting-text-from-textview-java-lang-indexoutofboundsexception-se
private fun fixSelectionRange() {
if (selectionStart < 0 || selectionEnd < 0) {
val spannableText = text as? Spannable ?: return
Selection.setSelection(spannableText, text.length)
}
}
}

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
), ),
] ]
) )
class BookmarkEntity( data class BookmarkEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "page_id", index = true) val pageId: Long, @ColumnInfo(name = "page_id", index = true) val pageId: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long, @ColumnInfo(name = "chapter_id") val chapterId: Long,

View File

@@ -2,15 +2,13 @@ package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable
import coil.size.Scale
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.base.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
@@ -23,29 +21,24 @@ fun bookmarkListAD(
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }
) { ) {
var imageRequest: Disposable? = null
val listener = AdapterDelegateClickListenerAdapter(this, clickListener) val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener) binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener) binding.root.setOnLongClickListener(listener)
bind { bind {
imageRequest?.dispose() binding.imageViewThumb.newImageRequest(item.imageUrl)?.run {
imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl) referer(item.manga.publicUrl)
.referer(item.manga.publicUrl) placeholder(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) allowRgb565(true)
.allowRgb565(true) lifecycle(lifecycleOwner)
.scale(Scale.FILL) enqueueWith(coil)
.lifecycle(lifecycleOwner) }
.enqueueWith(coil)
} }
onViewRecycled { onViewRecycled {
imageRequest?.dispose() binding.imageViewThumb.disposeImageRequest()
imageRequest = null
CoilUtils.dispose(binding.imageViewThumb)
binding.imageViewThumb.setImageDrawable(null)
} }
} }

View File

@@ -2,10 +2,9 @@ package org.koitharu.kotatsu.browser
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import org.koin.core.component.KoinComponent import android.webkit.WebViewClient
import org.koitharu.kotatsu.core.network.WebViewClientCompat
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent { class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
override fun onPageFinished(webView: WebView, url: String) { override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url) super.onPageFinished(webView, url)

View File

@@ -2,13 +2,14 @@ package org.koitharu.kotatsu.browser
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.widget.ProgressBar
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.google.android.material.progressindicator.BaseProgressIndicator import org.koitharu.kotatsu.utils.ext.setProgressCompat
private const val PROGRESS_MAX = 100 private const val PROGRESS_MAX = 100
class ProgressChromeClient( class ProgressChromeClient(
private val progressIndicator: BaseProgressIndicator<*>, private val progressIndicator: ProgressBar,
) : WebChromeClient() { ) : WebChromeClient() {
init { init {
@@ -24,7 +25,7 @@ class ProgressChromeClient(
progressIndicator.isIndeterminate = false progressIndicator.isIndeterminate = false
progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true) progressIndicator.setProgressCompat(newProgress.coerceAtMost(PROGRESS_MAX), true)
} else { } else {
progressIndicator.setIndeterminate(true) progressIndicator.isIndeterminate = true
} }
} }
} }

View File

@@ -2,19 +2,19 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.core.network.AndroidCookieJar import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.WebViewClientCompat
private const val CF_CLEARANCE = "cf_clearance" private const val CF_CLEARANCE = "cf_clearance"
class CloudFlareClient( class CloudFlareClient(
private val cookieJar: AndroidCookieJar, private val cookieJar: AndroidCookieJar,
private val callback: CloudFlareCallback, private val callback: CloudFlareCallback,
private val targetUrl: String private val targetUrl: String,
) : WebViewClientCompat() { ) : WebViewClient() {
private val oldClearance = getCookieValue(CF_CLEARANCE) private val oldClearance = getClearance()
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
@@ -32,14 +32,14 @@ class CloudFlareClient(
} }
private fun checkClearance() { private fun checkClearance() {
val clearance = getCookieValue(CF_CLEARANCE) val clearance = getClearance()
if (clearance != null && clearance != oldClearance) { if (clearance != null && clearance != oldClearance) {
callback.onCheckPassed() callback.onCheckPassed()
} }
} }
private fun getCookieValue(name: String): String? { private fun getClearance(): String? {
return cookieJar.loadForRequest(targetUrl.toHttpUrl()) return cookieJar.loadForRequest(targetUrl.toHttpUrl())
.find { it.name == name }?.value .find { it.name == CF_CLEARANCE }?.value
} }
} }

View File

@@ -1,14 +1,12 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import androidx.room.withTransaction
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
private const val PAGE_SIZE = 10 private const val PAGE_SIZE = 10
@@ -24,11 +22,11 @@ class BackupRepository(private val db: MangaDatabase) {
} }
offset += history.size offset += history.size
for (item in history) { for (item in history) {
val manga = item.manga.toJson() val manga = JsonSerializer(item.manga).toJson()
val tags = JSONArray() val tags = JSONArray()
item.tags.forEach { tags.put(it.toJson()) } item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
manga.put("tags", tags) manga.put("tags", tags)
val json = item.history.toJson() val json = JsonSerializer(item.history).toJson()
json.put("manga", manga) json.put("manga", manga)
entry.data.put(json) entry.data.put(json)
} }
@@ -40,7 +38,7 @@ class BackupRepository(private val db: MangaDatabase) {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
val categories = db.favouriteCategoriesDao.findAll() val categories = db.favouriteCategoriesDao.findAll()
for (item in categories) { for (item in categories) {
entry.data.put(item.toJson()) entry.data.put(JsonSerializer(item).toJson())
} }
return entry return entry
} }
@@ -55,11 +53,11 @@ class BackupRepository(private val db: MangaDatabase) {
} }
offset += favourites.size offset += favourites.size
for (item in favourites) { for (item in favourites) {
val manga = item.manga.toJson() val manga = JsonSerializer(item.manga).toJson()
val tags = JSONArray() val tags = JSONArray()
item.tags.forEach { tags.put(it.toJson()) } item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
manga.put("tags", tags) manga.put("tags", tags)
val json = item.favourite.toJson() val json = JsonSerializer(item.favourite).toJson()
json.put("manga", manga) json.put("manga", manga)
entry.data.put(json) entry.data.put(json)
} }
@@ -77,60 +75,54 @@ class BackupRepository(private val db: MangaDatabase) {
return entry return entry
} }
private fun MangaEntity.toJson(): JSONObject { suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val jo = JSONObject() val result = CompositeResult()
jo.put("id", id) for (item in entry.data.JSONIterator()) {
jo.put("title", title) val mangaJson = item.getJSONObject("manga")
jo.put("alt_title", altTitle) val manga = JsonDeserializer(mangaJson).toMangaEntity()
jo.put("url", url) val tags = mangaJson.getJSONArray("tags").mapJSON {
jo.put("public_url", publicUrl) JsonDeserializer(it).toTagEntity()
jo.put("rating", rating) }
jo.put("nsfw", isNsfw) val history = JsonDeserializer(item).toHistoryEntity()
jo.put("cover_url", coverUrl) result += runCatching {
jo.put("large_cover_url", largeCoverUrl) db.withTransaction {
jo.put("state", state) db.tagsDao.upsert(tags)
jo.put("author", author) db.mangaDao.upsert(manga, tags)
jo.put("source", source) db.historyDao.upsert(history)
return jo }
}
}
return result
} }
private fun TagEntity.toJson(): JSONObject { suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
val jo = JSONObject() val result = CompositeResult()
jo.put("id", id) for (item in entry.data.JSONIterator()) {
jo.put("title", title) val category = JsonDeserializer(item).toFavouriteCategoryEntity()
jo.put("key", key) result += runCatching {
jo.put("source", source) db.favouriteCategoriesDao.upsert(category)
return jo }
}
return result
} }
private fun HistoryEntity.toJson(): JSONObject { suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
val jo = JSONObject() val result = CompositeResult()
jo.put("manga_id", mangaId) for (item in entry.data.JSONIterator()) {
jo.put("created_at", createdAt) val mangaJson = item.getJSONObject("manga")
jo.put("updated_at", updatedAt) val manga = JsonDeserializer(mangaJson).toMangaEntity()
jo.put("chapter_id", chapterId) val tags = mangaJson.getJSONArray("tags").mapJSON {
jo.put("page", page) JsonDeserializer(it).toTagEntity()
jo.put("scroll", scroll) }
jo.put("percent", percent) val favourite = JsonDeserializer(item).toFavouriteEntity()
return jo result += runCatching {
} db.withTransaction {
db.tagsDao.upsert(tags)
private fun FavouriteCategoryEntity.toJson(): JSONObject { db.mangaDao.upsert(manga, tags)
val jo = JSONObject() db.favouritesDao.upsert(favourite)
jo.put("category_id", categoryId) }
jo.put("created_at", createdAt) }
jo.put("sort_key", sortKey) }
jo.put("title", title) return result
jo.put("order", order)
jo.put("track", track)
return jo
}
private fun FavouriteEntity.toJson(): JSONObject {
val jo = JSONObject()
jo.put("manga_id", mangaId)
jo.put("category_id", categoryId)
jo.put("created_at", createdAt)
return jo
} }
} }

View File

@@ -0,0 +1,62 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
class JsonDeserializer(private val json: JSONObject) {
fun toFavouriteEntity() = FavouriteEntity(
mangaId = json.getLong("manga_id"),
categoryId = json.getLong("category_id"),
createdAt = json.getLong("created_at"),
)
fun toMangaEntity() = MangaEntity(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getStringOrNull("alt_title"),
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover_url"),
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),
author = json.getStringOrNull("author"),
source = json.getString("source")
)
fun toTagEntity() = TagEntity(
id = json.getLong("id"),
title = json.getString("title"),
key = json.getString("key"),
source = json.getString("source")
)
fun toHistoryEntity() = HistoryEntity(
mangaId = json.getLong("manga_id"),
createdAt = json.getLong("created_at"),
updatedAt = json.getLong("updated_at"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
)
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
categoryId = json.getInt("category_id"),
createdAt = json.getLong("created_at"),
sortKey = json.getInt("sort_key"),
title = json.getString("title"),
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
track = json.getBooleanOrDefault("track", true),
)
}

View File

@@ -0,0 +1,70 @@
package org.koitharu.kotatsu.core.backup
import org.json.JSONObject
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
class JsonSerializer private constructor(private val json: JSONObject) {
constructor(e: FavouriteEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("category_id", e.categoryId)
put("created_at", e.createdAt)
}
)
constructor(e: FavouriteCategoryEntity) : this(
JSONObject().apply {
put("category_id", e.categoryId)
put("created_at", e.createdAt)
put("sort_key", e.sortKey)
put("title", e.title)
put("order", e.order)
put("track", e.track)
}
)
constructor(e: HistoryEntity) : this(
JSONObject().apply {
put("manga_id", e.mangaId)
put("created_at", e.createdAt)
put("updated_at", e.updatedAt)
put("chapter_id", e.chapterId)
put("page", e.page)
put("scroll", e.scroll)
put("percent", e.percent)
}
)
constructor(e: TagEntity) : this(
JSONObject().apply {
put("id", e.id)
put("title", e.title)
put("key", e.key)
put("source", e.source)
}
)
constructor(e: MangaEntity) : this(
JSONObject().apply {
put("id", e.id)
put("title", e.title)
put("alt_title", e.altTitle)
put("url", e.url)
put("public_url", e.publicUrl)
put("rating", e.rating)
put("nsfw", e.isNsfw)
put("cover_url", e.coverUrl)
put("large_cover_url", e.largeCoverUrl)
put("state", e.state)
put("author", e.author)
put("source", e.source)
}
)
fun toJson(): JSONObject = json
}

View File

@@ -1,113 +0,0 @@
package org.koitharu.kotatsu.core.backup
import androidx.room.withTransaction
import org.json.JSONObject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.json.*
class RestoreRepository(private val db: MangaDatabase) {
suspend fun upsertHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val mangaJson = item.getJSONObject("manga")
val manga = parseManga(mangaJson)
val tags = mangaJson.getJSONArray("tags").mapJSON {
parseTag(it)
}
val history = parseHistory(item)
result += runCatching {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags)
db.historyDao.upsert(history)
}
}
}
return result
}
suspend fun upsertCategories(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val category = parseCategory(item)
result += runCatching {
db.favouriteCategoriesDao.upsert(category)
}
}
return result
}
suspend fun upsertFavourites(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val mangaJson = item.getJSONObject("manga")
val manga = parseManga(mangaJson)
val tags = mangaJson.getJSONArray("tags").mapJSON {
parseTag(it)
}
val favourite = parseFavourite(item)
result += runCatching {
db.withTransaction {
db.tagsDao.upsert(tags)
db.mangaDao.upsert(manga, tags)
db.favouritesDao.upsert(favourite)
}
}
}
return result
}
private fun parseManga(json: JSONObject) = MangaEntity(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getStringOrNull("alt_title"),
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
coverUrl = json.getString("cover_url"),
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),
author = json.getStringOrNull("author"),
source = json.getString("source")
)
private fun parseTag(json: JSONObject) = TagEntity(
id = json.getLong("id"),
title = json.getString("title"),
key = json.getString("key"),
source = json.getString("source")
)
private fun parseHistory(json: JSONObject) = HistoryEntity(
mangaId = json.getLong("manga_id"),
createdAt = json.getLong("created_at"),
updatedAt = json.getLong("updated_at"),
chapterId = json.getLong("chapter_id"),
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
)
private fun parseCategory(json: JSONObject) = FavouriteCategoryEntity(
categoryId = json.getInt("category_id"),
createdAt = json.getLong("created_at"),
sortKey = json.getInt("sort_key"),
title = json.getString("title"),
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
track = json.getBooleanOrDefault("track", true),
)
private fun parseFavourite(json: JSONObject) = FavouriteEntity(
mangaId = json.getLong("manga_id"),
categoryId = json.getLong("category_id"),
createdAt = json.getLong("created_at")
)
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
import org.koitharu.kotatsu.core.db.dao.MangaDao import org.koitharu.kotatsu.core.db.dao.MangaDao
@@ -29,6 +30,8 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 12
@Database( @Database(
entities = [ entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
@@ -36,7 +39,7 @@ import org.koitharu.kotatsu.tracker.data.TracksDao
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, ScrobblingEntity::class,
], ],
version = 12, version = DATABASE_VERSION,
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
@@ -63,22 +66,23 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val scrobblingDao: ScrobblingDao abstract val scrobblingDao: ScrobblingDao
} }
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder( val databaseMigrations: Array<Migration>
context, get() = arrayOf(
MangaDatabase::class.java, Migration1To2(),
"kotatsu-db" Migration2To3(),
).addMigrations( Migration3To4(),
Migration1To2(), Migration4To5(),
Migration2To3(), Migration5To6(),
Migration3To4(), Migration6To7(),
Migration4To5(), Migration7To8(),
Migration5To6(), Migration8To9(),
Migration6To7(), Migration9To10(),
Migration7To8(), Migration10To11(),
Migration8To9(), Migration11To12(),
Migration9To10(), )
Migration10To11(),
Migration11To12(), fun MangaDatabase(context: Context): MangaDatabase = Room
).addCallback( .databaseBuilder(context, MangaDatabase::class.java, "kotatsu-db")
DatabasePrePopulateCallback(context.resources) .addMigrations(*databaseMigrations)
).build() .addCallback(DatabasePrePopulateCallback(context.resources))
.build()

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.db
const val TABLE_FAVOURITES = "favourites"
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"

View File

@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_MANGA
@Entity(tableName = "manga") @Entity(tableName = TABLE_MANGA)
class MangaEntity( data class MangaEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,
@@ -18,5 +19,5 @@ class MangaEntity(
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?, @ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?, @ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?, @ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "source") val source: String @ColumnInfo(name = "source") val source: String,
) )

View File

@@ -3,9 +3,11 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
@Entity( @Entity(
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], tableName = TABLE_MANGA_TAGS,
primaryKeys = ["manga_id", "tag_id"],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
@@ -23,5 +25,5 @@ import androidx.room.ForeignKey
) )
class MangaTagsEntity( class MangaTagsEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "tag_id", index = true) val tagId: Long @ColumnInfo(name = "tag_id", index = true) val tagId: Long,
) )

View File

@@ -3,12 +3,13 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_TAGS
@Entity(tableName = "tags") @Entity(tableName = TABLE_TAGS)
class TagEntity( data class TagEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long, @ColumnInfo(name = "tag_id") val id: Long,
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String, @ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "source") val source: String @ColumnInfo(name = "source") val source: String,
) )

View File

@@ -0,0 +1,3 @@
package org.koitharu.kotatsu.core.exceptions
class CaughtException(cause: Throwable, override val message: String?) : RuntimeException(cause)

View File

@@ -1,3 +0,0 @@
package org.koitharu.kotatsu.core.exceptions
class MangaNotFoundException(s: String? = null) : RuntimeException(s)

View File

@@ -8,9 +8,11 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.TaggedActivityResult import org.koitharu.kotatsu.utils.TaggedActivityResult
@@ -43,6 +45,10 @@ class ExceptionResolver private constructor(
suspend fun resolve(e: Throwable): Boolean = when (e) { suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e.url) is CloudFlareProtectedException -> resolveCF(e.url)
is AuthRequiredException -> resolveAuthException(e.source) is AuthRequiredException -> resolveAuthException(e.source)
is NotFoundException -> {
openInBrowser(e.url)
false
}
else -> false else -> false
} }
@@ -69,6 +75,11 @@ class ExceptionResolver private constructor(
sourceAuthContract.launch(source) sourceAuthContract.launch(source)
} }
private fun openInBrowser(url: String) {
val context = activity ?: fragment?.activity ?: return
context.startActivity(BrowserActivity.newIntent(context, url, null))
}
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager) private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
companion object { companion object {
@@ -77,6 +88,7 @@ class ExceptionResolver private constructor(
fun getResolveStringId(e: Throwable) = when (e) { fun getResolveStringId(e: Throwable) = when (e) {
is CloudFlareProtectedException -> R.string.captcha_solve is CloudFlareProtectedException -> R.string.captcha_solve
is AuthRequiredException -> R.string.sign_in is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
else -> 0 else -> 0
} }

View File

@@ -10,7 +10,7 @@ class GithubRepository(private val okHttp: OkHttpClient) {
suspend fun getLatestVersion(): AppVersion { suspend fun getLatestVersion(): AppVersion {
val request = Request.Builder() val request = Request.Builder()
.get() .get()
.url("https://api.github.com/repos/nv95/Kotatsu/releases/latest") .url("https://api.github.com/repos/KotatsuApp/Kotatsu/releases/latest")
val json = okHttp.newCall(request.build()).await().parseJson() val json = okHttp.newCall(request.build()).await().parseJson()
val asset = json.getJSONArray("assets").getJSONObject(0) val asset = json.getJSONArray("assets").getJSONObject(0)
return AppVersion( return AppVersion(

View File

@@ -21,7 +21,12 @@ class DoHManager(
private var cachedProvider: DoHProvider? = null private var cachedProvider: DoHProvider? = null
override fun lookup(hostname: String): List<InetAddress> { override fun lookup(hostname: String): List<InetAddress> {
return getDelegate().lookup(hostname) return try {
getDelegate().lookup(hostname)
} catch (e: UnknownHostException) {
// fallback
Dns.SYSTEM.lookup(hostname)
}
} }
@Synchronized @Synchronized
@@ -40,6 +45,7 @@ class DoHManager(
DoHProvider.NONE -> Dns.SYSTEM DoHProvider.NONE -> Dns.SYSTEM
DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient) DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns.google/dns-query".toHttpUrl()) .url("https://dns.google/dns-query".toHttpUrl())
.resolvePrivateAddresses(true)
.bootstrapDnsHosts( .bootstrapDnsHosts(
listOfNotNull( listOfNotNull(
tryGetByIp("8.8.4.4"), tryGetByIp("8.8.4.4"),
@@ -50,6 +56,7 @@ class DoHManager(
).build() ).build()
DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient) DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://cloudflare-dns.com/dns-query".toHttpUrl()) .url("https://cloudflare-dns.com/dns-query".toHttpUrl())
.resolvePrivateAddresses(true)
.bootstrapDnsHosts( .bootstrapDnsHosts(
listOfNotNull( listOfNotNull(
tryGetByIp("162.159.36.1"), tryGetByIp("162.159.36.1"),
@@ -65,6 +72,7 @@ class DoHManager(
).build() ).build()
DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient) DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl()) .url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
.resolvePrivateAddresses(true)
.bootstrapDnsHosts( .bootstrapDnsHosts(
listOfNotNull( listOfNotNull(
tryGetByIp("94.140.14.140"), tryGetByIp("94.140.14.140"),
@@ -81,4 +89,4 @@ class DoHManager(
e.printStackTraceDebug() e.printStackTraceDebug()
null null
} }
} }

View File

@@ -1,86 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.annotation.TargetApi
import android.os.Build
import android.webkit.*
@Suppress("OverridingDeprecatedMember")
abstract class WebViewClientCompat : WebViewClient() {
open fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
return false
}
open fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? {
return null
}
open fun onReceivedErrorCompat(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String,
isMainFrame: Boolean
) {
}
@TargetApi(Build.VERSION_CODES.N)
final override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean = shouldOverrideUrlCompat(view, request.url.toString())
final override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
return shouldOverrideUrlCompat(view, url)
}
final override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest
): WebResourceResponse? = shouldInterceptRequestCompat(view, request.url.toString())
final override fun shouldInterceptRequest(
view: WebView,
url: String
): WebResourceResponse? = shouldInterceptRequestCompat(view, url)
@TargetApi(Build.VERSION_CODES.M)
final override fun onReceivedError(
view: WebView,
request: WebResourceRequest,
error: WebResourceError
) {
onReceivedErrorCompat(
view,
error.errorCode,
error.description?.toString(),
request.url.toString(),
request.isForMainFrame
)
}
final override fun onReceivedError(
view: WebView,
errorCode: Int,
description: String?,
failingUrl: String
) {
onReceivedErrorCompat(view, errorCode, description, failingUrl, failingUrl == view.url)
}
@TargetApi(Build.VERSION_CODES.M)
final override fun onReceivedHttpError(
view: WebView,
request: WebResourceRequest,
error: WebResourceResponse
) {
onReceivedErrorCompat(
view,
error.statusCode,
error.reasonPhrase,
request.url
.toString(),
request.isForMainFrame
)
}
}

View File

@@ -6,38 +6,42 @@ import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import android.os.Build import android.os.Build
import android.util.Size import android.util.Size
import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.room.InvalidationTracker
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireBitmap import org.koitharu.kotatsu.utils.ext.requireBitmap
class ShortcutsRepository( class ShortcutsUpdater(
private val context: Context, private val context: Context,
private val coil: ImageLoader, private val coil: ImageLoader,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val mangaRepository: MangaDataRepository, private val mangaRepository: MangaDataRepository,
) { ) : InvalidationTracker.Observer(TABLE_HISTORY) {
private val iconSize by lazy { private val iconSize by lazy { getIconSize(context) }
getIconSize(context) private var shortcutsUpdateJob: Job? = null
}
suspend fun updateShortcuts() { override fun onInvalidated(tables: MutableSet<String>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return val prevJob = shortcutsUpdateJob
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager shortcutsUpdateJob = processLifecycleScope.launch(Dispatchers.Default) {
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity) prevJob?.join()
.filter { x -> x.title.isNotEmpty() } updateShortcutsImpl()
.map { buildShortcutInfo(it).build().toShortcutInfo() } }
manager.dynamicShortcuts = shortcuts
} }
suspend fun requestPinShortcut(manga: Manga): Boolean { suspend fun requestPinShortcut(manga: Manga): Boolean {
@@ -48,17 +52,30 @@ class ShortcutsRepository(
) )
} }
@VisibleForTesting
suspend fun await(): Boolean {
return shortcutsUpdateJob?.join() != null
}
private suspend fun updateShortcutsImpl() = runCatching {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
.filter { x -> x.title.isNotEmpty() }
.map { buildShortcutInfo(it).build().toShortcutInfo() }
manager.dynamicShortcuts = shortcuts
}.onFailure {
it.printStackTraceDebug()
}
private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder { private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat.Builder {
val icon = runCatching { val icon = runCatching {
withContext(Dispatchers.IO) { val bmp = coil.execute(
val bmp = coil.execute( ImageRequest.Builder(context)
ImageRequest.Builder(context) .data(manga.coverUrl)
.data(manga.coverUrl) .size(iconSize.width, iconSize.height)
.size(iconSize.width, iconSize.height) .build()
.build() ).requireBitmap()
).requireBitmap() ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
}
}.fold( }.fold(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) } onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }

View File

@@ -135,6 +135,26 @@ class ChaptersFragment :
mode.finish() mode.finish()
true true
} }
R.id.action_select_range -> {
val controller = selectionController ?: return false
val items = chaptersAdapter?.items ?: return false
val ids = HashSet(controller.peekCheckedIds())
val buffer = HashSet<Long>()
var isAdding = false
for (x in items) {
if (x.chapter.id in ids) {
isAdding = true
if (buffer.isNotEmpty()) {
ids.addAll(buffer)
buffer.clear()
}
} else if (isAdding) {
buffer.add(x.chapter.id)
}
}
controller.addAll(ids)
true
}
R.id.action_select_all -> { R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionController?.addAll(ids) selectionController?.addAll(ids)
@@ -158,14 +178,24 @@ class ChaptersFragment :
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
val selectedIds = selectionController?.peekCheckedIds() ?: return false val selectedIds = selectionController?.peekCheckedIds() ?: return false
val items = chaptersAdapter?.items?.filter { x -> x.chapter.id in selectedIds }.orEmpty() val allItems = chaptersAdapter?.items.orEmpty()
menu.findItem(R.id.action_save).isVisible = items.none { x -> val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds }
menu.findItem(R.id.action_save).isVisible = items.none { (_, x) ->
x.chapter.source == MangaSource.LOCAL x.chapter.source == MangaSource.LOCAL
} }
menu.findItem(R.id.action_delete).isVisible = items.all { x -> menu.findItem(R.id.action_delete).isVisible = items.all { (_, x) ->
x.chapter.source == MangaSource.LOCAL x.chapter.source == MangaSource.LOCAL
} }
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
mode.title = items.size.toString() mode.title = items.size.toString()
var hasGap = false
for (i in 0 until items.size - 1) {
if (items[i].index + 1 != items[i + 1].index) {
hasGap = true
break
}
}
menu.findItem(R.id.action_select_range).isVisible = hasGap
return true return true
} }

View File

@@ -34,7 +34,7 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
@@ -103,7 +103,8 @@ class DetailsActivity :
private fun onMangaRemoved(manga: Manga) { private fun onMangaRemoved(manga: Manga) {
Toast.makeText( Toast.makeText(
this, getString(R.string._s_deleted_from_local_storage, manga.title), this,
getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
finishAfterTransition() finishAfterTransition()
@@ -224,7 +225,7 @@ class DetailsActivity :
R.id.action_shortcut -> { R.id.action_shortcut -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
lifecycleScope.launch { lifecycleScope.launch {
if (!get<ShortcutsRepository>().requestPinShortcut(it)) { if (!get<ShortcutsUpdater>().requestPinShortcut(it)) {
binding.snackbar.show(getString(R.string.operation_not_supported)) binding.snackbar.show(getString(R.string.operation_not_supported))
} }
} }

View File

@@ -231,14 +231,13 @@ class DetailsFragment :
CoilUtils.dispose(imageViewCover) CoilUtils.dispose(imageViewCover)
return return
} }
imageViewCover.newImageRequest(scrobbling.coverUrl) imageViewCover.newImageRequest(scrobbling.coverUrl)?.run {
.crossfade(true) placeholder(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) lifecycle(viewLifecycleOwner)
.scale(Scale.FILL) enqueueWith(coil)
.lifecycle(viewLifecycleOwner) }
.enqueueWith(coil)
textViewTitle.text = scrobbling.title textViewTitle.text = scrobbling.title
textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0) textViewTitle.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, scrobbling.scrobbler.iconResId, 0)
ratingBar.rating = scrobbling.rating * ratingBar.numStars ratingBar.rating = scrobbling.rating * ratingBar.numStars

View File

@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.acra.ACRA
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -14,7 +12,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.NotFoundException
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.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -22,7 +20,6 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.setCurrentManga
class MangaDetailsDelegate( class MangaDetailsDelegate(
private val intent: MangaIntent, private val intent: MangaIntent,
@@ -43,9 +40,7 @@ class MangaDetailsDelegate(
val mangaId = intent.manga?.id ?: intent.mangaId val mangaId = intent.manga?.id ?: intent.mangaId
suspend fun doLoad() { suspend fun doLoad() {
var manga = mangaDataRepository.resolveIntent(intent) var manga = mangaDataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "")
?: throw MangaNotFoundException("Cannot find manga")
ACRA.setCurrentManga(manga)
mangaData.value = manga mangaData.value = manga
manga = MangaRepository(manga.source).getDetails(manga) manga = MangaRepository(manga.source).getDetails(manga)
// find default branch // find default branch

View File

@@ -27,13 +27,14 @@ fun downloadItemAD(
bind { bind {
job?.cancel() job?.cancel()
job = item.progressAsFlow().onFirst { state -> job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(state.manga.coverUrl) binding.imageViewCover.newImageRequest(state.manga.coverUrl)?.run {
.referer(state.manga.publicUrl) referer(state.manga.publicUrl)
.placeholder(state.cover) placeholder(state.cover)
.fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.allowRgb565(true) allowRgb565(true)
.enqueueWith(coil) enqueueWith(coil)
}
}.onEach { state -> }.onEach { state ->
binding.textViewTitle.text = state.manga.title binding.textViewTitle.text = state.manga.title
when (state) { when (state) {

View File

@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
@Entity(tableName = "favourite_categories") @Entity(tableName = TABLE_FAVOURITE_CATEGORIES)
class FavouriteCategoryEntity( data class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,

View File

@@ -3,10 +3,13 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity( @Entity(
tableName = "favourites", primaryKeys = ["manga_id", "category_id"], foreignKeys = [ tableName = TABLE_FAVOURITES,
primaryKeys = ["manga_id", "category_id"],
foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],
@@ -21,8 +24,8 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
) )
] ]
) )
class FavouriteEntity( data class FavouriteEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long, @ColumnInfo(name = "category_id", index = true) val categoryId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long @ColumnInfo(name = "created_at") val createdAt: Long,
) )

View File

@@ -148,7 +148,12 @@ class FavouritesContainerFragment :
menu.setOnMenuItemClickListener { menu.setOnMenuItemClickListener {
when (it.itemId) { when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category) R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(tabView.context, category.id)) R.id.action_edit -> startActivity(
FavouritesCategoryEditActivity.newIntent(
tabView.context,
category.id
)
)
else -> return@setOnMenuItemClickListener false else -> return@setOnMenuItemClickListener false
} }
true true
@@ -172,7 +177,7 @@ class FavouritesContainerFragment :
private fun showStub() { private fun showStub() {
val stub = stubBinding ?: ItemEmptyStateBinding.bind(binding.stubEmptyState.inflate()) val stub = stubBinding ?: ItemEmptyStateBinding.bind(binding.stubEmptyState.inflate())
stub.root.isVisible = true stub.root.isVisible = true
stub.icon.setImageResource(R.drawable.ic_heart_outline) stub.icon.setImageResource(R.drawable.ic_empty_favourites)
stub.textPrimary.setText(R.string.text_empty_holder_primary) stub.textPrimary.setText(R.string.text_empty_holder_primary)
stub.textSecondary.setText(R.string.empty_favourite_categories) stub.textSecondary.setText(R.string.empty_favourite_categories)
stub.buttonRetry.setText(R.string.add) stub.buttonRetry.setText(R.string.add)

View File

@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.favourites.ui.categories.edit
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
@@ -24,7 +26,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener, class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener,
View.OnClickListener { View.OnClickListener, TextWatcher {
private val viewModel by viewModel<FavouritesCategoryEditViewModel> { private val viewModel by viewModel<FavouritesCategoryEditViewModel> {
parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID)) parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID))
@@ -40,6 +42,8 @@ class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>
} }
initSortSpinner() initSortSpinner()
binding.buttonDone.setOnClickListener(this) binding.buttonDone.setOnClickListener(this)
binding.editName.addTextChangedListener(this)
afterTextChanged(binding.editName.text)
viewModel.onSaved.observe(this) { finishAfterTransition() } viewModel.onSaved.observe(this) { finishAfterTransition() }
viewModel.category.observe(this, ::onCategoryChanged) viewModel.category.observe(this, ::onCategoryChanged)
@@ -66,13 +70,21 @@ class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_done -> viewModel.save( R.id.button_done -> viewModel.save(
title = binding.editName.text?.toString().orEmpty(), title = binding.editName.text?.toString()?.trim().orEmpty(),
sortOrder = getSelectedSortOrder(), sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked, isTrackerEnabled = binding.switchTracker.isChecked,
) )
} }
} }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
binding.buttonDone.isEnabled = !s.isNullOrBlank()
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.scrollView.updatePadding( binding.scrollView.updatePadding(
left = insets.left, left = insets.left,

View File

@@ -42,6 +42,7 @@ class FavouritesCategoryEditViewModel(
isTrackerEnabled: Boolean, isTrackerEnabled: Boolean,
) { ) {
launchLoadingJob { launchLoadingJob {
check(title.isNotEmpty())
if (categoryId == NO_ID) { if (categoryId == NO_ID) {
repository.createCategory(title, sortOrder, isTrackerEnabled) repository.createCategory(title, sortOrder, isTrackerEnabled)
} else { } else {

View File

@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.favourites.ui.categories.select
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -26,7 +28,8 @@ class FavouriteCategoriesBottomSheet :
BaseBottomSheet<DialogFavoriteCategoriesBinding>(), BaseBottomSheet<DialogFavoriteCategoriesBinding>(),
OnListItemClickListener<MangaCategoryItem>, OnListItemClickListener<MangaCategoryItem>,
CategoriesEditDelegate.CategoriesEditCallback, CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener { View.OnClickListener,
Toolbar.OnMenuItemClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel> { private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga }) parametersOf(requireNotNull(arguments?.getParcelableArrayList<ParcelableManga>(KEY_MANGA_LIST)).map { it.manga })
@@ -44,7 +47,7 @@ class FavouriteCategoriesBottomSheet :
adapter = MangaCategoriesAdapter(this) adapter = MangaCategoriesAdapter(this)
binding.recyclerViewCategories.adapter = adapter binding.recyclerViewCategories.adapter = adapter
binding.buttonDone.setOnClickListener(this) binding.buttonDone.setOnClickListener(this)
binding.itemCreate.setOnClickListener(this) binding.toolbar.setOnMenuItemClickListener(this)
viewModel.content.observe(viewLifecycleOwner, this::onContentChanged) viewModel.content.observe(viewLifecycleOwner, this::onContentChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
@@ -57,11 +60,18 @@ class FavouriteCategoriesBottomSheet :
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.item_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
R.id.button_done -> dismiss() R.id.button_done -> dismiss()
} }
} }
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_create -> startActivity(FavouritesCategoryEditActivity.newIntent(requireContext()))
else -> return false
}
return true
}
override fun onItemClick(item: MangaCategoryItem, view: View) { override fun onItemClick(item: MangaCategoryItem, view: View) {
viewModel.setChecked(item.id, !item.isChecked) viewModel.setChecked(item.id, !item.isChecked)
} }

View File

@@ -10,5 +10,5 @@ val historyModule
single { HistoryRepository(get(), get(), get(), getAll()) } single { HistoryRepository(get(), get(), get(), getAll()) }
viewModel { HistoryListViewModel(get(), get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get()) }
} }

View File

@@ -46,7 +46,7 @@ abstract class HistoryDao {
abstract fun observeCount(): Flow<Int> abstract fun observeCount(): Flow<Int>
@Query("SELECT percent FROM history WHERE manga_id = :id") @Query("SELECT percent FROM history WHERE manga_id = :id")
abstract fun findProgress(id: Long): Float? abstract suspend fun findProgress(id: Long): Float?
@Query("DELETE FROM history") @Query("DELETE FROM history")
abstract suspend fun clear() abstract suspend fun clear()

View File

@@ -4,10 +4,11 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity( @Entity(
tableName = "history", tableName = TABLE_HISTORY,
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
@@ -17,7 +18,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
) )
] ]
) )
class HistoryEntity( data class HistoryEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,

View File

@@ -9,8 +9,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.plus
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -31,7 +29,6 @@ import java.util.concurrent.TimeUnit
class HistoryListViewModel( class HistoryListViewModel(
private val repository: HistoryRepository, private val repository: HistoryRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val shortcutsRepository: ShortcutsRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
@@ -72,7 +69,6 @@ class HistoryListViewModel(
fun clearHistory() { fun clearHistory() {
launchLoadingJob { launchLoadingJob {
repository.clear() repository.clear()
shortcutsRepository.updateShortcuts()
} }
} }
@@ -81,10 +77,7 @@ class HistoryListViewModel(
return return
} }
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val handle = repository.deleteReversible(ids) + ReversibleHandle { val handle = repository.deleteReversible(ids)
shortcutsRepository.updateShortcuts()
}
shortcutsRepository.updateShortcuts()
onItemsRemoved.postCall(handle) onItemsRemoved.postCall(handle)
} }
} }

View File

@@ -68,7 +68,7 @@ abstract class MangaListFragment :
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = FragmentListBinding.inflate(inflater, container, false) ) = FragmentListBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -76,13 +76,13 @@ abstract class MangaListFragment :
listAdapter = MangaListAdapter( listAdapter = MangaListAdapter(
coil = get(), coil = get(),
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
listener = this, listener = this
) )
selectionController = ListSelectionController( selectionController = ListSelectionController(
activity = requireActivity(), activity = requireActivity(),
decoration = MangaSelectionDecoration(view.context), decoration = MangaSelectionDecoration(view.context),
registryOwner = this, registryOwner = this,
callback = this, callback = this
) )
paginationListener = PaginationScrollListener(4, this) paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) { with(binding.recyclerView) {
@@ -97,7 +97,7 @@ abstract class MangaListFragment :
setOnRefreshListener(this@MangaListFragment) setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled isEnabled = isSwipeRefreshEnabled
} }
addMenuProvider(MangaListMenuProvider(childFragmentManager)) addMenuProvider(MangaListMenuProvider(this))
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
@@ -171,21 +171,21 @@ abstract class MangaListFragment :
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
binding.root.updatePadding( binding.root.updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right
) )
if (activity is MainActivity) { if (activity is MainActivity) {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
top = headerHeight, top = headerHeight,
bottom = insets.bottom, bottom = insets.bottom
) )
binding.swipeRefreshLayout.setProgressViewOffset( binding.swipeRefreshLayout.setProgressViewOffset(
true, true,
headerHeight + resources.resolveDp(-72), headerHeight + resources.resolveDp(-72),
headerHeight + resources.resolveDp(10), headerHeight + resources.resolveDp(10)
) )
} else { } else {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
bottom = insets.bottom, bottom = insets.bottom
) )
} }
} }

View File

@@ -4,11 +4,11 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentManager import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
class MangaListMenuProvider( class MangaListMenuProvider(
private val fragmentManager: FragmentManager, private val fragment: Fragment,
) : MenuProvider { ) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -17,7 +17,7 @@ class MangaListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_list_mode -> { R.id.action_list_mode -> {
ListModeSelectDialog.show(fragmentManager) ListModeSelectDialog.show(fragment.childFragmentManager)
true true
} }
else -> false else -> false

View File

@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -16,6 +13,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver import org.koitharu.kotatsu.search.ui.multi.adapter.ItemSizeResolver
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
@@ -29,7 +27,6 @@ fun mangaGridItemAD(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }
) { ) {
var imageRequest: Disposable? = null
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
@@ -47,16 +44,15 @@ fun mangaGridItemAD(
bind { payloads -> bind { payloads ->
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
imageRequest?.dispose() binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl) referer(item.manga.publicUrl)
.referer(item.manga.publicUrl) placeholder(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) allowRgb565(true)
.allowRgb565(true) lifecycle(lifecycleOwner)
.scale(Scale.FILL) enqueueWith(coil)
.lifecycle(lifecycleOwner) }
.enqueueWith(coil)
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
} }
@@ -64,9 +60,6 @@ fun mangaGridItemAD(
itemView.clearBadge(badge) itemView.clearBadge(badge)
binding.progressView.percent = PROGRESS_NONE binding.progressView.percent = PROGRESS_NONE
badge = null badge = null
imageRequest?.dispose() binding.imageViewCover.disposeImageRequest()
imageRequest = null
CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null)
} }
} }

View File

@@ -2,9 +2,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import org.koitharu.kotatsu.utils.ext.*
import coil.size.Scale
import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -14,10 +12,6 @@ import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel import org.koitharu.kotatsu.list.ui.model.MangaListDetailedModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun mangaListDetailedItemAD( fun mangaListDetailedItemAD(
coil: ImageLoader, coil: ImageLoader,
@@ -27,7 +21,6 @@ fun mangaListDetailedItemAD(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }
) { ) {
var imageRequest: Disposable? = null
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
@@ -38,19 +31,18 @@ fun mangaListDetailedItemAD(
} }
bind { payloads -> bind { payloads ->
imageRequest?.dispose()
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle binding.textViewSubtitle.textAndVisible = item.subtitle
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl) binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
.referer(item.manga.publicUrl) referer(item.manga.publicUrl)
.placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.scale(Scale.FILL) allowRgb565(true)
.allowRgb565(true) lifecycle(lifecycleOwner)
.lifecycle(lifecycleOwner) enqueueWith(coil)
.enqueueWith(coil) }
binding.textViewRating.textAndVisible = item.rating binding.textViewRating.textAndVisible = item.rating
binding.textViewTags.text = item.tags binding.textViewTags.text = item.tags
itemView.bindBadge(badge, item.counter) itemView.bindBadge(badge, item.counter)
@@ -60,9 +52,6 @@ fun mangaListDetailedItemAD(
itemView.clearBadge(badge) itemView.clearBadge(badge)
binding.progressView.percent = PROGRESS_NONE binding.progressView.percent = PROGRESS_NONE
badge = null badge = null
imageRequest?.dispose() binding.imageViewCover.disposeImageRequest()
imageRequest = null
CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null)
} }
} }

View File

@@ -2,9 +2,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import org.koitharu.kotatsu.utils.ext.*
import coil.size.Scale
import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -13,10 +11,6 @@ import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun mangaListItemAD( fun mangaListItemAD(
coil: ImageLoader, coil: ImageLoader,
@@ -26,7 +20,6 @@ fun mangaListItemAD(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
) { ) {
var imageRequest: Disposable? = null
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
@@ -37,27 +30,23 @@ fun mangaListItemAD(
} }
bind { bind {
imageRequest?.dispose()
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle binding.textViewSubtitle.textAndVisible = item.subtitle
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl) binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
.referer(item.manga.publicUrl) referer(item.manga.publicUrl)
.placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.scale(Scale.FILL) allowRgb565(true)
.allowRgb565(true) lifecycle(lifecycleOwner)
.lifecycle(lifecycleOwner) enqueueWith(coil)
.enqueueWith(coil) }
itemView.bindBadge(badge, item.counter) itemView.bindBadge(badge, item.counter)
} }
onViewRecycled { onViewRecycled {
itemView.clearBadge(badge) itemView.clearBadge(badge)
badge = null badge = null
imageRequest?.dispose() binding.imageViewCover.disposeImageRequest()
imageRequest = null
CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null)
} }
} }

View File

@@ -16,5 +16,5 @@ val localModule
factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) } factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) }
viewModel { LocalListViewModel(get(), get(), get(), get()) } viewModel { LocalListViewModel(get(), get(), get()) }
} }

View File

@@ -11,7 +11,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
@@ -29,7 +28,6 @@ class LocalListViewModel(
private val repository: LocalMangaRepository, private val repository: LocalMangaRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
settings: AppSettings, settings: AppSettings,
private val shortcutsRepository: ShortcutsRepository,
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Unit>() val onMangaRemoved = SingleLiveEvent<Unit>()
@@ -107,7 +105,6 @@ class LocalListViewModel(
} }
} }
} }
shortcutsRepository.updateShortcuts()
onMangaRemoved.call(Unit) onMangaRemoved.call(Unit)
} }
} }

View File

@@ -1,19 +1,29 @@
package org.koitharu.kotatsu.main package org.koitharu.kotatsu.main
import android.app.Application
import android.os.Build
import androidx.room.InvalidationTracker
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.main.ui.MainViewModel import org.koitharu.kotatsu.main.ui.MainViewModel
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.main.ui.protect.ProtectViewModel import org.koitharu.kotatsu.main.ui.protect.ProtectViewModel
val mainModule val mainModule
get() = module { get() = module {
single { AppProtectHelper(get()) } single { AppProtectHelper(get()) } bind Application.ActivityLifecycleCallbacks::class
single { ActivityRecreationHandle() } single { ActivityRecreationHandle() } bind Application.ActivityLifecycleCallbacks::class
factory { ShortcutsRepository(androidContext(), get(), get(), get()) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
single { ShortcutsUpdater(androidContext(), get(), get(), get()) } bind InvalidationTracker.Observer::class
} else {
factory { ShortcutsUpdater(androidContext(), get(), get(), get()) }
}
viewModel { MainViewModel(get(), get()) } viewModel { MainViewModel(get(), get()) }
viewModel { ProtectViewModel(get(), get()) } viewModel { ProtectViewModel(get(), get()) }
} }

View File

@@ -23,7 +23,6 @@ val readerModule
preselectedBranch = params[2], preselectedBranch = params[2],
dataRepository = get(), dataRepository = get(),
historyRepository = get(), historyRepository = get(),
shortcutsRepository = get(),
settings = get(), settings = get(),
pageSaveHelper = get(), pageSaveHelper = get(),
bookmarksRepository = get(), bookmarksRepository = get(),

View File

@@ -166,10 +166,9 @@ class ReaderActivity :
} }
} }
R.id.action_save_page -> { R.id.action_save_page -> {
viewModel.getCurrentPage()?.also { page -> viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) val page = viewModel.getCurrentPage() ?: return false
viewModel.saveCurrentPage(page, savePageRequest) viewModel.saveCurrentPage(page, savePageRequest)
} ?: return false
} }
R.id.action_bookmark -> { R.id.action_bookmark -> {
if (viewModel.isBookmarkAdded.value == true) { if (viewModel.isBookmarkAdded.value == true) {
@@ -346,14 +345,14 @@ class ReaderActivity :
menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark) menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark)
} }
private fun onUiStateChanged(uiState: ReaderUiState, previous: ReaderUiState?) { private fun onUiStateChanged(uiState: ReaderUiState?, previous: ReaderUiState?) {
title = uiState.chapterName ?: uiState.mangaName ?: getString(R.string.loading_) title = uiState?.chapterName ?: uiState?.mangaName ?: getString(R.string.loading_)
supportActionBar?.subtitle = if (uiState.chapterNumber in 1..uiState.chaptersTotal) { supportActionBar?.subtitle = if (uiState != null && uiState.chapterNumber in 1..uiState.chaptersTotal) {
getString(R.string.chapter_d_of_d, uiState.chapterNumber, uiState.chaptersTotal) getString(R.string.chapter_d_of_d, uiState.chapterNumber, uiState.chaptersTotal)
} else { } else {
null null
} }
if (previous?.chapterName != null && uiState.chapterName != previous.chapterName) { if (uiState != null && previous?.chapterName != null && uiState.chapterName != previous.chapterName) {
if (!uiState.chapterName.isNullOrEmpty()) { if (!uiState.chapterName.isNullOrEmpty()) {
binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) binding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION)
} }

View File

@@ -8,7 +8,6 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.acra.ACRA
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.domain.MangaIntent
@@ -16,12 +15,11 @@ import org.koitharu.kotatsu.base.domain.MangaUtils
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.* import org.koitharu.kotatsu.core.prefs.*
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.parsers.exception.NotFoundException
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.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
@@ -33,7 +31,6 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.setCurrentManga
import java.util.* import java.util.*
private const val BOUNDS_PAGE_OFFSET = 2 private const val BOUNDS_PAGE_OFFSET = 2
@@ -46,7 +43,6 @@ class ReaderViewModel(
private val preselectedBranch: String?, private val preselectedBranch: String?,
private val dataRepository: MangaDataRepository, private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val shortcutsRepository: ShortcutsRepository,
private val bookmarksRepository: BookmarksRepository, private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val pageSaveHelper: PageSaveHelper, private val pageSaveHelper: PageSaveHelper,
@@ -75,7 +71,7 @@ class ReaderViewModel(
chapterNumber = chapter?.number ?: 0, chapterNumber = chapter?.number ?: 0,
chaptersTotal = chapters.size() chaptersTotal = chapters.size()
) )
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val content = MutableLiveData(ReaderContent(emptyList(), null)) val content = MutableLiveData(ReaderContent(emptyList(), null))
val manga: Manga? val manga: Manga?
@@ -93,7 +89,7 @@ class ReaderViewModel(
) { manga, policy -> ) { manga, policy ->
policy == ScreenshotsPolicy.BLOCK_ALL || policy == ScreenshotsPolicy.BLOCK_ALL ||
(policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw) (policy == ScreenshotsPolicy.BLOCK_NSFW && manga != null && manga.isNsfw)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
val onZoomChanged = SingleLiveEvent<Unit>() val onZoomChanged = SingleLiveEvent<Unit>()
@@ -105,7 +101,7 @@ class ReaderViewModel(
bookmarksRepository.observeBookmark(manga, state.chapterId, state.page) bookmarksRepository.observeBookmark(manga, state.chapterId, state.page)
.map { it != null } .map { it != null }
} }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
init { init {
loadImpl() loadImpl()
@@ -263,8 +259,7 @@ class ReaderViewModel(
private fun loadImpl() { private fun loadImpl() {
loadingJob = launchLoadingJob(Dispatchers.Default) { loadingJob = launchLoadingJob(Dispatchers.Default) {
var manga = dataRepository.resolveIntent(intent) ?: throw MangaNotFoundException("Cannot find manga") var manga = dataRepository.resolveIntent(intent) ?: throw NotFoundException("Cannot find manga", "")
ACRA.setCurrentManga(manga)
mangaData.value = manga mangaData.value = manga
val repo = MangaRepository(manga.source) val repo = MangaRepository(manga.source)
manga = repo.getDetails(manga) manga = repo.getDetails(manga)
@@ -289,7 +284,6 @@ class ReaderViewModel(
currentState.value?.let { currentState.value?.let {
val percent = computePercent(it.chapterId, it.page) val percent = computePercent(it.chapterId, it.page)
historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent) historyRepository.addOrUpdate(manga, it.chapterId, it.page, it.scroll, percent)
shortcutsRepository.updateShortcuts()
} }
content.postValue(ReaderContent(pages, currentState.value)) content.postValue(ReaderContent(pages, currentState.value))

View File

@@ -2,15 +2,13 @@ package org.koitharu.kotatsu.scrobbling.ui.selector.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable
import coil.size.Scale
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.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.databinding.ItemMangaListBinding
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblerManga
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
@@ -23,30 +21,24 @@ fun shikimoriMangaAD(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }
) { ) {
var imageRequest: Disposable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
clickListener.onItemClick(item, it) clickListener.onItemClick(item, it)
} }
bind { bind {
imageRequest?.dispose()
binding.textViewTitle.text = item.name binding.textViewTitle.text = item.name
binding.textViewSubtitle.textAndVisible = item.altName binding.textViewSubtitle.textAndVisible = item.altName
imageRequest = binding.imageViewCover.newImageRequest(item.cover) binding.imageViewCover.newImageRequest(item.cover)?.run {
.placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.scale(Scale.FILL) allowRgb565(true)
.allowRgb565(true) lifecycle(lifecycleOwner)
.lifecycle(lifecycleOwner) enqueueWith(coil)
.enqueueWith(coil) }
} }
onViewRecycled { onViewRecycled {
imageRequest?.dispose() binding.imageViewCover.disposeImageRequest()
imageRequest = null
CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null)
} }
} }

View File

@@ -89,26 +89,25 @@ class MultiSearchViewModel(
} }
} }
private suspend fun searchImpl(q: String) { private suspend fun searchImpl(q: String) = coroutineScope {
val sources = settings.getMangaSources(includeHidden = false) val sources = settings.getMangaSources(includeHidden = false)
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val deferredList = coroutineScope { val deferredList = sources.map { source ->
sources.map { source -> async(dispatcher) {
async(dispatcher) { runCatching {
runCatching { val list = MangaRepository(source).getList(offset = 0, query = q)
val list = MangaRepository(source).getList(offset = 0, query = q) .toUi(ListMode.GRID)
.toUi(ListMode.GRID) if (list.isNotEmpty()) {
if (list.isNotEmpty()) { MultiSearchListModel(source, list.size > MIN_HAS_MORE_ITEMS, list)
MultiSearchListModel(source, list.size > MIN_HAS_MORE_ITEMS, list) } else {
} else { null
null
}
}.onFailure {
it.printStackTraceDebug()
} }
}.onFailure {
it.printStackTraceDebug()
} }
} }
} }
val errors = ArrayList<Throwable>() val errors = ArrayList<Throwable>()
for (deferred in deferredList) { for (deferred in deferredList) {
deferred.await() deferred.await()
@@ -120,13 +119,12 @@ class MultiSearchViewModel(
errors.add(it) errors.add(it)
} }
} }
if (listData.value.isNotEmpty()) { if (listData.value.isEmpty()) {
return when (errors.size) {
} 0 -> Unit
when (errors.size) { 1 -> throw errors[0]
0 -> Unit else -> throw CompositeException(errors)
1 -> throw errors[0] }
else -> throw CompositeException(errors)
} }
} }
} }

View File

@@ -5,7 +5,6 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -16,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
@@ -52,27 +52,24 @@ private fun searchSuggestionMangaGridAD(
{ layoutInflater, parent -> ItemSearchSuggestionMangaGridBinding.inflate(layoutInflater, parent, false) } { layoutInflater, parent -> ItemSearchSuggestionMangaGridBinding.inflate(layoutInflater, parent, false) }
) { ) {
var imageRequest: Disposable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
listener.onMangaClick(item) listener.onMangaClick(item)
} }
bind { bind {
imageRequest?.dispose() binding.imageViewCover.newImageRequest(item.coverUrl)?.run {
imageRequest = binding.imageViewCover.newImageRequest(item.coverUrl) placeholder(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) allowRgb565(true)
.allowRgb565(true) lifecycle(lifecycleOwner)
.lifecycle(lifecycleOwner) enqueueWith(coil)
.enqueueWith(coil) }
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
} }
onViewRecycled { onViewRecycled {
imageRequest?.dispose() binding.imageViewCover.disposeImageRequest()
binding.imageViewCover.setImageDrawable(null)
} }
} }

View File

@@ -1,12 +1,14 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.room.InvalidationTracker
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.settings.backup.BackupViewModel import org.koitharu.kotatsu.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel import org.koitharu.kotatsu.settings.backup.RestoreViewModel
import org.koitharu.kotatsu.settings.newsources.NewSourcesViewModel import org.koitharu.kotatsu.settings.newsources.NewSourcesViewModel
@@ -17,8 +19,11 @@ import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
val settingsModule val settingsModule
get() = module { get() = module {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
single<InvalidationTracker.Observer> { BackupObserver(androidContext()) }
}
factory { BackupRepository(get()) } factory { BackupRepository(get()) }
factory { RestoreRepository(get()) }
single(createdAtStart = true) { AppSettings(androidContext()) } single(createdAtStart = true) { AppSettings(androidContext()) }
viewModel { BackupViewModel(get(), androidContext()) } viewModel { BackupViewModel(get(), androidContext()) }

View File

@@ -4,9 +4,14 @@ import android.app.backup.BackupAgent
import android.app.backup.BackupDataInput import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput import android.app.backup.BackupDataOutput
import android.app.backup.FullBackupDataOutput import android.app.backup.FullBackupDataOutput
import android.content.Context
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.core.backup.* import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import java.io.* import java.io.*
@@ -26,7 +31,7 @@ class AppBackupAgent : BackupAgent() {
override fun onFullBackup(data: FullBackupDataOutput) { override fun onFullBackup(data: FullBackupDataOutput) {
super.onFullBackup(data) super.onFullBackup(data)
val file = createBackupFile() val file = createBackupFile(this, BackupRepository(MangaDatabase(applicationContext)))
try { try {
fullBackupFile(file, data) fullBackupFile(file, data)
} finally { } finally {
@@ -43,16 +48,16 @@ class AppBackupAgent : BackupAgent() {
mtime: Long mtime: Long
) { ) {
if (destination?.name?.endsWith(".bk.zip") == true) { if (destination?.name?.endsWith(".bk.zip") == true) {
restoreBackupFile(data.fileDescriptor, size) restoreBackupFile(data.fileDescriptor, size, BackupRepository(MangaDatabase(applicationContext)))
destination.delete() destination.delete()
} else { } else {
super.onRestoreFile(data, size, destination, type, mode, mtime) super.onRestoreFile(data, size, destination, type, mode, mtime)
} }
} }
private fun createBackupFile() = runBlocking { @VisibleForTesting
val repository = BackupRepository(MangaDatabase(applicationContext)) fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking {
BackupZipOutput(this@AppBackupAgent).use { backup -> BackupZipOutput(context).use { backup ->
backup.put(repository.createIndex()) backup.put(repository.createIndex())
backup.put(repository.dumpHistory()) backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories()) backup.put(repository.dumpCategories())
@@ -62,8 +67,8 @@ class AppBackupAgent : BackupAgent() {
} }
} }
private fun restoreBackupFile(fd: FileDescriptor, size: Long) { @VisibleForTesting
val repository = RestoreRepository(MangaDatabase(applicationContext)) fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
val tempFile = File.createTempFile("backup_", ".tmp") val tempFile = File.createTempFile("backup_", ".tmp")
FileInputStream(fd).use { input -> FileInputStream(fd).use { input ->
tempFile.outputStream().use { output -> tempFile.outputStream().use { output ->
@@ -73,9 +78,9 @@ class AppBackupAgent : BackupAgent() {
val backup = BackupZipInput(tempFile) val backup = BackupZipInput(tempFile)
try { try {
runBlocking { runBlocking {
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY)) repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY))
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES)) repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES))
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES)) repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES))
} }
} finally { } finally {
backup.close() backup.close()

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.settings.backup
import android.app.backup.BackupManager
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.room.InvalidationTracker
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
@RequiresApi(Build.VERSION_CODES.M)
class BackupObserver(
context: Context,
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
private val backupManager = BackupManager(context)
override fun onInvalidated(tables: MutableSet<String>) {
backupManager.dataChanged()
}
}

View File

@@ -7,9 +7,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.backup.BackupEntry import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipInput import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File import java.io.File
@@ -17,7 +17,7 @@ import java.io.FileNotFoundException
class RestoreViewModel( class RestoreViewModel(
uri: Uri?, uri: Uri?,
private val repository: RestoreRepository, private val repository: BackupRepository,
context: Context context: Context
) : BaseViewModel() { ) : BaseViewModel() {
@@ -44,13 +44,13 @@ class RestoreViewModel(
val result = CompositeResult() val result = CompositeResult()
progress.value = Progress(0, 3) progress.value = Progress(0, 3)
result += repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY)) result += repository.restoreHistory(backup.getEntry(BackupEntry.HISTORY))
progress.value = Progress(1, 3) progress.value = Progress(1, 3)
result += repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES)) result += repository.restoreCategories(backup.getEntry(BackupEntry.CATEGORIES))
progress.value = Progress(2, 3) progress.value = Progress(2, 3)
result += repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES)) result += repository.restoreFavourites(backup.getEntry(BackupEntry.FAVOURITES))
progress.value = Progress(3, 3) progress.value = Progress(3, 3)
onRestoreDone.call(result) onRestoreDone.call(result)

View File

@@ -6,8 +6,6 @@ import android.view.View
import android.widget.CompoundButton import android.widget.CompoundButton
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -16,7 +14,9 @@ import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding import org.koitharu.kotatsu.databinding.ItemSourceConfigDraggableBinding
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
fun sourceConfigHeaderDelegate() = fun sourceConfigHeaderDelegate() =
@@ -54,8 +54,6 @@ fun sourceConfigItemDelegate(
on = { item, _, _ -> item is SourceConfigItem.SourceItem && !item.isDraggable } on = { item, _, _ -> item is SourceConfigItem.SourceItem && !item.isDraggable }
) { ) {
var imageRequest: Disposable? = null
binding.switchToggle.setOnCheckedChangeListener { _, isChecked -> binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked) listener.onItemEnabledChanged(item, isChecked)
} }
@@ -64,17 +62,15 @@ fun sourceConfigItemDelegate(
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
binding.switchToggle.isChecked = item.isEnabled binding.switchToggle.isChecked = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary binding.textViewDescription.textAndVisible = item.summary
imageRequest = ImageRequest.Builder(context) binding.imageViewIcon.newImageRequest(item.faviconUrl)?.run {
.data(item.faviconUrl) error(R.drawable.ic_favicon_fallback)
.error(R.drawable.ic_favicon_fallback) lifecycle(lifecycleOwner)
.target(binding.imageViewIcon) enqueueWith(coil)
.lifecycle(lifecycleOwner) }
.enqueueWith(coil)
} }
onViewRecycled { onViewRecycled {
imageRequest?.dispose() binding.imageViewIcon.disposeImageRequest()
imageRequest = null
} }
} }

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.room.withTransaction import androidx.room.withTransaction
import java.util.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -19,6 +18,7 @@ import org.koitharu.kotatsu.tracker.data.toTrackingLogItem
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.*
private const val NO_ID = 0L private const val NO_ID = 0L
@@ -113,7 +113,7 @@ class TrackingRepository(
newChapters = when { newChapters = when {
track.newChapters == 0 -> 0 track.newChapters == 0 -> 0
chapterIndex < 0 -> track.newChapters chapterIndex < 0 -> track.newChapters
chapterIndex > lastNewChapterIndex -> chapters.lastIndex - chapterIndex chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex
else -> track.newChapters else -> track.newChapters
}, },
lastCheck = System.currentTimeMillis(), lastCheck = System.currentTimeMillis(),

View File

@@ -2,8 +2,6 @@ package org.koitharu.kotatsu.tracker.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable
import coil.size.Scale
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.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -11,6 +9,7 @@ import org.koitharu.kotatsu.databinding.ItemFeedBinding
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 org.koitharu.kotatsu.tracker.ui.model.FeedItem import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
@@ -22,22 +21,19 @@ fun feedItemAD(
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) }
) { ) {
var imageRequest: Disposable? = null
itemView.setOnClickListener { itemView.setOnClickListener {
clickListener.onItemClick(item.manga, it) clickListener.onItemClick(item.manga, it)
} }
bind { bind {
imageRequest?.dispose() binding.imageViewCover.newImageRequest(item.imageUrl)?.run {
imageRequest = binding.imageViewCover.newImageRequest(item.imageUrl) placeholder(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) error(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) allowRgb565(true)
.allowRgb565(true) lifecycle(lifecycleOwner)
.scale(Scale.FILL) enqueueWith(coil)
.lifecycle(lifecycleOwner) }
.enqueueWith(coil)
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewSummary.text = context.resources.getQuantityString( binding.textViewSummary.text = context.resources.getQuantityString(
R.plurals.new_chapters, R.plurals.new_chapters,
@@ -47,7 +43,6 @@ fun feedItemAD(
} }
onViewRecycled { onViewRecycled {
imageRequest?.dispose() binding.imageViewCover.disposeImageRequest()
binding.imageViewCover.setImageDrawable(null)
} }
} }

View File

@@ -7,14 +7,26 @@ import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.request.ImageResult import coil.request.ImageResult
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
fun ImageView.newImageRequest(url: String?) = ImageRequest.Builder(context) fun ImageView.newImageRequest(url: Any?): ImageRequest.Builder? {
.data(url) val current = CoilUtils.result(this)
.crossfade(true) if (current != null && current.request.data == url) {
.target(this) return null
}
return ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.target(this)
}
fun ImageView.disposeImageRequest() {
CoilUtils.dispose(this)
setImageDrawable(null)
}
fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())

View File

@@ -3,19 +3,16 @@ package org.koitharu.kotatsu.utils.ext
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.res.Resources import android.content.res.Resources
import okio.FileNotFoundException import okio.FileNotFoundException
import org.acra.ACRA
import org.acra.ktx.sendWithAcra import org.acra.ktx.sendWithAcra
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.*
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Manga
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources) = when (this) { fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required) is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required) is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is ActivityNotFoundException, is ActivityNotFoundException,
@@ -23,22 +20,22 @@ fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
is FileNotFoundException -> resources.getString(R.string.file_not_found) is FileNotFoundException -> resources.getString(R.string.file_not_found)
is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
is ContentUnavailableException -> message
is ParseException -> shortMessage
is SocketTimeoutException -> resources.getString(R.string.network_error) is SocketTimeoutException -> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password) is WrongPasswordException -> resources.getString(R.string.wrong_password)
else -> localizedMessage ?: resources.getString(R.string.error_occurred) is NotFoundException -> resources.getString(R.string.not_found_404)
} else -> localizedMessage
} ?: resources.getString(R.string.error_occurred)
fun Throwable.isReportable(): Boolean { fun Throwable.isReportable(): Boolean {
if (this !is Exception) { if (this !is Exception) {
return true return true
} }
return this is ParseException || this is IllegalArgumentException || this is IllegalStateException return this is ParseException || this is IllegalArgumentException ||
this is IllegalStateException || this.javaClass == RuntimeException::class.java
} }
fun Throwable.report(message: String?) { fun Throwable.report(message: String?) {
CaughtException(this, message).sendWithAcra() CaughtException(this, message).sendWithAcra()
} }
fun ACRA.setCurrentManga(manga: Manga?) = errorReporter.putCustomData("manga", manga?.publicUrl.toString())
private class CaughtException(cause: Throwable, override val message: String?) : RuntimeException(cause)

View File

@@ -1,10 +1,15 @@
package org.koitharu.kotatsu.widget package org.koitharu.kotatsu.widget
import androidx.room.InvalidationTracker
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.widget.shelf.ShelfConfigViewModel import org.koitharu.kotatsu.widget.shelf.ShelfConfigViewModel
val appWidgetModule val appWidgetModule
get() = module { get() = module {
single<InvalidationTracker.Observer> { WidgetUpdater(androidContext()) }
viewModel { ShelfConfigViewModel(get()) } viewModel { ShelfConfigViewModel(get()) }
} }

View File

@@ -4,36 +4,24 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import kotlinx.coroutines.CancellationException import androidx.room.InvalidationTracker
import kotlinx.coroutines.Dispatchers import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import kotlinx.coroutines.flow.launchIn import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider
import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider
class WidgetUpdater(private val context: Context) { class WidgetUpdater(private val context: Context) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) {
fun subscribeToFavourites(repository: FavouritesRepository) { override fun onInvalidated(tables: MutableSet<String>) {
repository.observeAll(SortOrder.NEWEST) if (TABLE_HISTORY in tables) {
.onEach { updateWidget(ShelfWidgetProvider::class.java) } updateWidgets(RecentWidgetProvider::class.java)
.retry { error -> error !is CancellationException } }
.launchIn(processLifecycleScope + Dispatchers.Default) if (TABLE_FAVOURITES in tables) {
updateWidgets(ShelfWidgetProvider::class.java)
}
} }
fun subscribeToHistory(repository: HistoryRepository) { private fun updateWidgets(cls: Class<*>) {
repository.observeAll()
.onEach { updateWidget(RecentWidgetProvider::class.java) }
.retry { error -> error !is CancellationException }
.launchIn(processLifecycleScope + Dispatchers.Default)
}
private fun updateWidget(cls: Class<*>) {
val intent = Intent(context, cls) val intent = Intent(context, cls)
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
val ids = AppWidgetManager.getInstance(context) val ids = AppWidgetManager.getInstance(context)

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M3 16H10V14H3M18 14V10H16V14H12V16H16V20H18V16H22V14M14 6H3V8H14M14 10H3V12H14V10Z" />
</vector>

View File

@@ -0,0 +1,15 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M4,3H5V5H3V4A1,1 0,0 1,4 3M20,3A1,1 0,0 1,21 4V5H19V3H20M15,5V3H17V5H15M11,5V3H13V5H11M7,5V3H9V5H7M21,20A1,1 0,0 1,20 21H19V19H21V20M15,21V19H17V21H15M11,21V19H13V21H11M7,21V19H9V21H7M4,21A1,1 0,0 1,3 20V19H5V21H4M3,15H5V17H3V15M21,15V17H19V15H21M3,11H5V13H3V11M21,11V13H19V11H21M3,7H5V9H3V7M21,7V9H19V7H21Z" />
<path
android:fillColor="#FF000000"
android:pathData="M8.687,5.585L8.687,9.514L6.201,9.514L9.514,12.828 12.828,9.514L10.345,9.514L10.345,5.585ZM14.486,11.172 L11.172,14.486h2.483v3.929h1.658v-3.929h2.486z"
android:strokeWidth="0.828309" />
</vector>

View File

@@ -217,7 +217,7 @@
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks" app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <org.koitharu.kotatsu.base.ui.widgets.SelectableTextView
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -9,7 +9,7 @@
<com.google.android.material.navigation.NavigationView <com.google.android.material.navigation.NavigationView
android:id="@+id/navigationView" android:id="@+id/navigationView"
android:layout_width="260dp" android:layout_width="230dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fitsSystemWindows="false" android:fitsSystemWindows="false"
app:drawerLayoutCornerSize="0dp" app:drawerLayoutCornerSize="0dp"
@@ -91,4 +91,4 @@
tools:visibility="visible" /> tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</LinearLayout> </LinearLayout>

View File

@@ -12,6 +12,7 @@
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
app:menu="@menu/opt_categories"
app:title="@string/add_to_favourites"> app:title="@string/add_to_favourites">
<Button <Button
@@ -35,15 +36,4 @@
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_checkable_new" /> tools:listitem="@layout/item_checkable_new" />
<TextView
android:id="@+id/item_create"
style="?listItemTextViewStyle"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?selectableItemBackground"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/create_category"
android:textAppearance="?attr/textAppearanceButton" />
</LinearLayout> </LinearLayout>

View File

@@ -40,8 +40,6 @@
android:maxLines="4" android:maxLines="4"
android:padding="@dimen/margin_normal" android:padding="@dimen/margin_normal"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textAppearance="@style/TextAppearance.Design.Snackbar.Message"
android:textColor="@android:color/white"
tools:text="Look at all the wonderful snack bar text..." /> tools:text="Look at all the wonderful snack bar text..." />
<Button <Button
@@ -53,7 +51,6 @@
android:paddingStart="@dimen/margin_normal" android:paddingStart="@dimen/margin_normal"
android:paddingEnd="@dimen/margin_normal" android:paddingEnd="@dimen/margin_normal"
android:visibility="gone" android:visibility="gone"
tools:targetApi="o"
tools:text="Action" tools:text="Action"
tools:visibility="visible" /> tools:visibility="visible" />

View File

@@ -214,7 +214,7 @@
app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks" app:layout_constraintTop_toBottomOf="@id/recyclerView_bookmarks"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <org.koitharu.kotatsu.base.ui.widgets.SelectableTextView
android:id="@+id/textView_description" android:id="@+id/textView_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -15,6 +15,12 @@
android:title="@string/delete" android:title="@string/delete"
app:showAsAction="ifRoom|withText" /> app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_select_range"
android:icon="@drawable/ic_select_range"
android:title="@string/select_range"
app:showAsAction="ifRoom|withText" />
<item <item
android:id="@+id/action_select_all" android:id="@+id/action_select_all"
android:icon="?actionModeSelectAllDrawable" android:icon="?actionModeSelectAllDrawable"

View File

@@ -0,0 +1,12 @@
<?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_create"
android:icon="@drawable/ic_list_create"
android:title="@string/create_category"
app:showAsAction="always" />
</menu>

View File

@@ -314,4 +314,8 @@
<string name="status_completed">Abgeschlossen</string> <string name="status_completed">Abgeschlossen</string>
<string name="exclude_nsfw_from_history_summary">Manga, die als NSFW markiert sind, werden nicht in den Verlauf aufgenommen und Ihr Fortschritt wird nicht gespeichert.</string> <string name="exclude_nsfw_from_history_summary">Manga, die als NSFW markiert sind, werden nicht in den Verlauf aufgenommen und Ihr Fortschritt wird nicht gespeichert.</string>
<string name="data_deletion">Datenlöschung</string> <string name="data_deletion">Datenlöschung</string>
<string name="invalid_domain_message">Ungültige Domäne</string>
<string name="status_reading">Lesen</string>
<string name="select_range">Bereich auswählen</string>
<string name="not_found_404">Inhalt nicht gefunden oder entfernt</string>
</resources> </resources>

View File

@@ -317,4 +317,5 @@
<string name="exclude_nsfw_from_history_summary">El manga marcado como NSFW nunca se añadirá al historial y no se guardará tu progreso</string> <string name="exclude_nsfw_from_history_summary">El manga marcado como NSFW nunca se añadirá al historial y no se guardará tu progreso</string>
<string name="clear_cookies_summary">Puede ayudar en caso de algunos problemas. Todas las autorizaciones serán invalidadas</string> <string name="clear_cookies_summary">Puede ayudar en caso de algunos problemas. Todas las autorizaciones serán invalidadas</string>
<string name="show_all">Mostrar todo</string> <string name="show_all">Mostrar todo</string>
<string name="invalid_domain_message">Dominio no válido</string>
</resources> </resources>

View File

@@ -308,4 +308,6 @@
<string name="status_re_reading">Lukemassa uudelleen</string> <string name="status_re_reading">Lukemassa uudelleen</string>
<string name="data_deletion">Tietojen poistaminen</string> <string name="data_deletion">Tietojen poistaminen</string>
<string name="show_all">Näytä kaikki</string> <string name="show_all">Näytä kaikki</string>
<string name="select_range">Valitse alue</string>
<string name="not_found_404">Sisältöä ei löydy tai se on poistettu</string>
</resources> </resources>

View File

@@ -317,4 +317,7 @@
<string name="logout">Se déconnecter</string> <string name="logout">Se déconnecter</string>
<string name="status_completed">Terminé</string> <string name="status_completed">Terminé</string>
<string name="status_re_reading">Relecture</string> <string name="status_re_reading">Relecture</string>
<string name="invalid_domain_message">Domaine invalide</string>
<string name="select_range">Sélectionner une plage</string>
<string name="not_found_404">Contenu non trouvé ou supprimé</string>
</resources> </resources>

View File

@@ -317,4 +317,7 @@
<string name="status_planned">Pianificato</string> <string name="status_planned">Pianificato</string>
<string name="status_completed">Finito</string> <string name="status_completed">Finito</string>
<string name="status_dropped">Abbandonato</string> <string name="status_dropped">Abbandonato</string>
<string name="invalid_domain_message">Dominio non valido</string>
<string name="select_range">Seleziona l\'intervallo</string>
<string name="not_found_404">Contenuto non trovato o rimosso</string>
</resources> </resources>

View File

@@ -317,4 +317,7 @@
<string name="show_reading_indicators">読書の進行状況インジケーターを表示</string> <string name="show_reading_indicators">読書の進行状況インジケーターを表示</string>
<string name="exclude_nsfw_from_history_summary">NSFWとマークされたマンガは履歴に追加されず、進行状況も保存されない</string> <string name="exclude_nsfw_from_history_summary">NSFWとマークされたマンガは履歴に追加されず、進行状況も保存されない</string>
<string name="show_all">すべて表示</string> <string name="show_all">すべて表示</string>
<string name="invalid_domain_message">無効なドメイン</string>
<string name="select_range">範囲を選択</string>
<string name="not_found_404">コンテンツが見つからない、または削除された</string>
</resources> </resources>

View File

@@ -317,4 +317,7 @@
<string name="status_planned">Planlandı</string> <string name="status_planned">Planlandı</string>
<string name="status_re_reading">Yeniden okunuyor</string> <string name="status_re_reading">Yeniden okunuyor</string>
<string name="show_all">Tümünü göster</string> <string name="show_all">Tümünü göster</string>
<string name="invalid_domain_message">Geçersiz etki alanı</string>
<string name="select_range">Aralık seç</string>
<string name="not_found_404">İçerik bulunamadı veya kaldırıldı</string>
</resources> </resources>

View File

@@ -1,305 +1,323 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<string name="wait_for_loading_finish">Дочекайтеся завершення завантаження…</string> <string name="wait_for_loading_finish">Дочекайтеся завершення завантаження…</string>
<string name="delete">Видалити</string> <string name="delete">Видалити</string>
<string name="nothing_found">Нічого не знайдено</string> <string name="nothing_found">Нічого не знайдено</string>
<string name="add_to_favourites">Додати до улюблених</string> <string name="add_to_favourites">Додати до улюблених</string>
<string name="clear_history">Очистити історію</string> <string name="clear_history">Очистити історію</string>
<string name="history_is_empty">Історії ще немає</string> <string name="history_is_empty">Історії ще немає</string>
<string name="add">Додати</string> <string name="add">Додати</string>
<string name="save">Зберегти</string> <string name="save">Зберегти</string>
<string name="local_storage">Локальне сховище</string> <string name="local_storage">Локальне сховище</string>
<string name="network_error">Не вдалося підключитися до Інтернету</string> <string name="network_error">Не вдалося підключитися до Інтернету</string>
<string name="details">Деталі</string> <string name="details">Деталі</string>
<string name="try_again">Спробуйте ще раз</string> <string name="try_again">Спробуйте ще раз</string>
<string name="open_menu">Відкрити меню</string> <string name="open_menu">Відкрити меню</string>
<string name="you_have_not_favourites_yet">Улюблених ще немає</string> <string name="you_have_not_favourites_yet">Улюблених ще немає</string>
<string name="add_new_category">Нова категорія</string> <string name="add_new_category">Нова категорія</string>
<string name="enter_category_name">Введіть назву категорії</string> <string name="enter_category_name">Введіть назву категорії</string>
<string name="download_complete">Завантажено</string> <string name="download_complete">Завантажено</string>
<string name="favourites">Уподобання</string> <string name="favourites">Уподобання</string>
<string name="history">Історія</string> <string name="history">Історія</string>
<string name="error_occurred">Сталася помилка</string> <string name="error_occurred">Сталася помилка</string>
<string name="chapters">Розділи</string> <string name="chapters">Розділи</string>
<string name="list">Список</string> <string name="list">Список</string>
<string name="detailed_list">Детальний список</string> <string name="detailed_list">Детальний список</string>
<string name="list_mode">Режим списку</string> <string name="list_mode">Режим списку</string>
<string name="settings">Налаштування</string> <string name="settings">Налаштування</string>
<string name="remote_sources">Віддалені джерела</string> <string name="remote_sources">Віддалені джерела</string>
<string name="loading_">Завантаження…</string> <string name="loading_">Завантаження…</string>
<string name="computing_">Обчислення…</string> <string name="computing_">Обчислення…</string>
<string name="chapter_d_of_d">Розділ %1$d із %2$d</string> <string name="chapter_d_of_d">Розділ %1$d із %2$d</string>
<string name="close">Закрити</string> <string name="close">Закрити</string>
<string name="read">Читати</string> <string name="read">Читати</string>
<string name="grid">Таблиця</string> <string name="grid">Таблиця</string>
<string name="share">Поділитися</string> <string name="share">Поділитися</string>
<string name="create_shortcut">Створити ярлик…</string> <string name="create_shortcut">Створити ярлик…</string>
<string name="share_s">Поділитися %s</string> <string name="share_s">Поділитися %s</string>
<string name="search">Пошук</string> <string name="search">Пошук</string>
<string name="search_manga">Пошук манґи</string> <string name="search_manga">Пошук манґи</string>
<string name="processing_">Обробка…</string> <string name="processing_">Обробка…</string>
<string name="by_name">Ім\'я</string> <string name="by_name">Ім\'я</string>
<string name="popular">Популярна</string> <string name="popular">Популярна</string>
<string name="updated">Оновлена</string> <string name="updated">Оновлена</string>
<string name="newest">Нова</string> <string name="newest">Нова</string>
<string name="by_rating">Рейтинг</string> <string name="by_rating">Рейтинг</string>
<string name="sort_order">Порядок сортування</string> <string name="sort_order">Порядок сортування</string>
<string name="filter">Фільтр</string> <string name="filter">Фільтр</string>
<string name="theme">Тема</string> <string name="theme">Тема</string>
<string name="light">Світла</string> <string name="light">Світла</string>
<string name="dark">Темна</string> <string name="dark">Темна</string>
<string name="pages">Сторінки</string> <string name="pages">Сторінки</string>
<string name="text_clear_history_prompt">Очистити всю історію читання перманентно\?</string> <string name="text_clear_history_prompt">Очистити всю історію читання перманентно\?</string>
<string name="remove">Видалити</string> <string name="remove">Видалити</string>
<string name="_s_removed_from_history">\"%s\" видалено з історії</string> <string name="_s_removed_from_history">\"%s\" видалено з історії</string>
<string name="_s_deleted_from_local_storage">\"%s\" видалено з локального сховища</string> <string name="_s_deleted_from_local_storage">\"%s\" видалено з локального сховища</string>
<string name="save_page">Зберегти сторінку</string> <string name="save_page">Зберегти сторінку</string>
<string name="page_saved">Збережено</string> <string name="page_saved">Збережено</string>
<string name="share_image">Поділитись зображенням</string> <string name="share_image">Поділитись зображенням</string>
<string name="operation_not_supported">Ця операція не підтримується</string> <string name="operation_not_supported">Ця операція не підтримується</string>
<string name="text_file_not_supported">Виберіть файл ZIP або CBZ.</string> <string name="text_file_not_supported">Виберіть файл ZIP або CBZ.</string>
<string name="no_description">Немає опису</string> <string name="no_description">Немає опису</string>
<string name="history_and_cache">Історія та кеш</string> <string name="history_and_cache">Історія та кеш</string>
<string name="clear_pages_cache">Очистити кеш сторінок</string> <string name="clear_pages_cache">Очистити кеш сторінок</string>
<string name="cache">Кеш</string> <string name="cache">Кеш</string>
<string name="text_file_sizes">Б|кБ|МБ|ГБ|ТБ</string> <string name="text_file_sizes">Б|кБ|МБ|ГБ|ТБ</string>
<string name="standard">Стандартний</string> <string name="standard">Стандартний</string>
<string name="webtoon">Вебтун</string> <string name="webtoon">Вебтун</string>
<string name="read_mode">Режим читання</string> <string name="read_mode">Режим читання</string>
<string name="grid_size">Розмір сітки</string> <string name="grid_size">Розмір сітки</string>
<string name="search_on_s">Пошук по %s</string> <string name="search_on_s">Пошук по %s</string>
<string name="delete_manga">Видалити манґу</string> <string name="delete_manga">Видалити манґу</string>
<string name="text_delete_local_manga">Видалити \"%s\" з пристрою перманентно\?</string> <string name="text_delete_local_manga">Видалити \"%s\" з пристрою перманентно\?</string>
<string name="reader_settings">Налаштування читача</string> <string name="reader_settings">Налаштування читача</string>
<string name="switch_pages">Перегортання сторінок</string> <string name="switch_pages">Перегортання сторінок</string>
<string name="volume_buttons">Кнопки гучності</string> <string name="volume_buttons">Кнопки гучності</string>
<string name="cancelling_">Скасування…</string> <string name="cancelling_">Скасування…</string>
<string name="error">Помилка</string> <string name="error">Помилка</string>
<string name="clear_thumbs_cache">Очистити кеш мініатюр</string> <string name="clear_thumbs_cache">Очистити кеш мініатюр</string>
<string name="clear_search_history">Очистити історію пошуку</string> <string name="clear_search_history">Очистити історію пошуку</string>
<string name="search_history_cleared">Очищено</string> <string name="search_history_cleared">Очищено</string>
<string name="gestures_only">Тільки жести</string> <string name="gestures_only">Тільки жести</string>
<string name="internal_storage">Внутрішнє сховище</string> <string name="internal_storage">Внутрішнє сховище</string>
<string name="external_storage">Зовнішнє сховище</string> <string name="external_storage">Зовнішнє сховище</string>
<string name="domain">Домен</string> <string name="domain">Домен</string>
<string name="application_update">Перевірити наявність нових версій додатка</string> <string name="application_update">Перевірити наявність нових версій додатка</string>
<string name="app_update_available">Доступна нова версія додатка</string> <string name="app_update_available">Доступна нова версія додатка</string>
<string name="large_manga_save_confirm">Ця манґа має %s. Зберегти все це\?</string> <string name="large_manga_save_confirm">Ця манґа має %s. Зберегти все це\?</string>
<string name="save_manga">Зберегти</string> <string name="save_manga">Зберегти</string>
<string name="notifications">Сповіщення</string> <string name="notifications">Сповіщення</string>
<string name="enabled_d_of_d" tools:ignore="PluralsCandidate">Увімкнено %1$d з %2$d</string> <string name="enabled_d_of_d" tools:ignore="PluralsCandidate">Увімкнено %1$d з %2$d</string>
<string name="new_chapters">Нові розділи</string> <string name="new_chapters">Нові розділи</string>
<string name="download">Завантажити</string> <string name="download">Завантажити</string>
<string name="read_from_start">Читати з початку</string> <string name="read_from_start">Читати з початку</string>
<string name="restart">Перезавантажити</string> <string name="restart">Перезавантажити</string>
<string name="vibration">Вібрація</string> <string name="vibration">Вібрація</string>
<string name="favourites_categories">Улюблені категорії</string> <string name="favourites_categories">Улюблені категорії</string>
<string name="category_delete_confirm">Вилучити категорію \"%s\" зі своїх уподобань\? <string name="category_delete_confirm">Вилучити категорію \"%s\" зі своїх уподобань\?
\nВся манґа в ній буде втрачена.</string> \nВся манґа в ній буде втрачена.</string>
<string name="remove_category">Видалити</string> <string name="remove_category">Видалити</string>
<string name="text_empty_holder_primary">Тут якось пусто…</string> <string name="text_empty_holder_primary">Тут якось пусто…</string>
<string name="text_search_holder_secondary">Спробуйте переформулювати запит.</string> <string name="text_search_holder_secondary">Спробуйте переформулювати запит.</string>
<string name="text_history_holder_primary">Те, що ви читаєте, буде показано тут</string> <string name="text_history_holder_primary">Те, що ви читаєте, буде показано тут</string>
<string name="text_history_holder_secondary">Знайдіть, що читати, у бічному меню.</string> <string name="text_history_holder_secondary">Знайдіть, що читати, у бічному меню.</string>
<string name="text_local_holder_primary">Спочатку збережіть щось</string> <string name="text_local_holder_primary">Спочатку збережіть щось</string>
<string name="text_local_holder_secondary">Збережіть його з онлайн-джерела або імпортуйте файли.</string> <string name="text_local_holder_secondary">Збережіть його з онлайн-джерела або імпортуйте файли.</string>
<string name="manga_shelf">Полиця</string> <string name="manga_shelf">Полиця</string>
<string name="recent_manga">Недавні</string> <string name="recent_manga">Недавні</string>
<string name="pages_animation">Анімація перегортання</string> <string name="pages_animation">Анімація перегортання</string>
<string name="manga_save_location">Тека для завантажень</string> <string name="manga_save_location">Тека для завантажень</string>
<string name="other_storage">Інше сховище</string> <string name="other_storage">Інше сховище</string>
<string name="done">Готово</string> <string name="done">Готово</string>
<string name="all_favourites">Усі улюблені</string> <string name="all_favourites">Усі улюблені</string>
<string name="favourites_category_empty">Порожня категорія</string> <string name="favourites_category_empty">Порожня категорія</string>
<string name="read_later">Прочитати пізніше</string> <string name="read_later">Прочитати пізніше</string>
<string name="updates">Оновлення</string> <string name="updates">Оновлення</string>
<string name="related">Схожі</string> <string name="related">Схожі</string>
<string name="new_version_s">Нова версія: %s</string> <string name="new_version_s">Нова версія: %s</string>
<string name="size_s">Розмір: %s</string> <string name="size_s">Розмір: %s</string>
<string name="waiting_for_network">Очікування мережі…</string> <string name="waiting_for_network">Очікування мережі…</string>
<string name="clear_updates_feed">Очистити стрічку оновлень</string> <string name="clear_updates_feed">Очистити стрічку оновлень</string>
<string name="updates_feed_cleared">Очищено</string> <string name="updates_feed_cleared">Очищено</string>
<string name="rotate_screen">Повернути екран</string> <string name="rotate_screen">Повернути екран</string>
<string name="update">Оновити</string> <string name="update">Оновити</string>
<string name="feed_will_update_soon">Оновлення скоро почнеться</string> <string name="feed_will_update_soon">Оновлення скоро почнеться</string>
<string name="track_sources">Стежити за оновленнями</string> <string name="track_sources">Стежити за оновленнями</string>
<string name="dont_check">Не перевіряти</string> <string name="dont_check">Не перевіряти</string>
<string name="wrong_password">Неправильний пароль</string> <string name="wrong_password">Неправильний пароль</string>
<string name="protect_application">Захистити додаток</string> <string name="protect_application">Захистити додаток</string>
<string name="protect_application_summary">Запитувати пароль під час запуску Kotatsu</string> <string name="protect_application_summary">Запитувати пароль під час запуску Kotatsu</string>
<string name="repeat_password">Повторіть пароль</string> <string name="repeat_password">Повторіть пароль</string>
<string name="passwords_mismatch">Паролі не співпадають</string> <string name="passwords_mismatch">Паролі не співпадають</string>
<string name="about">Про програму</string> <string name="about">Про програму</string>
<string name="app_version">Версія %s</string> <string name="app_version">Версія %s</string>
<string name="check_for_updates">Перевірити наявність оновлень</string> <string name="check_for_updates">Перевірити наявність оновлень</string>
<string name="checking_for_updates">Перевірка наявності оновлень…</string> <string name="checking_for_updates">Перевірка наявності оновлень…</string>
<string name="update_check_failed">Не вдалося перевірити оновлення</string> <string name="update_check_failed">Не вдалося перевірити оновлення</string>
<string name="no_update_available">Немає доступних оновлень</string> <string name="no_update_available">Немає доступних оновлень</string>
<string name="create_category">Нова категорія</string> <string name="create_category">Нова категорія</string>
<string name="scale_mode">Режим масштабування</string> <string name="scale_mode">Режим масштабування</string>
<string name="zoom_mode_fit_center">Вмістити в екран</string> <string name="zoom_mode_fit_center">Вмістити в екран</string>
<string name="zoom_mode_fit_height">Підігнати по висоті</string> <string name="zoom_mode_fit_height">Підігнати по висоті</string>
<string name="zoom_mode_fit_width">Підігнати по ширині</string> <string name="zoom_mode_fit_width">Підігнати по ширині</string>
<string name="zoom_mode_keep_start">Вихідний розмір</string> <string name="zoom_mode_keep_start">Вихідний розмір</string>
<string name="black_dark_theme">Чорна</string> <string name="black_dark_theme">Чорна</string>
<string name="black_dark_theme_summary">Споживає менше енергії на екранах AMOLED</string> <string name="black_dark_theme_summary">Споживає менше енергії на екранах AMOLED</string>
<string name="backup_restore">Резервне копіювання та відновлення</string> <string name="backup_restore">Резервне копіювання та відновлення</string>
<string name="data_restored">Відновлено</string> <string name="data_restored">Відновлено</string>
<string name="preparing_">Підготовка…</string> <string name="preparing_">Підготовка…</string>
<string name="report_github">Створити проблему на GitHub</string> <string name="report_github">Створити проблему на GitHub</string>
<string name="file_not_found">Файл не знайдено</string> <string name="file_not_found">Файл не знайдено</string>
<string name="data_restored_with_errors">Дані відновлено, але є деякі помилки</string> <string name="data_restored_with_errors">Дані відновлено, але є деякі помилки</string>
<string name="backup_information">Ви можете створити резервну копію своєї історії та уподобань і відновити їх</string> <string name="backup_information">Ви можете створити резервну копію своєї історії та уподобань і відновити їх</string>
<string name="just_now">Тільки що</string> <string name="just_now">Тільки що</string>
<string name="tap_to_try_again">Торкніться, щоб спробувати ще раз</string> <string name="tap_to_try_again">Торкніться, щоб спробувати ще раз</string>
<string name="reader_mode_hint">Обраний режим буде запам\'ятован для цієї манги</string> <string name="reader_mode_hint">Обраний режим буде запам\'ятован для цієї манги</string>
<string name="captcha_required">Потрібна CAPTCHA</string> <string name="captcha_required">Потрібна CAPTCHA</string>
<string name="captcha_solve">Пройти</string> <string name="captcha_solve">Пройти</string>
<string name="clear_cookies">Очистити кукі</string> <string name="clear_cookies">Очистити кукі</string>
<string name="cookies_cleared">Всі кукі були видалені</string> <string name="cookies_cleared">Всі кукі були видалені</string>
<string name="clear_feed">Очистити стрічку</string> <string name="clear_feed">Очистити стрічку</string>
<string name="check_for_new_chapters">Перевірити нові розділи</string> <string name="check_for_new_chapters">Перевірити нові розділи</string>
<string name="reverse">В зворотньому порядку</string> <string name="reverse">В зворотньому порядку</string>
<string name="sign_in">Увійти</string> <string name="sign_in">Увійти</string>
<string name="auth_required">Увійдіть, щоб переглянути цей вміст</string> <string name="auth_required">Увійдіть, щоб переглянути цей вміст</string>
<string name="default_s">За замовчуванням: %s</string> <string name="default_s">За замовчуванням: %s</string>
<string name="_and_x_more">…і ще %1$d</string> <string name="_and_x_more">…і ще %1$d</string>
<string name="next">Далі</string> <string name="next">Далі</string>
<string name="protect_application_subtitle">Введіть пароль для запуску програми</string> <string name="protect_application_subtitle">Введіть пароль для запуску програми</string>
<string name="confirm">Підтвердити</string> <string name="confirm">Підтвердити</string>
<string name="password_length_hint">Пароль має містити 4 символи або більше</string> <string name="password_length_hint">Пароль має містити 4 символи або більше</string>
<string name="search_only_on_s">Пошук лише на %s</string> <string name="search_only_on_s">Пошук лише на %s</string>
<string name="welcome">Ласкаво просимо</string> <string name="welcome">Ласкаво просимо</string>
<string name="backup_saved">Резервна копія збережена</string> <string name="backup_saved">Резервна копія збережена</string>
<string name="read_more">Докладніше</string> <string name="read_more">Докладніше</string>
<string name="queued">У черзі</string> <string name="queued">У черзі</string>
<string name="text_downloads_holder">Немає активних завантажень</string> <string name="text_downloads_holder">Немає активних завантажень</string>
<string name="about_app_translation_summary">Допомогти з перекладом програми</string> <string name="about_app_translation_summary">Допомогти з перекладом програми</string>
<string name="about_app_translation">Переклад</string> <string name="about_app_translation">Переклад</string>
<string name="about_feedback_4pda">Тема на 4PDA</string> <string name="about_feedback_4pda">Тема на 4PDA</string>
<string name="auth_complete">Авторизація виконана</string> <string name="auth_complete">Авторизація виконана</string>
<string name="auth_not_supported_by">Вхід на %s не підтримується</string> <string name="auth_not_supported_by">Вхід на %s не підтримується</string>
<string name="text_clear_cookies_prompt">Ви вийдете з усіх джерел</string> <string name="text_clear_cookies_prompt">Ви вийдете з усіх джерел</string>
<string name="state_finished">Завершена</string> <string name="state_finished">Завершена</string>
<string name="state_ongoing">Триває</string> <string name="state_ongoing">Триває</string>
<string name="date_format">Формат дати</string> <string name="date_format">Формат дати</string>
<string name="exclude_nsfw_from_history">Виключити NSFW манґу з історії</string> <string name="exclude_nsfw_from_history">Виключити NSFW манґу з історії</string>
<string name="error_empty_name">Ви повинні ввести ім’я</string> <string name="error_empty_name">Ви повинні ввести ім’я</string>
<string name="show_pages_numbers">Показувати номери сторінок</string> <string name="show_pages_numbers">Показувати номери сторінок</string>
<string name="enabled_sources">Включені джерела</string> <string name="enabled_sources">Включені джерела</string>
<string name="dynamic_theme_summary">Застосовує тему програми, засновану на палітрі кольорів шпалер на пристрої</string> <string name="dynamic_theme_summary">Застосовує тему програми, засновану на палітрі кольорів шпалер на пристрої</string>
<string name="importing_progress">Імпорт манґи: %1$d з %2$d</string> <string name="importing_progress">Імпорт манґи: %1$d з %2$d</string>
<string name="screenshots_policy">Політика щодо знімків екрана</string> <string name="screenshots_policy">Політика щодо знімків екрана</string>
<string name="screenshots_allow">Дозволити</string> <string name="screenshots_allow">Дозволити</string>
<string name="suggestions_summary">Пропонувати манґу на основі ваших уподобань</string> <string name="suggestions_summary">Пропонувати манґу на основі ваших уподобань</string>
<string name="suggestions_info">Усі дані аналізуються локально на цьому пристрої. Передача ваших персональних даних у будь-які сервіси не здійснюється</string> <string name="suggestions_info">Усі дані аналізуються локально на цьому пристрої. Передача ваших персональних даних у будь-які сервіси не здійснюється</string>
<string name="text_suggestion_holder">Почніть читати манґу, і ви отримаєте персоналізовані пропозиції</string> <string name="text_suggestion_holder">Почніть читати манґу, і ви отримаєте персоналізовані пропозиції</string>
<string name="enabled">Увімкнено</string> <string name="enabled">Увімкнено</string>
<string name="disabled">Вимкнено</string> <string name="disabled">Вимкнено</string>
<string name="reset_filter">Скинути фільтр</string> <string name="reset_filter">Скинути фільтр</string>
<string name="find_genre">Знайти жанр</string> <string name="find_genre">Знайти жанр</string>
<string name="onboard_text">Виберіть мови, якими ви хочете читати манґу. Це можливо змінити пізніше в налаштуваннях.</string> <string name="onboard_text">Виберіть мови, якими ви хочете читати манґу. Це можливо змінити пізніше в налаштуваннях.</string>
<string name="only_using_wifi">Тільки по Wi-Fi</string> <string name="only_using_wifi">Тільки по Wi-Fi</string>
<string name="preload_pages">Попереднє завантаження сторінок</string> <string name="preload_pages">Попереднє завантаження сторінок</string>
<string name="logged_in_as">Ви увійшли як %s</string> <string name="logged_in_as">Ви увійшли як %s</string>
<string name="nsfw">18+</string> <string name="nsfw">18+</string>
<string name="various_languages">Різні мови</string> <string name="various_languages">Різні мови</string>
<string name="search_chapters">Знайти розділ</string> <string name="search_chapters">Знайти розділ</string>
<string name="chapters_empty">Немає розділів у цій манзі</string> <string name="chapters_empty">Немає розділів у цій манзі</string>
<string name="percent_string_pattern">%1$s%%</string> <string name="percent_string_pattern">%1$s%%</string>
<string name="content">Зміст</string> <string name="content">Зміст</string>
<string name="suggestions_updating">Оновлення пропозицій</string> <string name="suggestions_updating">Оновлення пропозицій</string>
<string name="text_delete_local_manga_batch">Видалити вибрані елементи з пристрою назавжди\?</string> <string name="text_delete_local_manga_batch">Видалити вибрані елементи з пристрою назавжди\?</string>
<string name="removal_completed">Видалення завершено</string> <string name="removal_completed">Видалення завершено</string>
<string name="batch_manga_save_confirm">Ви впевнені, що хочете завантажити всю вибрану манґу з усіма її розділами\? Це може споживати багато трафіку та пам’яті</string> <string name="batch_manga_save_confirm">Ви впевнені, що хочете завантажити всю вибрану манґу з усіма її розділами\? Це може споживати багато трафіку та пам’яті</string>
<string name="parallel_downloads">Завантажувати паралельно</string> <string name="parallel_downloads">Завантажувати паралельно</string>
<string name="download_slowdown">Сповільнення завантаження</string> <string name="download_slowdown">Сповільнення завантаження</string>
<string name="local_manga_processing">Обробка збереженої манґи</string> <string name="local_manga_processing">Обробка збереженої манґи</string>
<string name="hide">Приховати</string> <string name="hide">Приховати</string>
<string name="new_sources_text">Доступні нові джерела манґи</string> <string name="new_sources_text">Доступні нові джерела манґи</string>
<string name="close_menu">Закрити меню</string> <string name="close_menu">Закрити меню</string>
<string name="manga_downloading_">Завантаження…</string> <string name="manga_downloading_">Завантаження…</string>
<string name="clear">Очистити</string> <string name="clear">Очистити</string>
<string name="downloads">Завантаження</string> <string name="downloads">Завантаження</string>
<string name="automatic">Як в системі</string> <string name="automatic">Як в системі</string>
<string name="chapter_is_missing_text">Завантажте або прочитайте цей відсутній розділ онлайн.</string> <string name="chapter_is_missing_text">Завантажте або прочитайте цей відсутній розділ онлайн.</string>
<string name="chapter_is_missing">Розділ відсутній</string> <string name="chapter_is_missing">Розділ відсутній</string>
<string name="about_feedback">Зворотній зв\'язок</string> <string name="about_feedback">Зворотній зв\'язок</string>
<string name="genres">Жанри</string> <string name="genres">Жанри</string>
<string name="system_default">За замовчуванням</string> <string name="system_default">За замовчуванням</string>
<string name="always">Завжди</string> <string name="always">Завжди</string>
<string name="_continue">Продовжити</string> <string name="_continue">Продовжити</string>
<string name="_import">Імпорт</string> <string name="_import">Імпорт</string>
<string name="taps_on_edges">Натискання по краях</string> <string name="taps_on_edges">Натискання по краях</string>
<string name="warning">Попередження</string> <string name="warning">Попередження</string>
<string name="network_consumption_warning">Це може призвести до витрати великої кількості трафіку</string> <string name="network_consumption_warning">Це може призвести до витрати великої кількості трафіку</string>
<string name="dont_ask_again">Більше не питати</string> <string name="dont_ask_again">Більше не питати</string>
<string name="notifications_settings">Налаштування сповіщень</string> <string name="notifications_settings">Налаштування сповіщень</string>
<string name="rename">Перейменувати</string> <string name="rename">Перейменувати</string>
<string name="show_notification_app_update">Показувати сповіщення, якщо доступна нова версія</string> <string name="show_notification_app_update">Показувати сповіщення, якщо доступна нова версія</string>
<string name="open_in_browser">Відкрити у веб-браузері</string> <string name="open_in_browser">Відкрити у веб-браузері</string>
<string name="not_available">Недоступно</string> <string name="not_available">Недоступно</string>
<string name="cannot_find_available_storage">Немає доступного сховища</string> <string name="cannot_find_available_storage">Немає доступного сховища</string>
<string name="text_feed_holder">Нові розділи того, що ви читаєте, показано тут</string> <string name="text_feed_holder">Нові розділи того, що ви читаєте, показано тут</string>
<string name="search_results">Результати пошуку</string> <string name="search_results">Результати пошуку</string>
<string name="enter_password">Введіть пароль</string> <string name="enter_password">Введіть пароль</string>
<string name="notification_sound">Звук сповіщень</string> <string name="notification_sound">Звук сповіщень</string>
<string name="light_indicator">Світлодіодний індикатор</string> <string name="light_indicator">Світлодіодний індикатор</string>
<string name="categories_">Категорії…</string> <string name="categories_">Категорії…</string>
<string name="text_categories_holder">Ви можете використовувати категорії для впорядкування своїх уподобань. Натисніть «+», щоб створити категорію</string> <string name="text_categories_holder">Ви можете використовувати категорії для впорядкування своїх уподобань. Натисніть «+», щоб створити категорію</string>
<string name="yesterday">Учора</string> <string name="yesterday">Учора</string>
<string name="right_to_left">Справа наліво (←)</string> <string name="right_to_left">Справа наліво (←)</string>
<string name="create_backup">Створити резервну копію</string> <string name="create_backup">Створити резервну копію</string>
<string name="restore_backup">Відновити з резервної копії</string> <string name="restore_backup">Відновити з резервної копії</string>
<string name="data_restored_success">Всі дані були відновлені</string> <string name="data_restored_success">Всі дані були відновлені</string>
<string name="group">Групувати</string> <string name="group">Групувати</string>
<string name="today">Сьогодні</string> <string name="today">Сьогодні</string>
<string name="silent">Без звуку</string> <string name="silent">Без звуку</string>
<string name="long_ago">Давно</string> <string name="long_ago">Давно</string>
<string name="chapters_checking_progress">Перевірка наявності нових розділів: %1$d з %2$d</string> <string name="chapters_checking_progress">Перевірка наявності нових розділів: %1$d з %2$d</string>
<string name="text_clear_updates_feed_prompt">Очистити всю історію оновлень назавжди\?</string> <string name="text_clear_updates_feed_prompt">Очистити всю історію оновлень назавжди\?</string>
<string name="tracker_warning">Деякі пристрої мають різну поведінку системи, що може порушити фонові завдання.</string> <string name="tracker_warning">Деякі пристрої мають різну поведінку системи, що може порушити фонові завдання.</string>
<string name="text_clear_search_history_prompt">Видалити всі останні пошукові запити назавжди\?</string> <string name="text_clear_search_history_prompt">Видалити всі останні пошукові запити назавжди\?</string>
<string name="other">Інше</string> <string name="other">Інше</string>
<string name="available_sources">Доступні джерела</string> <string name="available_sources">Доступні джерела</string>
<string name="dynamic_theme">Динамічна тема</string> <string name="dynamic_theme">Динамічна тема</string>
<string name="screenshots_block_nsfw">Блок на NSFW</string> <string name="screenshots_block_nsfw">Блок на NSFW</string>
<string name="screenshots_block_all">Завжди блокувати</string> <string name="screenshots_block_all">Завжди блокувати</string>
<string name="suggestions">Пропозиції</string> <string name="suggestions">Пропозиції</string>
<string name="suggestions_enable">Увімкнути пропозиції</string> <string name="suggestions_enable">Увімкнути пропозиції</string>
<string name="exclude_nsfw_from_suggestions">Не пропонувати NSFW манґу</string> <string name="exclude_nsfw_from_suggestions">Не пропонувати NSFW манґу</string>
<string name="filter_load_error">Не вдалося завантажити список жанрів</string> <string name="filter_load_error">Не вдалося завантажити список жанрів</string>
<string name="never">Ніколи</string> <string name="never">Ніколи</string>
<string name="appearance">Зовнішній вигляд</string> <string name="appearance">Зовнішній вигляд</string>
<string name="suggestions_excluded_genres">Виключити жанри</string> <string name="suggestions_excluded_genres">Виключити жанри</string>
<string name="suggestions_excluded_genres_summary">Укажіть жанри, які ви не хочете бачити в пропозиціях</string> <string name="suggestions_excluded_genres_summary">Укажіть жанри, які ви не хочете бачити в пропозиціях</string>
<string name="download_slowdown_summary">Допомагає уникнути блокування вашої IP-адреси</string> <string name="download_slowdown_summary">Допомагає уникнути блокування вашої IP-адреси</string>
<string name="chapters_will_removed_background">Розділи будуть видалені у фоновому режимі. Це може зайняти деякий час</string> <string name="chapters_will_removed_background">Розділи будуть видалені у фоновому режимі. Це може зайняти деякий час</string>
<string name="check_new_chapters_title">Перевіряти наявність нових розділів і повідомляти про них</string> <string name="check_new_chapters_title">Перевіряти наявність нових розділів і повідомляти про них</string>
<string name="show_notification_new_chapters_on">Ви будете отримувати повідомлення про оновлення манґи, яку ви читаєте</string> <string name="show_notification_new_chapters_on">Ви будете отримувати повідомлення про оновлення манґи, яку ви читаєте</string>
<string name="notifications_enable">Увімкнути сповіщення</string> <string name="notifications_enable">Увімкнути сповіщення</string>
<string name="show_notification_new_chapters_off">Ви не будете отримувати повідомлення, але нові розділи будуть відображатися у списку</string> <string name="show_notification_new_chapters_off">Ви не будете отримувати повідомлення, але нові розділи будуть відображатися у списку</string>
<string name="empty_favourite_categories">Немає улюблених категорій</string> <string name="empty_favourite_categories">Немає улюблених категорій</string>
<string name="name">Назва</string> <string name="name">Назва</string>
<string name="edit">Змінити</string> <string name="edit">Змінити</string>
<string name="edit_category">Змінити категорію</string> <string name="edit_category">Змінити категорію</string>
<string name="bookmark_add">Додати закладку</string> <string name="bookmark_add">Додати закладку</string>
<string name="bookmark_remove">Видалити закладку</string> <string name="bookmark_remove">Видалити закладку</string>
<string name="bookmarks">Закладки</string> <string name="bookmarks">Закладки</string>
<string name="bookmark_removed">Закладка видалена</string> <string name="bookmark_removed">Закладка видалена</string>
<string name="bookmark_added">Додано закладку</string> <string name="bookmark_added">Додано закладку</string>
<string name="undo">Відмінити</string> <string name="undo">Відмінити</string>
<string name="removed_from_history">Видалено з історії</string> <string name="removed_from_history">Видалено з історії</string>
<string name="dns_over_https">DNS через HTTPS</string> <string name="dns_over_https">DNS через HTTPS</string>
<string name="default_mode">Режим за замовчуванням</string> <string name="default_mode">Режим за замовчуванням</string>
<string name="detect_reader_mode_summary">Автоматично визначати, чи є манга вебтуном</string> <string name="detect_reader_mode_summary">Автоматично визначати, чи є манга вебтуном</string>
<string name="detect_reader_mode">Автовизначення режиму читання</string> <string name="detect_reader_mode">Автовизначення режиму читання</string>
<string name="disable_battery_optimization">Вимкнути оптимізацію акумулятора</string> <string name="disable_battery_optimization">Вимкнути оптимізацію акумулятора</string>
<string name="disable_battery_optimization_summary">Допомагає з перевірками фонових оновлень</string> <string name="disable_battery_optimization_summary">Допомагає з перевірками фонових оновлень</string>
<string name="crash_text">Щось пішло не так. Будь ласка, надішліть звіт про помилку розробникам, щоб допомогти нам її виправити.</string> <string name="crash_text">Щось пішло не так. Будь ласка, надішліть звіт про помилку розробникам, щоб допомогти нам її виправити.</string>
<string name="send">Надіслати</string> <string name="send">Надіслати</string>
<string name="disable_all">Вимкнути все</string> <string name="disable_all">Вимкнути все</string>
<string name="use_fingerprint">Використовувати відбиток пальця, якщо доступно</string> <string name="use_fingerprint">Використовувати відбиток пальця, якщо доступно</string>
<string name="appwidget_shelf_description">Манга з Вашого улюбленого</string> <string name="appwidget_shelf_description">Манга з Вашого улюбленого</string>
<string name="appwidget_recent_description">Манга, яку Ви нещодавно читали</string> <string name="appwidget_recent_description">Манга, яку Ви нещодавно читали</string>
<string name="invalid_domain_message">Недійсний домен</string>
<string name="report">Звіт</string>
<string name="tracking">Відстеження</string>
<string name="logout">Вийти</string>
<string name="status_planned">Заплановано</string>
<string name="status_reading">Читаю</string>
<string name="status_re_reading">Перечитую</string>
<string name="status_completed">Завершено</string>
<string name="status_on_hold">Відкладено</string>
<string name="status_dropped">Занедбано</string>
<string name="show_reading_indicators">Показувати індикатори прогресу читання</string>
<string name="data_deletion">Видалення даних</string>
<string name="show_reading_indicators_summary">Показати відсоток прочитаного в історії та обраному</string>
<string name="exclude_nsfw_from_history_summary">Манґа, позначена як NSFW, ніколи не буде додана до історії і ваш прогрес не буде збережений</string>
<string name="clear_cookies_summary">Може допомогти в разі виникнення проблем. Усі авторизації будуть анульовані</string>
<string name="show_all">Показати всі</string>
<string name="select_range">Виберіть діапазон</string>
<string name="not_found_404">Вміст не знайдено або видалено</string>
</resources> </resources>

View File

@@ -321,4 +321,6 @@
<string name="clear_cookies_summary">Can help in case of some issues. All authorizations will be invalidated</string> <string name="clear_cookies_summary">Can help in case of some issues. All authorizations will be invalidated</string>
<string name="show_all">Show all</string> <string name="show_all">Show all</string>
<string name="invalid_domain_message">Invalid domain</string> <string name="invalid_domain_message">Invalid domain</string>
<string name="select_range">Select range</string>
<string name="not_found_404">Content not found or removed</string>
</resources> </resources>

View File

@@ -1,15 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<external-files-path <!-- https://issuetracker.google.com/issues/37125252 -->
name="manga-ext" <!--suppress AndroidElementNotAllowed -->
path="/manga" /> <root-path
<files-path name="root"
name="manga" path="." />
path="/manga" />
<external-files-path
name="backups-ext"
path="/backups" />
<files-path
name="backups"
path="/backups" />
</paths> </paths>

View File

@@ -0,0 +1,93 @@
package org.koitharu.kotatsu.core.backup
import org.junit.Assert.assertEquals
import org.junit.Test
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.concurrent.TimeUnit
class JsonSerializerTest {
@Test
fun toFavouriteEntity() {
val entity = FavouriteEntity(
mangaId = 40,
categoryId = 20,
createdAt = System.currentTimeMillis(),
)
val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toFavouriteEntity()
assertEquals(entity, result)
}
@Test
fun toMangaEntity() {
val entity = MangaEntity(
id = 231,
title = "Lorem Ipsum",
altTitle = "Lorem Ispum 2",
url = "erw",
publicUrl = "hthth",
rating = 0.78f,
isNsfw = true,
coverUrl = "5345",
largeCoverUrl = null,
state = MangaState.FINISHED.name,
author = "RERE",
source = MangaSource.DUMMY.name,
)
val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toMangaEntity()
assertEquals(entity, result)
}
@Test
fun toTagEntity() {
val entity = TagEntity(
id = 934023534,
title = "Adventure",
key = "adventure",
source = MangaSource.DUMMY.name,
)
val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toTagEntity()
assertEquals(entity, result)
}
@Test
fun toHistoryEntity() {
val entity = HistoryEntity(
mangaId = 304135341,
createdAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(6),
updatedAt = System.currentTimeMillis(),
chapterId = 29014843034,
page = 35,
scroll = 24.0f,
percent = 0.6f,
)
val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toHistoryEntity()
assertEquals(entity, result)
}
@Test
fun toFavouriteCategoryEntity() {
val entity = FavouriteCategoryEntity(
categoryId = 142,
createdAt = System.currentTimeMillis(),
sortKey = 14,
title = "Read later",
order = SortOrder.RATING.name,
track = false,
)
val json = JsonSerializer(entity).toJson()
val result = JsonDeserializer(json).toFavouriteCategoryEntity()
assertEquals(entity, result)
}
}

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.github
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.headersContentLength
import org.junit.Assert
import org.junit.Test
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.parsers.util.await
class GithubRepositoryTest {
private val okHttpClient = OkHttpClient()
private val repository = GithubRepository(okHttpClient)
@Test
fun getLatestVersion() = runTest {
val version = repository.getLatestVersion()
val versionId = VersionId(version.name)
val apkHead = okHttpClient.newCall(
Request.Builder()
.url(version.apkUrl)
.head()
.build()
).await()
Assert.assertTrue(versionId <= VersionId(BuildConfig.VERSION_NAME))
Assert.assertTrue(apkHead.isSuccessful)
Assert.assertEquals(version.apkSize, apkHead.headersContentLength())
}
}