Compare commits
81 Commits
ui_playgro
...
feature/dy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d8820bcab | ||
|
|
77bb5c2fcd | ||
|
|
475a4904a9 | ||
|
|
cf43b8ebda | ||
|
|
f34096af98 | ||
|
|
d60ff2a052 | ||
|
|
59d4953554 | ||
|
|
f76052b1d6 | ||
|
|
26e59b0953 | ||
|
|
9ee1164f08 | ||
|
|
cfc3823593 | ||
|
|
8407a414c5 | ||
|
|
a379604974 | ||
|
|
c01d80f7da | ||
|
|
7533dce0d2 | ||
|
|
9f1e97fd54 | ||
|
|
382a73310c | ||
|
|
5eeab7fd08 | ||
|
|
bc54e7cfba | ||
|
|
4502ffb6d2 | ||
|
|
b6f9ce824e | ||
|
|
d33081c1c7 | ||
|
|
76c08535d6 | ||
|
|
b55fef67e1 | ||
|
|
56798677d5 | ||
|
|
ff30b9c225 | ||
|
|
5c3293ec44 | ||
|
|
1b17605e0e | ||
|
|
ba4e4dcf56 | ||
|
|
b35d5d4779 | ||
|
|
124f31ebe1 | ||
|
|
173087ee19 | ||
|
|
8d7bad97de | ||
|
|
188fbfbb95 | ||
|
|
3498a54bdf | ||
|
|
18169c2355 | ||
|
|
87beb9442f | ||
|
|
e642d54929 | ||
|
|
59ce5d5e67 | ||
|
|
58d5237692 | ||
|
|
8d5bde6e60 | ||
|
|
bf740ddc93 | ||
|
|
fddbf35e8c | ||
|
|
a47fea02d1 | ||
|
|
250136cfdc | ||
|
|
597ad01e8f | ||
|
|
f7b44f2b0f | ||
|
|
5aab43ac93 | ||
|
|
2d278159ea | ||
|
|
da61462d79 | ||
|
|
2ab0912880 | ||
|
|
3914616222 | ||
|
|
a73b2703be | ||
|
|
49590f6d02 | ||
|
|
f4a0fcf5ba | ||
|
|
6ab803e682 | ||
|
|
0faa97b08c | ||
|
|
2ae488544b | ||
|
|
a7e2cfc878 | ||
|
|
da6db9c1b4 | ||
|
|
88b3e5cf34 | ||
|
|
7347f0ba10 | ||
|
|
4c55682552 | ||
|
|
324031aa2a | ||
|
|
1355c3d75c | ||
|
|
8533168155 | ||
|
|
51f6ec6e55 | ||
|
|
7e3f67c14d | ||
|
|
c51320f033 | ||
|
|
9c50a47abc | ||
|
|
473d273d18 | ||
|
|
f19b628655 | ||
|
|
fa74d4b27a | ||
|
|
cdb6655e37 | ||
|
|
4f19f7ebdf | ||
|
|
bf8838f943 | ||
|
|
1e1e9fabdc | ||
|
|
745972a717 | ||
|
|
6055776329 | ||
|
|
4074791f9a | ||
|
|
b8283acd0d |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -2,4 +2,4 @@ blank_issues_enabled: false
|
|||||||
contact_links:
|
contact_links:
|
||||||
- name: ⚠️ Source issue
|
- name: ⚠️ Source issue
|
||||||
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
|
||||||
about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead
|
about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
4
.github/ISSUE_TEMPLATE/report_bug.yml
vendored
@@ -60,7 +60,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||||
required: true
|
required: true
|
||||||
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
- label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
4
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
@@ -20,5 +20,5 @@ body:
|
|||||||
label: Acknowledgements
|
label: Acknowledgements
|
||||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
- label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 642
|
versionCode = 650
|
||||||
versionName = '7.0.1'
|
versionName = '7.2.1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -82,7 +82,7 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:078b59b1e2') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:f923acc5a7') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +90,15 @@ dependencies {
|
|||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||||
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
implementation 'androidx.core:core-ktx:1.13.1'
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation 'androidx.activity:activity-ktx:1.9.0'
|
implementation 'androidx.activity:activity-ktx:1.9.0'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.7.1'
|
implementation 'androidx.fragment:fragment-ktx:1.8.0'
|
||||||
|
implementation 'androidx.transition:transition-ktx:1.5.0'
|
||||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.8.0'
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.8.0'
|
implementation 'androidx.lifecycle:lifecycle-process:2.8.2'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
@@ -105,7 +106,7 @@ dependencies {
|
|||||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||||
implementation 'com.google.android.material:material:1.12.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.0'
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.2'
|
||||||
implementation 'androidx.webkit:webkit:1.11.0'
|
implementation 'androidx.webkit:webkit:1.11.0'
|
||||||
|
|
||||||
implementation 'androidx.work:work-runtime:2.9.0'
|
implementation 'androidx.work:work-runtime:2.9.0'
|
||||||
@@ -135,7 +136,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.6.0'
|
implementation 'io.coil-kt:coil-base:2.6.0'
|
||||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:8cafac256e'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
implementation 'io.noties.markwon:core:4.6.2'
|
implementation 'io.noties.markwon:core:4.6.2'
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<manifest
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<application>
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".tracker.ui.debug.TrackerDebugActivity"
|
|
||||||
android:label="@string/check_for_new_chapters" />
|
|
||||||
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import leakcanary.LeakCanary
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.workinspector.WorkInspector
|
||||||
|
|
||||||
|
class SettingsMenuProvider(
|
||||||
|
private val context: Context,
|
||||||
|
) : MenuProvider {
|
||||||
|
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||||
|
R.id.action_leaks -> {
|
||||||
|
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.action_works -> {
|
||||||
|
context.startActivity(WorkInspector.getIntent(context))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,14 +8,9 @@
|
|||||||
android:title="@string/leak_canary_display_activity_label"
|
android:title="@string/leak_canary_display_activity_label"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
<item
|
|
||||||
android:id="@id/action_tracker"
|
|
||||||
android:title="@string/check_for_new_chapters"
|
|
||||||
app:showAsAction="never" />
|
|
||||||
|
|
||||||
<item
|
<item
|
||||||
android:id="@id/action_works"
|
android:id="@id/action_works"
|
||||||
android:title="Works"
|
android:title="@string/wi_lib_name"
|
||||||
app:showAsAction="never" />
|
app:showAsAction="never" />
|
||||||
|
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@@ -100,6 +100,13 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="${applicationId}.action.READ_MANGA" />
|
<action android:name="${applicationId}.action.READ_MANGA" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.samsung.android.support.REMOTE_ACTION"
|
||||||
|
android:resource="@xml/remote_action" />
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
||||||
@@ -248,6 +255,9 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity"
|
android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity"
|
||||||
android:label="@string/app_update_available" />
|
android:label="@string/app_update_available" />
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
|
||||||
|
android:label="@string/tracker_debug_info" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals
|
|||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -57,7 +58,7 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
||||||
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
|
||||||
result.addAll(sourcesRepository.getEnabledSources())
|
result.addAll(sourcesRepository.getEnabledSources())
|
||||||
result.sortByDescending { it.priority(ref) }
|
result.sortByDescending { it.priority(ref) }
|
||||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
||||||
@@ -78,8 +79,10 @@ class AlternativesUseCase @Inject constructor(
|
|||||||
|
|
||||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||||
var res = 0
|
var res = 0
|
||||||
if (locale == ref.locale) res += 2
|
if (this is MangaParserSource && ref is MangaParserSource) {
|
||||||
if (contentType == ref.contentType) res++
|
if (locale == ref.locale) res += 2
|
||||||
|
if (contentType == ref.contentType) res++
|
||||||
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,135 +12,184 @@ import org.koitharu.kotatsu.history.data.toMangaHistory
|
|||||||
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.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||||
|
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class MigrateUseCase @Inject constructor(
|
class MigrateUseCase
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
@Inject
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
constructor(
|
||||||
private val database: MangaDatabase,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
) {
|
private val database: MangaDatabase,
|
||||||
|
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||||
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
|
||||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
) {
|
||||||
runCatchingCancellable {
|
suspend operator fun invoke(
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
oldManga: Manga,
|
||||||
}.getOrDefault(oldManga)
|
newManga: Manga,
|
||||||
} else {
|
) {
|
||||||
oldManga
|
val oldDetails =
|
||||||
}
|
if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
runCatchingCancellable {
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
} else {
|
}.getOrDefault(oldManga)
|
||||||
newManga
|
} else {
|
||||||
}
|
oldManga
|
||||||
mangaDataRepository.storeManga(newDetails)
|
}
|
||||||
database.withTransaction {
|
val newDetails =
|
||||||
// replace favorites
|
if (newManga.chapters.isNullOrEmpty()) {
|
||||||
val favoritesDao = database.getFavouritesDao()
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
} else {
|
||||||
if (oldFavourites.isNotEmpty()) {
|
newManga
|
||||||
favoritesDao.delete(oldManga.id)
|
}
|
||||||
for (f in oldFavourites) {
|
mangaDataRepository.storeManga(newDetails)
|
||||||
val e = f.copy(
|
database.withTransaction {
|
||||||
mangaId = newManga.id,
|
// replace favorites
|
||||||
|
val favoritesDao = database.getFavouritesDao()
|
||||||
|
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||||
|
if (oldFavourites.isNotEmpty()) {
|
||||||
|
favoritesDao.delete(oldManga.id)
|
||||||
|
for (f in oldFavourites) {
|
||||||
|
val e =
|
||||||
|
f.copy(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
)
|
||||||
|
favoritesDao.upsert(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// replace history
|
||||||
|
val historyDao = database.getHistoryDao()
|
||||||
|
val oldHistory = historyDao.find(oldDetails.id)
|
||||||
|
val newHistory =
|
||||||
|
if (oldHistory != null) {
|
||||||
|
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||||
|
historyDao.delete(oldDetails.id)
|
||||||
|
historyDao.upsert(newHistory)
|
||||||
|
newHistory
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
// track
|
||||||
|
val tracksDao = database.getTracksDao()
|
||||||
|
val oldTrack = tracksDao.find(oldDetails.id)
|
||||||
|
if (oldTrack != null) {
|
||||||
|
val lastChapter = newDetails.chapters?.lastOrNull()
|
||||||
|
val newTrack =
|
||||||
|
TrackEntity(
|
||||||
|
mangaId = newDetails.id,
|
||||||
|
lastChapterId = lastChapter?.id ?: 0L,
|
||||||
|
newChapters = 0,
|
||||||
|
lastCheckTime = System.currentTimeMillis(),
|
||||||
|
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
||||||
|
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
||||||
|
lastError = null,
|
||||||
|
)
|
||||||
|
tracksDao.delete(oldDetails.id)
|
||||||
|
tracksDao.upsert(newTrack)
|
||||||
|
}
|
||||||
|
// scrobbling
|
||||||
|
for (scrobbler in scrobblers) {
|
||||||
|
if (!scrobbler.isEnabled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
|
||||||
|
scrobbler.unregisterScrobbling(oldDetails.id)
|
||||||
|
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
|
||||||
|
scrobbler.updateScrobblingInfo(
|
||||||
|
mangaId = newDetails.id,
|
||||||
|
rating = prevInfo.rating,
|
||||||
|
status =
|
||||||
|
prevInfo.status ?: when {
|
||||||
|
newHistory == null -> ScrobblingStatus.PLANNED
|
||||||
|
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||||
|
else -> ScrobblingStatus.READING
|
||||||
|
},
|
||||||
|
comment = prevInfo.comment,
|
||||||
)
|
)
|
||||||
favoritesDao.upsert(e)
|
if (newHistory != null) {
|
||||||
|
scrobbler.scrobble(
|
||||||
|
manga = newDetails,
|
||||||
|
chapterId = newHistory.chapterId,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// replace history
|
progressUpdateUseCase(newManga)
|
||||||
val historyDao = database.getHistoryDao()
|
|
||||||
val oldHistory = historyDao.find(oldDetails.id)
|
|
||||||
if (oldHistory != null) {
|
|
||||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
|
||||||
historyDao.delete(oldDetails.id)
|
|
||||||
historyDao.upsert(newHistory)
|
|
||||||
}
|
|
||||||
// track
|
|
||||||
val tracksDao = database.getTracksDao()
|
|
||||||
val oldTrack = tracksDao.find(oldDetails.id)
|
|
||||||
if (oldTrack != null) {
|
|
||||||
val lastChapter = newDetails.chapters?.lastOrNull()
|
|
||||||
val newTrack = TrackEntity(
|
|
||||||
mangaId = newDetails.id,
|
|
||||||
lastChapterId = lastChapter?.id ?: 0L,
|
|
||||||
newChapters = 0,
|
|
||||||
lastCheckTime = System.currentTimeMillis(),
|
|
||||||
lastChapterDate = lastChapter?.uploadDate ?: 0L,
|
|
||||||
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
|
|
||||||
)
|
|
||||||
tracksDao.delete(oldDetails.id)
|
|
||||||
tracksDao.upsert(newTrack)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
progressUpdateUseCase(newManga)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeNewHistory(
|
private fun makeNewHistory(
|
||||||
oldManga: Manga,
|
oldManga: Manga,
|
||||||
newManga: Manga,
|
newManga: Manga,
|
||||||
history: HistoryEntity,
|
history: HistoryEntity,
|
||||||
): HistoryEntity {
|
): HistoryEntity {
|
||||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||||
val branch = newManga.getPreferredBranch(null)
|
val branch = newManga.getPreferredBranch(null)
|
||||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||||
val currentChapter = if (history.percent in 0f..1f) {
|
val currentChapter =
|
||||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
if (history.percent in 0f..1f) {
|
||||||
} else {
|
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||||
chapters.first()
|
} else {
|
||||||
|
chapters.first()
|
||||||
|
}
|
||||||
|
return HistoryEntity(
|
||||||
|
mangaId = newManga.id,
|
||||||
|
createdAt = history.createdAt,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
chapterId = currentChapter.id,
|
||||||
|
page = history.page,
|
||||||
|
scroll = history.scroll,
|
||||||
|
percent = history.percent,
|
||||||
|
deletedAt = 0,
|
||||||
|
chaptersCount = chapters.size,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||||
|
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||||
|
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||||
|
if (index < 0) {
|
||||||
|
index =
|
||||||
|
if (history.percent in 0f..1f) {
|
||||||
|
(oldChapters.lastIndex * history.percent).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||||
|
val newBranch =
|
||||||
|
if (newChapters.containsKey(branch)) {
|
||||||
|
branch
|
||||||
|
} else {
|
||||||
|
newManga.getPreferredBranch(null)
|
||||||
|
}
|
||||||
|
val newChapterId =
|
||||||
|
checkNotNull(newChapters[newBranch])
|
||||||
|
.let {
|
||||||
|
val oldChapter = oldChapters[index]
|
||||||
|
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||||
|
}.id
|
||||||
|
|
||||||
return HistoryEntity(
|
return HistoryEntity(
|
||||||
mangaId = newManga.id,
|
mangaId = newManga.id,
|
||||||
createdAt = history.createdAt,
|
createdAt = history.createdAt,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
chapterId = currentChapter.id,
|
chapterId = newChapterId,
|
||||||
page = history.page,
|
page = history.page,
|
||||||
scroll = history.scroll,
|
scroll = history.scroll,
|
||||||
percent = history.percent,
|
percent = PROGRESS_NONE,
|
||||||
deletedAt = 0,
|
deletedAt = 0,
|
||||||
chaptersCount = chapters.size,
|
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
|
||||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
private fun List<MangaChapter>.findByNumber(
|
||||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
volume: Int,
|
||||||
if (index < 0) {
|
number: Float,
|
||||||
index = if (history.percent in 0f..1f) {
|
): MangaChapter? =
|
||||||
(oldChapters.lastIndex * history.percent).toInt()
|
if (number <= 0f) {
|
||||||
|
null
|
||||||
} else {
|
} else {
|
||||||
0
|
firstOrNull { it.volume == volume && it.number == number }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
|
||||||
val newBranch = if (newChapters.containsKey(branch)) {
|
|
||||||
branch
|
|
||||||
} else {
|
|
||||||
newManga.getPreferredBranch(null)
|
|
||||||
}
|
|
||||||
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
|
||||||
val oldChapter = oldChapters[index]
|
|
||||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
|
||||||
}.id
|
|
||||||
|
|
||||||
return HistoryEntity(
|
|
||||||
mangaId = newManga.id,
|
|
||||||
createdAt = history.createdAt,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
chapterId = newChapterId,
|
|
||||||
page = history.page,
|
|
||||||
scroll = history.scroll,
|
|
||||||
percent = PROGRESS_NONE,
|
|
||||||
deletedAt = 0,
|
|
||||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
|
||||||
return if (number <= 0f) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
firstOrNull { it.volume == volume && it.number == number }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import coil.request.ImageRequest
|
|||||||
import coil.transform.CircleCropTransformation
|
import coil.transform.CircleCropTransformation
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||||
@@ -63,7 +64,7 @@ fun alternativeAD(
|
|||||||
}
|
}
|
||||||
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||||
binding.chipSource.also { chip ->
|
binding.chipSource.also { chip ->
|
||||||
chip.text = item.manga.source.title
|
chip.text = item.manga.source.getTitle(chip.context)
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(item.manga.source.faviconUri())
|
.data(item.manga.source.faviconUri())
|
||||||
.lifecycle(lifecycleOwner)
|
.lifecycle(lifecycleOwner)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
@@ -95,9 +96,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
getString(
|
getString(
|
||||||
R.string.migrate_confirmation,
|
R.string.migrate_confirmation,
|
||||||
viewModel.manga.title,
|
viewModel.manga.title,
|
||||||
viewModel.manga.source.title,
|
viewModel.manga.source.getTitle(this),
|
||||||
target.title,
|
target.title,
|
||||||
target.source.title,
|
target.source.getTitle(this),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ import androidx.core.graphics.Insets
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import okhttp3.internal.userAgent
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
@@ -42,10 +43,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||||
}
|
}
|
||||||
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
|
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
|
||||||
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
val repository = mangaRepositoryFactory.create(mangaSource) as? RemoteMangaRepository
|
||||||
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
||||||
}
|
|
||||||
viewBinding.webView.configureForParser(userAgent)
|
viewBinding.webView.configureForParser(userAgent)
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||||
@@ -108,8 +108,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
viewBinding.webView.stopLoading()
|
if (hasViewBinding()) {
|
||||||
viewBinding.webView.destroy()
|
viewBinding.webView.stopLoading()
|
||||||
|
viewBinding.webView.destroy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLoadingStateChanged(isLoading: Boolean) {
|
override fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
@@ -145,7 +147,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
return Intent(context, BrowserActivity::class.java)
|
return Intent(context, BrowserActivity::class.java)
|
||||||
.setData(Uri.parse(url))
|
.setData(Uri.parse(url))
|
||||||
.putExtra(EXTRA_TITLE, title)
|
.putExtra(EXTRA_TITLE, title)
|
||||||
.putExtra(EXTRA_SOURCE, source)
|
.putExtra(EXTRA_SOURCE, source?.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import coil.request.ErrorResult
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
class CaptchaNotifier(
|
class CaptchaNotifier(
|
||||||
@@ -46,7 +47,7 @@ class CaptchaNotifier(
|
|||||||
.setGroup(GROUP_CAPTCHA)
|
.setGroup(GROUP_CAPTCHA)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setVisibility(
|
.setVisibility(
|
||||||
if (exception.source?.contentType == ContentType.HENTAI) {
|
if (exception.source?.isNsfw() == true) {
|
||||||
NotificationCompat.VISIBILITY_SECRET
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
} else {
|
} else {
|
||||||
NotificationCompat.VISIBILITY_PUBLIC
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
@@ -55,7 +56,7 @@ class CaptchaNotifier(
|
|||||||
.setContentText(
|
.setContentText(
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.captcha_required_summary,
|
R.string.captcha_required_summary,
|
||||||
exception.source?.title ?: context.getString(R.string.app_name),
|
exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import okhttp3.OkHttpClient
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
|
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
@@ -48,6 +48,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
|||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||||
|
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
||||||
@@ -152,10 +153,12 @@ interface AppModule {
|
|||||||
appProtectHelper: AppProtectHelper,
|
appProtectHelper: AppProtectHelper,
|
||||||
activityRecreationHandle: ActivityRecreationHandle,
|
activityRecreationHandle: ActivityRecreationHandle,
|
||||||
acraScreenLogger: AcraScreenLogger,
|
acraScreenLogger: AcraScreenLogger,
|
||||||
|
screenshotPolicyHelper: ScreenshotPolicyHelper,
|
||||||
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
|
||||||
appProtectHelper,
|
appProtectHelper,
|
||||||
activityRecreationHandle,
|
activityRecreationHandle,
|
||||||
acraScreenLogger,
|
acraScreenLogger,
|
||||||
|
screenshotPolicyHelper,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ class JsonDeserializer(private val json: JSONObject) {
|
|||||||
source = json.getString("source"),
|
source = json.getString("source"),
|
||||||
isEnabled = json.getBoolean("enabled"),
|
isEnabled = json.getBoolean("enabled"),
|
||||||
sortKey = json.getInt("sort_key"),
|
sortKey = json.getInt("sort_key"),
|
||||||
|
addedIn = json.getIntOrDefault("added_in", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
fun toMap(): Map<String, Any?> {
|
||||||
|
|||||||
@@ -16,16 +16,16 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
|
|||||||
|
|
||||||
private val isLowRam = application.isLowRamDevice()
|
private val isLowRam = application.isLowRamDevice()
|
||||||
|
|
||||||
init {
|
|
||||||
application.registerComponentCallbacks(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
|
||||||
private val pagesCache =
|
private val pagesCache =
|
||||||
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
|
||||||
private val relatedMangaCache =
|
private val relatedMangaCache =
|
||||||
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
|
||||||
|
|
||||||
|
init {
|
||||||
|
application.registerComponentCallbacks(this)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
suspend fun getDetails(source: MangaSource, url: String): Manga? {
|
||||||
return detailsCache[Key(source, url)]?.awaitOrNull()
|
return detailsCache[Key(source, url)]?.awaitOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||||
@@ -58,7 +59,7 @@ 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 = 20
|
const val DATABASE_VERSION = 21
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -118,6 +119,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration17To18(),
|
Migration17To18(),
|
||||||
Migration18To19(),
|
Migration18To19(),
|
||||||
Migration19To20(),
|
Migration19To20(),
|
||||||
|
Migration20To21(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
|
|||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.intellij.lang.annotations.Language
|
import org.intellij.lang.annotations.Language
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("SELECT source FROM sources WHERE enabled = 1")
|
@Query("SELECT source FROM sources WHERE enabled = 1")
|
||||||
abstract suspend fun findAllEnabledNames(): List<String>
|
abstract suspend fun findAllEnabledNames(): List<String>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE added_in >= :version")
|
||||||
|
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||||
|
|
||||||
@@ -68,6 +72,7 @@ abstract class MangaSourcesDao {
|
|||||||
source = source,
|
source = source,
|
||||||
isEnabled = isEnabled,
|
isEnabled = isEnabled,
|
||||||
sortKey = getMaxSortKey() + 1,
|
sortKey = getMaxSortKey() + 1,
|
||||||
|
addedIn = BuildConfig.VERSION_CODE,
|
||||||
)
|
)
|
||||||
upsert(entity)
|
upsert(entity)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ data class MangaSourceEntity(
|
|||||||
val source: String,
|
val source: String,
|
||||||
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
@ColumnInfo(name = "enabled") val isEnabled: Boolean,
|
||||||
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
|
||||||
|
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.content.Context
|
|||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
|
||||||
class Migration16To17(context: Context) : Migration(16, 17) {
|
class Migration16To17(context: Context) : Migration(16, 17) {
|
||||||
|
|
||||||
@@ -15,11 +15,8 @@ class Migration16To17(context: Context) : Migration(16, 17) {
|
|||||||
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
|
||||||
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
|
||||||
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
|
||||||
val sources = MangaSource.entries
|
val sources = MangaParserSource.entries
|
||||||
for (source in sources) {
|
for (source in sources) {
|
||||||
if (source == MangaSource.LOCAL) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val name = source.name
|
val name = source.name
|
||||||
val isHidden = name in hiddenSources
|
val isHidden = name in hiddenSources
|
||||||
var sortKey = order.indexOf(name)
|
var sortKey = order.indexOf(name)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration20To21 : Migration(20, 21) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,48 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions.resolve
|
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultCallback
|
import androidx.activity.result.ActivityResultCallback
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.collection.ArrayMap
|
import androidx.collection.MutableScatterMap
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||||
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.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
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 java.security.cert.CertPathValidatorException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||||
|
|
||||||
private val continuations = ArrayMap<String, Continuation<Boolean>>(1)
|
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||||
private val activity: FragmentActivity?
|
private val activity: FragmentActivity?
|
||||||
private val fragment: Fragment?
|
private val fragment: Fragment?
|
||||||
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
private val sourceAuthContract: ActivityResultLauncher<MangaSource>
|
||||||
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
|
||||||
|
|
||||||
|
val context: Context?
|
||||||
|
get() = activity ?: fragment?.context
|
||||||
|
|
||||||
constructor(activity: FragmentActivity) {
|
constructor(activity: FragmentActivity) {
|
||||||
this.activity = activity
|
this.activity = activity
|
||||||
fragment = null
|
fragment = null
|
||||||
@@ -56,6 +68,12 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||||
is CloudFlareProtectedException -> resolveCF(e)
|
is CloudFlareProtectedException -> resolveCF(e)
|
||||||
is AuthRequiredException -> resolveAuthException(e.source)
|
is AuthRequiredException -> resolveAuthException(e.source)
|
||||||
|
is SSLException,
|
||||||
|
is CertPathValidatorException -> {
|
||||||
|
showSslErrorDialog()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
is NotFoundException -> {
|
is NotFoundException -> {
|
||||||
openInBrowser(e.url)
|
openInBrowser(e.url)
|
||||||
false
|
false
|
||||||
@@ -80,13 +98,37 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun openInBrowser(url: String) {
|
private fun openInBrowser(url: String) {
|
||||||
val context = activity ?: fragment?.activity ?: return
|
context?.run {
|
||||||
context.startActivity(BrowserActivity.newIntent(context, url, null, null))
|
startActivity(BrowserActivity.newIntent(this, url, null, null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAlternatives(manga: Manga) {
|
private fun openAlternatives(manga: Manga) {
|
||||||
val context = activity ?: fragment?.activity ?: return
|
context?.run {
|
||||||
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
startActivity(AlternativesActivity.newIntent(this, manga))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSslErrorDialog() {
|
||||||
|
val ctx = context ?: return
|
||||||
|
val settings = getAppSettings(ctx)
|
||||||
|
if (settings.isSSLBypassEnabled) {
|
||||||
|
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.ignore_ssl_errors)
|
||||||
|
.setMessage(R.string.ignore_ssl_errors_summary)
|
||||||
|
.setPositiveButton(R.string.apply) { _, _ ->
|
||||||
|
settings.isSSLBypassEnabled = true
|
||||||
|
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
|
||||||
|
ctx.findActivity()?.finishAffinity()
|
||||||
|
}.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAppSettings(context: Context): AppSettings {
|
||||||
|
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||||
@@ -99,6 +141,9 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
|||||||
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
|
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||||
|
is SSLException,
|
||||||
|
is CertPathValidatorException -> R.string.fix
|
||||||
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
|||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
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.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.util.formatSimple
|
import org.koitharu.kotatsu.parsers.util.formatSimple
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
@@ -109,7 +108,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val Manga.isLocal: Boolean
|
val Manga.isLocal: Boolean
|
||||||
get() = source == MangaSource.LOCAL
|
get() = source == LocalMangaSource
|
||||||
|
|
||||||
val Manga.appUrl: Uri
|
val Manga.appUrl: Uri
|
||||||
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
|
||||||
|
|||||||
@@ -7,26 +7,41 @@ import android.text.style.ForegroundColorSpan
|
|||||||
import android.text.style.RelativeSizeSpan
|
import android.text.style.RelativeSizeSpan
|
||||||
import android.text.style.SuperscriptSpan
|
import android.text.style.SuperscriptSpan
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.text.buildSpannedString
|
|
||||||
import androidx.core.text.inSpans
|
import androidx.core.text.inSpans
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
|
||||||
import java.util.Locale
|
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
fun MangaSource(name: String): MangaSource {
|
data object LocalMangaSource : MangaSource {
|
||||||
MangaSource.entries.forEach {
|
override val name = "LOCAL"
|
||||||
if (it.name == name) return it
|
|
||||||
}
|
|
||||||
return MangaSource.DUMMY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
|
data object UnknownMangaSource : MangaSource {
|
||||||
|
override val name = "UNKNOWN"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MangaSource(name: String?): MangaSource {
|
||||||
|
when (name) {
|
||||||
|
null,
|
||||||
|
UnknownMangaSource.name -> UnknownMangaSource
|
||||||
|
|
||||||
|
LocalMangaSource.name -> LocalMangaSource
|
||||||
|
}
|
||||||
|
MangaParserSource.entries.forEach {
|
||||||
|
if (it.name == name) return it
|
||||||
|
}
|
||||||
|
return UnknownMangaSource
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MangaSource.isNsfw() = when (this) {
|
||||||
|
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
val ContentType.titleResId
|
val ContentType.titleResId
|
||||||
@@ -37,23 +52,23 @@ val ContentType.titleResId
|
|||||||
ContentType.OTHER -> R.string.content_type_other
|
ContentType.OTHER -> R.string.content_type_other
|
||||||
}
|
}
|
||||||
|
|
||||||
fun MangaSource.getSummary(context: Context): String {
|
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||||
val type = context.getString(contentType.titleResId)
|
is MangaParserSource -> {
|
||||||
val locale = locale?.toLocale().getDisplayName(context)
|
val type = context.getString(contentType.titleResId)
|
||||||
return context.getString(R.string.source_summary_pattern, type, locale)
|
val locale = locale.toLocale().getDisplayName(context)
|
||||||
}
|
context.getString(R.string.source_summary_pattern, type, locale)
|
||||||
|
|
||||||
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
|
|
||||||
buildSpannedString {
|
|
||||||
append(title)
|
|
||||||
append(' ')
|
|
||||||
appendNsfwLabel(context)
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
title
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
fun MangaSource.getTitle(context: Context): String = when (this) {
|
||||||
|
is MangaParserSource -> title
|
||||||
|
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||||
|
else -> context.getString(R.string.unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
||||||
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
|
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
|
||||||
RelativeSizeSpan(0.74f),
|
RelativeSizeSpan(0.74f),
|
||||||
SuperscriptSpan(),
|
SuperscriptSpan(),
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.koitharu.kotatsu.core.model.parcelable
|
||||||
|
|
||||||
|
import android.os.Parcel
|
||||||
|
import kotlinx.parcelize.Parceler
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class MangaSourceParceler : Parceler<MangaSource> {
|
||||||
|
|
||||||
|
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
|
||||||
|
|
||||||
|
override fun MangaSource.write(parcel: Parcel, flags: Int) {
|
||||||
|
parcel.writeString(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,8 @@ import android.os.Parcel
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class ParcelableChapter(
|
data class ParcelableChapter(
|
||||||
@@ -25,8 +24,8 @@ data class ParcelableChapter(
|
|||||||
scanlator = parcel.readString(),
|
scanlator = parcel.readString(),
|
||||||
uploadDate = parcel.readLong(),
|
uploadDate = parcel.readLong(),
|
||||||
branch = parcel.readString(),
|
branch = parcel.readString(),
|
||||||
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
|
source = MangaSource(parcel.readString()),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
|
||||||
@@ -38,7 +37,7 @@ data class ParcelableChapter(
|
|||||||
parcel.writeString(scanlator)
|
parcel.writeString(scanlator)
|
||||||
parcel.writeLong(uploadDate)
|
parcel.writeLong(uploadDate)
|
||||||
parcel.writeString(branch)
|
parcel.writeString(branch)
|
||||||
parcel.writeSerializable(source)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.Parcelable
|
|||||||
import androidx.core.os.ParcelCompat
|
import androidx.core.os.ParcelCompat
|
||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -30,7 +31,7 @@ data class ParcelableManga(
|
|||||||
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||||
parcel.writeSerializable(state)
|
parcel.writeSerializable(state)
|
||||||
parcel.writeString(author)
|
parcel.writeString(author)
|
||||||
parcel.writeSerializable(source)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun create(parcel: Parcel) = ParcelableManga(
|
override fun create(parcel: Parcel) = ParcelableManga(
|
||||||
@@ -49,8 +50,8 @@ data class ParcelableManga(
|
|||||||
state = parcel.readSerializableCompat(),
|
state = parcel.readSerializableCompat(),
|
||||||
author = parcel.readString(),
|
author = parcel.readString(),
|
||||||
chapters = null,
|
chapters = null,
|
||||||
source = requireNotNull(parcel.readSerializableCompat()),
|
source = MangaSource(parcel.readString()),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import android.os.Parcelable
|
|||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.parcelize.TypeParceler
|
import kotlinx.parcelize.TypeParceler
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
|
||||||
object MangaPageParceler : Parceler<MangaPage> {
|
object MangaPageParceler : Parceler<MangaPage> {
|
||||||
@@ -13,14 +13,14 @@ object MangaPageParceler : Parceler<MangaPage> {
|
|||||||
id = parcel.readLong(),
|
id = parcel.readLong(),
|
||||||
url = requireNotNull(parcel.readString()),
|
url = requireNotNull(parcel.readString()),
|
||||||
preview = parcel.readString(),
|
preview = parcel.readString(),
|
||||||
source = requireNotNull(parcel.readSerializableCompat()),
|
source = MangaSource(parcel.readString()),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun MangaPage.write(parcel: Parcel, flags: Int) {
|
override fun MangaPage.write(parcel: Parcel, flags: Int) {
|
||||||
parcel.writeLong(id)
|
parcel.writeLong(id)
|
||||||
parcel.writeString(url)
|
parcel.writeString(url)
|
||||||
parcel.writeString(preview)
|
parcel.writeString(preview)
|
||||||
parcel.writeSerializable(source)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ import android.os.Parcelable
|
|||||||
import kotlinx.parcelize.Parceler
|
import kotlinx.parcelize.Parceler
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.parcelize.TypeParceler
|
import kotlinx.parcelize.TypeParceler
|
||||||
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
|
||||||
object MangaTagParceler : Parceler<MangaTag> {
|
object MangaTagParceler : Parceler<MangaTag> {
|
||||||
override fun create(parcel: Parcel) = MangaTag(
|
override fun create(parcel: Parcel) = MangaTag(
|
||||||
title = requireNotNull(parcel.readString()),
|
title = requireNotNull(parcel.readString()),
|
||||||
key = requireNotNull(parcel.readString()),
|
key = requireNotNull(parcel.readString()),
|
||||||
source = requireNotNull(parcel.readSerializableCompat()),
|
source = MangaSource(parcel.readString()),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun MangaTag.write(parcel: Parcel, flags: Int) {
|
override fun MangaTag.write(parcel: Parcel, flags: Int) {
|
||||||
parcel.writeString(title)
|
parcel.writeString(title)
|
||||||
parcel.writeString(key)
|
parcel.writeString(key)
|
||||||
parcel.writeSerializable(source)
|
parcel.writeString(source.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import android.util.Log
|
|||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Interceptor.Chain
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import okio.IOException
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
@@ -13,6 +15,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
|||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.net.IDN
|
import java.net.IDN
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -23,7 +26,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Chain): Response {
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val source = request.tag(MangaSource::class.java)
|
val source = request.tag(MangaSource::class.java)
|
||||||
val repository = if (source != null) {
|
val repository = if (source != null) {
|
||||||
@@ -46,7 +49,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
|
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
|
||||||
}
|
}
|
||||||
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
|
||||||
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
return repository?.interceptSafe(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
private fun Headers.Builder.trySet(name: String, value: String) = try {
|
||||||
@@ -55,10 +58,21 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
|
||||||
|
intercept(chain)
|
||||||
|
}.getOrElse { e ->
|
||||||
|
if (e is IOException) {
|
||||||
|
throw e
|
||||||
|
} else {
|
||||||
|
// only IOException can be safely thrown from an Interceptor
|
||||||
|
throw IOException("Error in interceptor: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class ProxyChain(
|
private class ProxyChain(
|
||||||
private val delegate: Interceptor.Chain,
|
private val delegate: Chain,
|
||||||
private val request: Request,
|
private val request: Request,
|
||||||
) : Interceptor.Chain by delegate {
|
) : Chain by delegate {
|
||||||
|
|
||||||
override fun request(): Request = request
|
override fun request(): Request = request
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ class DoHManager(
|
|||||||
tryGetByIp("2a10:50c0::2:ff"),
|
tryGetByIp("2a10:50c0::2:ff"),
|
||||||
),
|
),
|
||||||
).build()
|
).build()
|
||||||
|
|
||||||
|
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||||
|
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
|
||||||
|
.resolvePublicAddresses(true)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryGetByIp(ip: String): InetAddress? = try {
|
private fun tryGetByIp(ip: String): InetAddress? = try {
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.network
|
|||||||
|
|
||||||
enum class DoHProvider {
|
enum class DoHProvider {
|
||||||
|
|
||||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD
|
NONE, GOOGLE, CLOUDFLARE, ADGUARD, ZERO_MS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import coil.intercept.Interceptor
|
|
||||||
import coil.request.ErrorResult
|
|
||||||
import coil.request.ImageResult
|
|
||||||
import coil.request.SuccessResult
|
|
||||||
import coil.size.Dimension
|
|
||||||
import coil.size.isOriginal
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import java.util.Collections
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ImageProxyInterceptor @Inject constructor(
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) : Interceptor {
|
|
||||||
|
|
||||||
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
|
||||||
|
|
||||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
|
||||||
val request = chain.request
|
|
||||||
if (!settings.isImagesProxyEnabled) {
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
val url: HttpUrl? = when (val data = request.data) {
|
|
||||||
is HttpUrl -> data
|
|
||||||
is String -> data.toHttpUrlOrNull()
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
val newUrl = HttpUrl.Builder()
|
|
||||||
.scheme("https")
|
|
||||||
.host("wsrv.nl")
|
|
||||||
.addQueryParameter("url", url.toString())
|
|
||||||
.addQueryParameter("we", null)
|
|
||||||
val size = request.sizeResolver.size()
|
|
||||||
if (!size.isOriginal) {
|
|
||||||
newUrl.addQueryParameter("crop", "cover")
|
|
||||||
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
|
||||||
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val newRequest = request.newBuilder()
|
|
||||||
.data(newUrl.build())
|
|
||||||
.build()
|
|
||||||
val result = chain.proceed(newRequest)
|
|
||||||
return if (result is SuccessResult) {
|
|
||||||
result
|
|
||||||
} else {
|
|
||||||
logDebug((result as? ErrorResult)?.throwable)
|
|
||||||
chain.proceed(request).also {
|
|
||||||
if (it is SuccessResult) {
|
|
||||||
blacklist.add(url.host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
|
||||||
if (!settings.isImagesProxyEnabled) {
|
|
||||||
return okHttp.newCall(request).await()
|
|
||||||
}
|
|
||||||
val sourceUrl = request.url
|
|
||||||
val targetUrl = HttpUrl.Builder()
|
|
||||||
.scheme("https")
|
|
||||||
.host("wsrv.nl")
|
|
||||||
.addQueryParameter("url", sourceUrl.toString())
|
|
||||||
.addQueryParameter("we", null)
|
|
||||||
val newRequest = request.newBuilder()
|
|
||||||
.url(targetUrl.build())
|
|
||||||
.build()
|
|
||||||
return runCatchingCancellable {
|
|
||||||
okHttp.doCall(newRequest)
|
|
||||||
}.recover {
|
|
||||||
logDebug(it)
|
|
||||||
okHttp.doCall(request).also {
|
|
||||||
blacklist.add(sourceUrl.host)
|
|
||||||
}
|
|
||||||
}.getOrThrow()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
|
||||||
return newCall(request).await().ensureSuccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logDebug(e: Throwable?) {
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Log.w("ImageProxy", e.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ import okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
|||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import java.util.EnumMap
|
import java.util.EnumMap
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -26,8 +27,8 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java)
|
private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
|
||||||
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java)
|
private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
|
||||||
|
|
||||||
val isEnabled: Boolean
|
val isEnabled: Boolean
|
||||||
get() = settings.isMirrorSwitchingAvailable
|
get() = settings.isMirrorSwitchingAvailable
|
||||||
@@ -145,15 +146,15 @@ class MirrorSwitchInterceptor @Inject constructor(
|
|||||||
return source().readByteArray().toResponseBody(contentType())
|
return source().readByteArray().toResponseBody(contentType())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) {
|
private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
|
||||||
Any()
|
Any()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isBlacklisted(source: MangaSource, domain: String): Boolean {
|
private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
|
||||||
return blacklist[source]?.contains(domain) == true
|
return blacklist[source]?.contains(domain) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addToBlacklist(source: MangaSource, domain: String) {
|
private fun addToBlacklist(source: MangaParserSource, domain: String) {
|
||||||
blacklist.getOrPut(source) {
|
blacklist.getOrPut(source) {
|
||||||
ArraySet(2)
|
ArraySet(2)
|
||||||
}.add(domain)
|
}.add(domain)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
|
||||||
|
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||||
|
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
@@ -29,6 +31,9 @@ interface NetworkModule {
|
|||||||
@Binds
|
@Binds
|
||||||
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
|
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
fun bindImageProxyInterceptor(impl: RealImageProxyInterceptor): ImageProxyInterceptor
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import coil.intercept.Interceptor
|
||||||
|
import coil.network.HttpException
|
||||||
|
import coil.request.ErrorResult
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.request.ImageResult
|
||||||
|
import coil.request.SuccessResult
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.HttpStatusException
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
|
||||||
|
|
||||||
|
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
|
||||||
|
|
||||||
|
final override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
|
val request = chain.request
|
||||||
|
val url: HttpUrl? = when (val data = request.data) {
|
||||||
|
is HttpUrl -> data
|
||||||
|
is String -> data.toHttpUrlOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
val newRequest = onInterceptImageRequest(request, url)
|
||||||
|
return when (val result = chain.proceed(newRequest)) {
|
||||||
|
is SuccessResult -> result
|
||||||
|
is ErrorResult -> {
|
||||||
|
logDebug(result.throwable, newRequest.data)
|
||||||
|
chain.proceed(request).also {
|
||||||
|
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
|
||||||
|
blacklist.add(url.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||||
|
val newRequest = onInterceptPageRequest(request)
|
||||||
|
return runCatchingCancellable {
|
||||||
|
okHttp.doCall(newRequest)
|
||||||
|
}.recover { error ->
|
||||||
|
logDebug(error, newRequest.url)
|
||||||
|
okHttp.doCall(request).also {
|
||||||
|
if (error.isBlockedByServer()) {
|
||||||
|
blacklist.add(request.url.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest
|
||||||
|
|
||||||
|
protected abstract suspend fun onInterceptPageRequest(request: Request): Request
|
||||||
|
|
||||||
|
private suspend fun OkHttpClient.doCall(request: Request): Response {
|
||||||
|
return newCall(request).await().ensureSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logDebug(e: Throwable, url: Any) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.w("ImageProxy", "${e.message}: $url", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Throwable.isBlockedByServer(): Boolean {
|
||||||
|
return this is CloudFlareBlockedException
|
||||||
|
|| (this is HttpException && response.code == HttpURLConnection.HTTP_FORBIDDEN)
|
||||||
|
|| (this is HttpStatusException && statusCode == HttpURLConnection.HTTP_FORBIDDEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
|
import coil.intercept.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
interface ImageProxyInterceptor : Interceptor {
|
||||||
|
|
||||||
|
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
|
import coil.intercept.Interceptor
|
||||||
|
import coil.request.ImageResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class RealImageProxyInterceptor @Inject constructor(
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) : ImageProxyInterceptor {
|
||||||
|
|
||||||
|
private val delegate = settings.observeAsStateFlow(
|
||||||
|
scope = processLifecycleScope + Dispatchers.Default,
|
||||||
|
key = AppSettings.KEY_IMAGES_PROXY,
|
||||||
|
valueProducer = { createDelegate() },
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||||
|
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
|
||||||
|
return delegate.value?.interceptPageRequest(request, okHttp) ?: okHttp.newCall(request).await()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDelegate(): ImageProxyInterceptor? = when (val proxy = settings.imagesProxy) {
|
||||||
|
-1 -> null
|
||||||
|
0 -> WsrvNlProxyInterceptor()
|
||||||
|
1 -> ZeroMsProxyInterceptor()
|
||||||
|
else -> error("Unsupported images proxy $proxy")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.size.Dimension
|
||||||
|
import coil.size.isOriginal
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
class WsrvNlProxyInterceptor : BaseImageProxyInterceptor() {
|
||||||
|
|
||||||
|
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||||
|
val newUrl = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("wsrv.nl")
|
||||||
|
.addQueryParameter("url", url.toString())
|
||||||
|
.addQueryParameter("we", null)
|
||||||
|
val size = request.sizeResolver.size()
|
||||||
|
if (!size.isOriginal) {
|
||||||
|
newUrl.addQueryParameter("crop", "cover")
|
||||||
|
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
|
||||||
|
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.newBuilder()
|
||||||
|
.data(newUrl.build())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||||
|
val sourceUrl = request.url
|
||||||
|
val targetUrl = HttpUrl.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host("wsrv.nl")
|
||||||
|
.addQueryParameter("url", sourceUrl.toString())
|
||||||
|
.addQueryParameter("we", null)
|
||||||
|
return request.newBuilder()
|
||||||
|
.url(targetUrl.build())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.koitharu.kotatsu.core.network.imageproxy
|
||||||
|
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() {
|
||||||
|
|
||||||
|
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||||
|
if (url.host == "x.0ms.dev" || url.host == "0ms.dev") {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
val newUrl = ("https://x.0ms.dev/q70/$url").toHttpUrl()
|
||||||
|
return request.newBuilder()
|
||||||
|
.data(newUrl)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||||
|
val newUrl = ("https://x.0ms.dev/q70/${request.url}").toHttpUrl()
|
||||||
|
return request.newBuilder()
|
||||||
|
.url(newUrl)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ 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.db.TABLE_HISTORY
|
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
@@ -173,9 +174,10 @@ class AppShortcutManager @Inject constructor(
|
|||||||
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) },
|
||||||
)
|
)
|
||||||
|
val title = source.getTitle(context)
|
||||||
ShortcutInfoCompat.Builder(context, source.name)
|
ShortcutInfoCompat.Builder(context, source.name)
|
||||||
.setShortLabel(source.title)
|
.setShortLabel(title)
|
||||||
.setLongLabel(source.title)
|
.setLongLabel(title)
|
||||||
.setIcon(icon)
|
.setIcon(icon)
|
||||||
.setLongLived(true)
|
.setLongLived(true)
|
||||||
.setIntent(MangaListActivity.newIntent(context, source))
|
.setIntent(MangaListActivity.newIntent(context, source))
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||||
|
import org.koitharu.kotatsu.parsers.bitmap.Rect
|
||||||
|
import java.io.OutputStream
|
||||||
|
import android.graphics.Bitmap as AndroidBitmap
|
||||||
|
import android.graphics.Rect as AndroidRect
|
||||||
|
|
||||||
|
class BitmapWrapper private constructor(
|
||||||
|
private val androidBitmap: AndroidBitmap,
|
||||||
|
) : Bitmap {
|
||||||
|
|
||||||
|
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
|
||||||
|
|
||||||
|
override val height: Int
|
||||||
|
get() = androidBitmap.height
|
||||||
|
|
||||||
|
override val width: Int
|
||||||
|
get() = androidBitmap.width
|
||||||
|
|
||||||
|
override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) {
|
||||||
|
val androidSourceBitmap = (sourceBitmap as BitmapWrapper).androidBitmap
|
||||||
|
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun compressTo(output: OutputStream) {
|
||||||
|
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
|
||||||
|
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
|
||||||
|
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ 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.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
@@ -16,7 +16,7 @@ import java.util.EnumSet
|
|||||||
/**
|
/**
|
||||||
* This parser is just for parser development, it should not be used in releases
|
* This parser is just for parser development, it should not be used in releases
|
||||||
*/
|
*/
|
||||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
|
||||||
|
|
||||||
override val configKeyDomain: ConfigKey.Domain
|
override val configKeyDomain: ConfigKey.Domain
|
||||||
get() = ConfigKey.Domain("localhost")
|
get() = ConfigKey.Domain("localhost")
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This parser is just for parser development, it should not be used in releases
|
||||||
|
*/
|
||||||
|
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder>
|
||||||
|
get() = EnumSet.allOf(SortOrder::class.java)
|
||||||
|
override val states: Set<MangaState>
|
||||||
|
get() = emptySet()
|
||||||
|
override val contentRatings: Set<ContentRating>
|
||||||
|
get() = emptySet()
|
||||||
|
override var defaultSortOrder: SortOrder
|
||||||
|
get() = SortOrder.NEWEST
|
||||||
|
set(value) = Unit
|
||||||
|
override val isMultipleTagsSupported: Boolean
|
||||||
|
get() = false
|
||||||
|
override val isTagsExclusionSupported: Boolean
|
||||||
|
get() = false
|
||||||
|
override val isSearchSupported: Boolean
|
||||||
|
get() = false
|
||||||
|
|
||||||
|
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||||
|
|
||||||
|
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> = stub(null)
|
||||||
|
|
||||||
|
override suspend fun getLocales(): Set<Locale> = stub(null)
|
||||||
|
|
||||||
|
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
||||||
|
|
||||||
|
private fun stub(manga: Manga?): Nothing {
|
||||||
|
throw UnsupportedSourceException("This manga source is not supported", manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
|
|||||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||||
@@ -101,7 +102,7 @@ class MangaDataRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun cleanupLocalManga() {
|
suspend fun cleanupLocalManga() {
|
||||||
val dao = db.getMangaDao()
|
val dao = db.getMangaDao()
|
||||||
val broken = dao.findAllBySource(MangaSource.LOCAL.name)
|
val broken = dao.findAllBySource(LocalMangaSource.name)
|
||||||
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
|
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
|
||||||
if (broken.isNotEmpty()) {
|
if (broken.isNotEmpty()) {
|
||||||
dao.delete(broken.map { it.manga })
|
dao.delete(broken.map { it.manga })
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import android.net.Uri
|
|||||||
import coil.request.CachePolicy
|
import coil.request.CachePolicy
|
||||||
import dagger.Reusable
|
import dagger.Reusable
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
@@ -36,7 +37,7 @@ class MangaLinkResolver @Inject constructor(
|
|||||||
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
|
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
|
||||||
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
|
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
|
||||||
val source = MangaSource(sourceName)
|
val source = MangaSource(sourceName)
|
||||||
require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" }
|
require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" }
|
||||||
val repo = repositoryFactory.create(source)
|
val repo = repositoryFactory.create(source)
|
||||||
return repo.findExact(
|
return repo.findExact(
|
||||||
url = uri.getQueryParameter("url"),
|
url = uri.getQueryParameter("url"),
|
||||||
@@ -108,7 +109,7 @@ class MangaLinkResolver @Inject constructor(
|
|||||||
url = url,
|
url = url,
|
||||||
publicUrl = "",
|
publicUrl = "",
|
||||||
rating = 0.0f,
|
rating = 0.0f,
|
||||||
isNsfw = source.contentType == ContentType.HENTAI,
|
isNsfw = source.isNsfw(),
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
tags = emptySet(),
|
tags = emptySet(),
|
||||||
state = null,
|
state = null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.parser
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
@@ -10,15 +11,21 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||||
|
import okio.Buffer
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.requireBody
|
||||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.toList
|
import org.koitharu.kotatsu.core.util.ext.toList
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
|
||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||||
@@ -68,6 +75,27 @@ class MangaLoaderContextImpl @Inject constructor(
|
|||||||
return LocaleListCompat.getAdjustedDefault().toList()
|
return LocaleListCompat.getAdjustedDefault().toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
|
||||||
|
val image = response.requireBody().byteStream()
|
||||||
|
|
||||||
|
val opts = BitmapFactory.Options()
|
||||||
|
opts.inMutable = true
|
||||||
|
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
|
||||||
|
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
|
||||||
|
|
||||||
|
val body = Buffer().also {
|
||||||
|
result.compressTo(it.outputStream())
|
||||||
|
}.asResponseBody("image/jpeg".toMediaType())
|
||||||
|
|
||||||
|
return response.newBuilder()
|
||||||
|
.body(body)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createBitmap(width: Int, height: Int): Bitmap {
|
||||||
|
return BitmapWrapper.create(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
private fun obtainWebView(): WebView {
|
private fun obtainWebView(): WebView {
|
||||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ package org.koitharu.kotatsu.core.parser
|
|||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
|
|
||||||
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
|
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||||
return if (source == MangaSource.DUMMY) {
|
return when (source) {
|
||||||
DummyParser(loaderContext)
|
MangaParserSource.DUMMY -> DummyParser(loaderContext)
|
||||||
} else {
|
else -> loaderContext.newParserInstance(source)
|
||||||
loaderContext.newParserInstance(source)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
@@ -10,12 +13,12 @@ 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.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.EnumMap
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -61,24 +64,35 @@ interface MangaRepository {
|
|||||||
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
|
private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
|
||||||
|
|
||||||
@AnyThread
|
@AnyThread
|
||||||
fun create(source: MangaSource): MangaRepository {
|
fun create(source: MangaSource): MangaRepository {
|
||||||
if (source == MangaSource.LOCAL) {
|
when (source) {
|
||||||
return localMangaRepository
|
LocalMangaSource -> return localMangaRepository
|
||||||
|
UnknownMangaSource -> return EmptyMangaRepository(source)
|
||||||
}
|
}
|
||||||
cache[source]?.get()?.let { return it }
|
cache[source]?.get()?.let { return it }
|
||||||
return synchronized(cache) {
|
return synchronized(cache) {
|
||||||
cache[source]?.get()?.let { return it }
|
cache[source]?.get()?.let { return it }
|
||||||
val repository = RemoteMangaRepository(
|
val repository = createRepository(source)
|
||||||
parser = MangaParser(source, loaderContext),
|
if (repository != null) {
|
||||||
cache = contentCache,
|
cache[source] = WeakReference(repository)
|
||||||
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
|
repository
|
||||||
)
|
} else {
|
||||||
cache[source] = WeakReference(repository)
|
EmptyMangaRepository(source)
|
||||||
repository
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
|
||||||
|
is MangaParserSource -> RemoteMangaRepository(
|
||||||
|
parser = MangaParser(source, loaderContext),
|
||||||
|
cache = contentCache,
|
||||||
|
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ 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.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
@@ -46,7 +46,7 @@ class RemoteMangaRepository(
|
|||||||
private val relatedMangaMutex = MultiMutex<Long>()
|
private val relatedMangaMutex = MultiMutex<Long>()
|
||||||
private val pagesMutex = MultiMutex<Long>()
|
private val pagesMutex = MultiMutex<Long>()
|
||||||
|
|
||||||
override val source: MangaSource
|
override val source: MangaParserSource
|
||||||
get() = parser.source
|
get() = parser.source
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
override val sortOrders: Set<SortOrder>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
|||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.requireBody
|
||||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||||
import org.koitharu.kotatsu.local.data.CacheDir
|
import org.koitharu.kotatsu.local.data.CacheDir
|
||||||
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
|
||||||
@@ -45,7 +46,7 @@ class FaviconFetcher(
|
|||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
|
|
||||||
private val diskCacheKey
|
private val diskCacheKey
|
||||||
get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}"
|
get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
|
||||||
|
|
||||||
private val fileSystem
|
private val fileSystem
|
||||||
get() = checkNotNull(diskCache.value).fileSystem
|
get() = checkNotNull(diskCache.value).fileSystem
|
||||||
@@ -150,10 +151,6 @@ class FaviconFetcher(
|
|||||||
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
|
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Response.requireBody(): ResponseBody {
|
|
||||||
return checkNotNull(body) { "response body == null" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Size.toCacheKey() = buildString {
|
private fun Size.toCacheKey() = buildString {
|
||||||
append(width.toString())
|
append(width.toString())
|
||||||
append('x')
|
append('x')
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isTrackerNotificationsEnabled: Boolean
|
val isTrackerNotificationsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
||||||
|
|
||||||
|
val isTrackerNsfwDisabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
|
||||||
|
|
||||||
var notificationSound: Uri
|
var notificationSound: Uri
|
||||||
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
|
||||||
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
?: Settings.System.DEFAULT_NOTIFICATION_URI
|
||||||
@@ -287,17 +290,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
|
get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
|
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
|
||||||
|
|
||||||
val isNewSourcesTipEnabled: Boolean
|
var sourcesVersion: Int
|
||||||
get() = prefs.getBoolean(KEY_SOURCES_NEW, true)
|
get() = prefs.getInt(KEY_SOURCES_VERSION, 0)
|
||||||
|
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
|
||||||
|
|
||||||
val isPagesNumbersEnabled: Boolean
|
val isPagesNumbersEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||||
|
|
||||||
val screenshotsPolicy: ScreenshotsPolicy
|
val screenshotsPolicy: ScreenshotsPolicy
|
||||||
get() = runCatching {
|
get() = prefs.getEnumValue(KEY_SCREENSHOTS_POLICY, ScreenshotsPolicy.ALLOW)
|
||||||
val key = prefs.getString(KEY_SCREENSHOTS_POLICY, null)?.uppercase(Locale.ROOT)
|
|
||||||
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
|
|
||||||
}.getOrDefault(ScreenshotsPolicy.ALLOW)
|
|
||||||
|
|
||||||
var userSpecifiedMangaDirectories: Set<File>
|
var userSpecifiedMangaDirectories: Set<File>
|
||||||
get() {
|
get() {
|
||||||
@@ -380,14 +381,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isImagesProxyEnabled: Boolean
|
val imagesProxy: Int
|
||||||
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
|
get() {
|
||||||
|
val raw = prefs.getString(KEY_IMAGES_PROXY, null)?.toIntOrNull()
|
||||||
|
return raw ?: if (prefs.getBoolean(KEY_IMAGES_PROXY_OLD, false)) 0 else -1
|
||||||
|
}
|
||||||
|
|
||||||
val dnsOverHttps: DoHProvider
|
val dnsOverHttps: DoHProvider
|
||||||
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
|
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
|
||||||
|
|
||||||
val isSSLBypassEnabled: Boolean
|
var isSSLBypassEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
|
get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_SSL_BYPASS, value) }
|
||||||
|
|
||||||
val proxyType: Proxy.Type
|
val proxyType: Proxy.Type
|
||||||
get() {
|
get() {
|
||||||
@@ -547,8 +552,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
|
|
||||||
|
|
||||||
const val TRACK_HISTORY = "history"
|
const val TRACK_HISTORY = "history"
|
||||||
const val TRACK_FAVOURITES = "favourites"
|
const val TRACK_FAVOURITES = "favourites"
|
||||||
|
|
||||||
@@ -585,6 +588,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_TRACK_CATEGORIES = "track_categories"
|
const val KEY_TRACK_CATEGORIES = "track_categories"
|
||||||
const val KEY_TRACK_WARNING = "track_warning"
|
const val KEY_TRACK_WARNING = "track_warning"
|
||||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||||
|
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
|
||||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||||
@@ -597,7 +601,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||||
const val KEY_PROTECT_APP = "protect_app"
|
const val KEY_PROTECT_APP = "protect_app"
|
||||||
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
||||||
const val KEY_APP_VERSION = "app_version"
|
|
||||||
const val KEY_ZOOM_MODE = "zoom_mode"
|
const val KEY_ZOOM_MODE = "zoom_mode"
|
||||||
const val KEY_BACKUP = "backup"
|
const val KEY_BACKUP = "backup"
|
||||||
const val KEY_RESTORE = "restore"
|
const val KEY_RESTORE = "restore"
|
||||||
@@ -647,9 +650,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||||
const val KEY_APP_LOCALE = "app_locale"
|
const val KEY_APP_LOCALE = "app_locale"
|
||||||
const val KEY_LOGGING_ENABLED = "logging"
|
const val KEY_LOGGING_ENABLED = "logging"
|
||||||
const val KEY_LOGS_SHARE = "logs_share"
|
|
||||||
const val KEY_SOURCES_GRID = "sources_grid"
|
const val KEY_SOURCES_GRID = "sources_grid"
|
||||||
const val KEY_SOURCES_NEW = "sources_new"
|
|
||||||
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
|
||||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||||
const val KEY_SSL_BYPASS = "ssl_bypass"
|
const val KEY_SSL_BYPASS = "ssl_bypass"
|
||||||
@@ -662,7 +663,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_PROXY_AUTH = "proxy_auth"
|
const val KEY_PROXY_AUTH = "proxy_auth"
|
||||||
const val KEY_PROXY_LOGIN = "proxy_login"
|
const val KEY_PROXY_LOGIN = "proxy_login"
|
||||||
const val KEY_PROXY_PASSWORD = "proxy_password"
|
const val KEY_PROXY_PASSWORD = "proxy_password"
|
||||||
const val KEY_IMAGES_PROXY = "images_proxy"
|
const val KEY_IMAGES_PROXY = "images_proxy_2"
|
||||||
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
|
||||||
const val KEY_DISABLE_NSFW = "no_nsfw"
|
const val KEY_DISABLE_NSFW = "no_nsfw"
|
||||||
const val KEY_RELATED_MANGA = "related_manga"
|
const val KEY_RELATED_MANGA = "related_manga"
|
||||||
@@ -676,7 +677,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_CF_CONTRAST = "cf_contrast"
|
const val KEY_CF_CONTRAST = "cf_contrast"
|
||||||
const val KEY_CF_INVERTED = "cf_inverted"
|
const val KEY_CF_INVERTED = "cf_inverted"
|
||||||
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
|
||||||
const val KEY_PAGES_TAB = "pages_tab"
|
const val KEY_PAGES_TAB = "pages_tab"
|
||||||
const val KEY_DETAILS_TAB = "details_tab"
|
const val KEY_DETAILS_TAB = "details_tab"
|
||||||
const val KEY_DETAILS_LAST_TAB = "details_last_tab"
|
const val KEY_DETAILS_LAST_TAB = "details_last_tab"
|
||||||
@@ -684,9 +684,19 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||||
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
||||||
const val KEY_STATS_ENABLED = "stats_on"
|
const val KEY_STATS_ENABLED = "stats_on"
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
|
||||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
|
||||||
const val KEY_FEED_HEADER = "feed_header"
|
const val KEY_FEED_HEADER = "feed_header"
|
||||||
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
|
||||||
|
const val KEY_SOURCES_VERSION = "sources_version"
|
||||||
|
|
||||||
|
// keys for non-persistent preferences
|
||||||
|
const val KEY_APP_VERSION = "app_version"
|
||||||
|
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||||
|
const val KEY_TRACKER_DEBUG = "tracker_debug"
|
||||||
|
const val KEY_LOGS_SHARE = "logs_share"
|
||||||
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
|
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||||
|
|
||||||
|
// old keys are for migration only
|
||||||
|
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.prefs
|
|||||||
enum class ScreenshotsPolicy {
|
enum class ScreenshotsPolicy {
|
||||||
|
|
||||||
// Do not rename this
|
// Do not rename this
|
||||||
ALLOW, BLOCK_NSFW, BLOCK_ALL;
|
ALLOW, BLOCK_NSFW, BLOCK_INCOGNITO, BLOCK_ALL;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.prefs
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import okhttp3.internal.isSensitiveHeader
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||||
@@ -12,6 +11,7 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
|
|||||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||||
|
|
||||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||||
|
|
||||||
@@ -31,7 +31,11 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
|||||||
.ifNullOrEmpty { key.defaultValue }
|
.ifNullOrEmpty { key.defaultValue }
|
||||||
.sanitizeHeaderValue()
|
.sanitizeHeaderValue()
|
||||||
|
|
||||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue)
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { DomainValidator.isValidDomain(it) }
|
||||||
|
?: key.defaultValue
|
||||||
|
|
||||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||||
} as T
|
} as T
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.ui
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -18,6 +19,8 @@ import dagger.hilt.EntryPoint
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
@@ -25,10 +28,12 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
|||||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||||
|
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||||
|
|
||||||
@Suppress("LeakingThis")
|
@Suppress("LeakingThis")
|
||||||
abstract class BaseActivity<B : ViewBinding> :
|
abstract class BaseActivity<B : ViewBinding> :
|
||||||
AppCompatActivity(),
|
AppCompatActivity(),
|
||||||
|
ScreenshotPolicyHelper.ContentContainer,
|
||||||
WindowInsetsDelegate.WindowInsetsListener {
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
private var isAmoledTheme = false
|
private var isAmoledTheme = false
|
||||||
@@ -92,10 +97,20 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSupportNavigateUp(): Boolean {
|
override fun onSupportNavigateUp(): Boolean {
|
||||||
if (supportFragmentManager.popBackStackImmediate()) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
// TODO fix behavior on Android 14
|
||||||
|
dispatchNavigateUp()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val fm = supportFragmentManager
|
||||||
|
if (fm.isStateSaved) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
dispatchNavigateUp()
|
if (fm.backStackEntryCount > 0) {
|
||||||
|
fm.popBackStack()
|
||||||
|
} else {
|
||||||
|
dispatchNavigateUp()
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +155,8 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
|
||||||
|
|
||||||
private fun putDataToExtras(intent: Intent?) {
|
private fun putDataToExtras(intent: Intent?) {
|
||||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||||
}
|
}
|
||||||
@@ -159,6 +176,8 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected fun hasViewBinding() = ::viewBinding.isInitialized
|
||||||
|
|
||||||
@EntryPoint
|
@EntryPoint
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
interface BaseActivityEntryPoint {
|
interface BaseActivityEntryPoint {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun setTitle(title: CharSequence?) {
|
protected open fun setTitle(title: CharSequence?) {
|
||||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ abstract class BaseViewModel : ViewModel() {
|
|||||||
errorEvent.call(error)
|
errorEvent.call(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
|
protected inline fun <T> withLoading(block: () -> T): T = try {
|
||||||
loadingCounter.increment()
|
loadingCounter.increment()
|
||||||
block()
|
block()
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ class FastScrollRecyclerView @JvmOverloads constructor(
|
|||||||
fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
|
fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
|
||||||
|
super.setPadding(left, top, right, bottom)
|
||||||
|
fastScroller.setPadding(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
|
||||||
|
super.setPaddingRelative(start, top, end, bottom)
|
||||||
|
fastScroller.setPaddingRelative(start, top, end, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onAttachedToWindow() {
|
override fun onAttachedToWindow() {
|
||||||
super.onAttachedToWindow()
|
super.onAttachedToWindow()
|
||||||
fastScroller.attachRecyclerView(this)
|
fastScroller.attachRecyclerView(this)
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.sheet
|
package org.koitharu.kotatsu.core.ui.sheet
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.activity.BackEventCompat
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
|
||||||
|
|
||||||
class BottomSheetCollapseCallback(
|
class BottomSheetCollapseCallback(
|
||||||
private val behavior: BottomSheetBehavior<*>,
|
private val sheet: ViewGroup,
|
||||||
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) {
|
private val behavior: BottomSheetBehavior<*> = BottomSheetBehavior.from(sheet),
|
||||||
|
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED || behavior.state == STATE_HALF_EXPANDED) {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
behavior.addBottomSheetCallback(
|
behavior.addBottomSheetCallback(
|
||||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||||
|
|
||||||
|
@SuppressLint("SwitchIntDef")
|
||||||
override fun onStateChanged(view: View, state: Int) {
|
override fun onStateChanged(view: View, state: Int) {
|
||||||
isEnabled = state == STATE_EXPANDED || state == STATE_HALF_EXPANDED
|
when (state) {
|
||||||
|
STATE_EXPANDED,
|
||||||
|
STATE_HALF_EXPANDED -> isEnabled = true
|
||||||
|
|
||||||
|
STATE_COLLAPSED,
|
||||||
|
STATE_HIDDEN -> isEnabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSlide(p0: View, p1: Float) = Unit
|
override fun onSlide(p0: View, p1: Float) = Unit
|
||||||
@@ -24,7 +36,11 @@ class BottomSheetCollapseCallback(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() = behavior.handleBackInvoked()
|
||||||
behavior.state = STATE_COLLAPSED
|
|
||||||
}
|
override fun handleOnBackCancelled() = behavior.cancelBackProgress()
|
||||||
|
|
||||||
|
override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent)
|
||||||
|
|
||||||
|
override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import com.google.android.material.chip.ChipGroup
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
||||||
|
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
class ChipsView @JvmOverloads constructor(
|
class ChipsView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
@@ -24,7 +26,9 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
onChipClickListener?.onChipClick(it as Chip, it.tag)
|
onChipClickListener?.onChipClick(it as Chip, it.tag)
|
||||||
}
|
}
|
||||||
private val chipOnCloseListener = OnClickListener {
|
private val chipOnCloseListener = OnClickListener {
|
||||||
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
|
val chip = it as Chip
|
||||||
|
val data = it.tag
|
||||||
|
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
||||||
}
|
}
|
||||||
private val chipStyle: Int
|
private val chipStyle: Int
|
||||||
var onChipClickListener: OnChipClickListener? = null
|
var onChipClickListener: OnChipClickListener? = null
|
||||||
@@ -48,7 +52,7 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
if (isInEditMode) {
|
if (isInEditMode) {
|
||||||
setChips(
|
setChips(
|
||||||
List(5) {
|
List(5) {
|
||||||
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
|
ChipModel(title = "Chip $it")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,15 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
chip.isChipIconVisible = true
|
chip.isChipIconVisible = true
|
||||||
}
|
}
|
||||||
chip.isChecked = model.isChecked
|
chip.isChecked = model.isChecked
|
||||||
|
chip.isCheckedIconVisible = chip.isCheckable && model.icon == 0
|
||||||
|
chip.isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
|
||||||
|
chip.setCloseIconResource(
|
||||||
|
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
chip.tag = model.data
|
chip.tag = model.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +119,11 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
val chip = Chip(context)
|
val chip = Chip(context)
|
||||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||||
chip.setChipDrawable(drawable)
|
chip.setChipDrawable(drawable)
|
||||||
chip.isCheckedIconVisible = true
|
|
||||||
chip.isChipIconVisible = false
|
chip.isChipIconVisible = false
|
||||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
|
||||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||||
chip.setEnsureMinTouchTargetSize(false)
|
chip.setEnsureMinTouchTargetSize(false)
|
||||||
chip.setOnClickListener(chipOnClickListener)
|
chip.setOnClickListener(chipOnClickListener)
|
||||||
|
chip.isElegantTextHeight = false
|
||||||
addView(chip)
|
addView(chip)
|
||||||
return chip
|
return chip
|
||||||
}
|
}
|
||||||
@@ -127,11 +139,12 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class ChipModel(
|
data class ChipModel(
|
||||||
@ColorRes val tint: Int,
|
|
||||||
val title: CharSequence,
|
val title: CharSequence,
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int = 0,
|
||||||
val isCheckable: Boolean,
|
val isCheckable: Boolean = false,
|
||||||
val isChecked: Boolean,
|
@ColorRes val tint: Int = 0,
|
||||||
|
val isChecked: Boolean = false,
|
||||||
|
val isDropdown: Boolean = false,
|
||||||
val data: Any? = null,
|
val data: Any? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.map
|
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class LocaleComparator : Comparator<Locale> {
|
class LocaleComparator : Comparator<Locale> {
|
||||||
|
|
||||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
|
private val deviceLocales: List<String>
|
||||||
.map { it.language }
|
|
||||||
.distinct()
|
init {
|
||||||
|
val localeList = LocaleListCompat.getAdjustedDefault()
|
||||||
|
deviceLocales = buildList(localeList.size() + 1) {
|
||||||
|
add("")
|
||||||
|
val set = HashSet<String>(localeList.size() + 1)
|
||||||
|
set.add("")
|
||||||
|
for (locale in localeList) {
|
||||||
|
val lang = locale.language
|
||||||
|
if (set.add(lang)) {
|
||||||
|
add(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun compare(a: Locale, b: Locale): Int {
|
override fun compare(a: Locale, b: Locale): Int {
|
||||||
val indexA = deviceLocales.indexOf(a.language)
|
val indexA = deviceLocales.indexOf(a.language)
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import okhttp3.internal.isSensitiveHeader
|
|
||||||
import okio.IOException
|
import okio.IOException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.jsoup.HttpStatusException
|
import org.jsoup.HttpStatusException
|
||||||
@@ -42,6 +41,8 @@ fun Response.ensureSuccess() = apply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
|
||||||
|
|
||||||
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||||
c.name(name)
|
c.name(name)
|
||||||
c.value(value)
|
c.value(value)
|
||||||
|
|||||||
@@ -22,11 +22,10 @@ fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementE
|
|||||||
|
|
||||||
fun String.toLocale() = Locale(this)
|
fun String.toLocale() = Locale(this)
|
||||||
|
|
||||||
fun Locale?.getDisplayName(context: Context): String {
|
fun Locale?.getDisplayName(context: Context): String = when (this) {
|
||||||
if (this == null) {
|
null -> context.getString(R.string.all_languages)
|
||||||
return context.getString(R.string.various_languages)
|
Locale.ROOT -> context.getString(R.string.various_languages)
|
||||||
}
|
else -> getDisplayLanguage(this).toTitleCase(this)
|
||||||
return getDisplayLanguage(this).toTitleCase(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
|
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import android.content.Intent
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.model.findById
|
import org.koitharu.kotatsu.core.model.findById
|
||||||
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
@@ -62,7 +64,7 @@ class MangaPrefetchService : CoroutineIntentService() {
|
|||||||
|
|
||||||
private suspend fun prefetchLast() {
|
private suspend fun prefetchLast() {
|
||||||
val last = historyRepository.getLastOrNull() ?: return
|
val last = historyRepository.getLastOrNull() ?: return
|
||||||
if (last.source == MangaSource.LOCAL) return
|
if (last.isLocal) return
|
||||||
val repo = mangaRepositoryFactory.create(last.source)
|
val repo = mangaRepositoryFactory.create(last.source)
|
||||||
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
|
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
|
||||||
val chapters = details.chapters
|
val chapters = details.chapters
|
||||||
@@ -110,7 +112,7 @@ class MangaPrefetchService : CoroutineIntentService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
|
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
|
||||||
if (source == MangaSource.LOCAL || context.isPowerSaveMode()) {
|
if (source == LocalMangaSource || context.isPowerSaveMode()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
val entryPoint = EntryPointAccessors.fromApplication(
|
val entryPoint = EntryPointAccessors.fromApplication(
|
||||||
|
|||||||
@@ -31,17 +31,21 @@ import coil.request.ImageRequest
|
|||||||
import coil.request.SuccessResult
|
import coil.request.SuccessResult
|
||||||
import coil.transform.CircleCropTransformation
|
import coil.transform.CircleCropTransformation
|
||||||
import coil.util.CoilUtils
|
import coil.util.CoilUtils
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
|
||||||
import com.google.android.material.chip.Chip
|
import com.google.android.material.chip.Chip
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNot
|
import kotlinx.coroutines.flow.filterNot
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.model.iconResId
|
import org.koitharu.kotatsu.core.model.iconResId
|
||||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||||
import org.koitharu.kotatsu.core.model.titleResId
|
import org.koitharu.kotatsu.core.model.titleResId
|
||||||
@@ -93,7 +97,6 @@ import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
|||||||
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
|
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
|
||||||
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
|
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
@@ -153,8 +156,8 @@ class DetailsActivity :
|
|||||||
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
|
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||||
viewBinding.chipsTags.onChipClickListener = this
|
viewBinding.chipsTags.onChipClickListener = this
|
||||||
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
|
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
|
||||||
viewBinding.containerBottomSheet?.let { BottomSheetBehavior.from(it) }?.let { behavior ->
|
viewBinding.containerBottomSheet?.let { sheet ->
|
||||||
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(behavior))
|
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
|
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
|
||||||
@@ -198,6 +201,8 @@ class DetailsActivity :
|
|||||||
addMenuProvider(menuProvider)
|
addMenuProvider(menuProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.isNsfw == true }
|
||||||
|
|
||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
when (v.id) {
|
when (v.id) {
|
||||||
R.id.button_read -> openReader(isIncognitoMode = false)
|
R.id.button_read -> openReader(isIncognitoMode = false)
|
||||||
@@ -460,10 +465,10 @@ class DetailsActivity :
|
|||||||
imageViewState.isVisible = false
|
imageViewState.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
|
if (manga.source == LocalMangaSource || manga.source == UnknownMangaSource) {
|
||||||
infoLayout.chipSource.isVisible = false
|
infoLayout.chipSource.isVisible = false
|
||||||
} else {
|
} else {
|
||||||
infoLayout.chipSource.text = manga.source.title
|
infoLayout.chipSource.text = manga.source.getTitle(this@DetailsActivity)
|
||||||
infoLayout.chipSource.isVisible = true
|
infoLayout.chipSource.isVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +540,7 @@ class DetailsActivity :
|
|||||||
}
|
}
|
||||||
val isFirstCall = buttonRead.tag == null
|
val isFirstCall = buttonRead.tag == null
|
||||||
buttonRead.tag = Unit
|
buttonRead.tag = Unit
|
||||||
buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, !isFirstCall)
|
buttonRead.setProgress(info.percent.coerceIn(0f, 1f), !isFirstCall)
|
||||||
buttonDownload?.isEnabled = info.isValid && info.canDownload
|
buttonDownload?.isEnabled = info.isValid && info.canDownload
|
||||||
buttonRead.isEnabled = info.isValid
|
buttonRead.isEnabled = info.isValid
|
||||||
}
|
}
|
||||||
@@ -613,10 +618,7 @@ class DetailsActivity :
|
|||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
title = tag.title,
|
title = tag.title,
|
||||||
tint = tagHighlighter.getTagTint(tag),
|
tint = tagHighlighter.getTagTint(tag),
|
||||||
icon = 0,
|
|
||||||
data = tag,
|
data = tag,
|
||||||
isCheckable = false,
|
|
||||||
isChecked = false,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ import kotlinx.coroutines.launch
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ShareHelper
|
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||||
@@ -38,10 +39,10 @@ class DetailsMenuProvider(
|
|||||||
|
|
||||||
override fun onPrepareMenu(menu: Menu) {
|
override fun onPrepareMenu(menu: Menu) {
|
||||||
val manga = viewModel.manga.value
|
val manga = viewModel.manga.value
|
||||||
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
|
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
|
||||||
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
|
menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
|
||||||
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
|
menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource
|
||||||
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != MangaSource.LOCAL
|
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource
|
||||||
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||||
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
|
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
|
||||||
@@ -53,7 +54,7 @@ class DetailsMenuProvider(
|
|||||||
R.id.action_share -> {
|
R.id.action_share -> {
|
||||||
viewModel.manga.value?.let {
|
viewModel.manga.value?.let {
|
||||||
val shareHelper = ShareHelper(activity)
|
val shareHelper = ShareHelper(activity)
|
||||||
if (it.source == MangaSource.LOCAL) {
|
if (it.isLocal) {
|
||||||
shareHelper.shareCbz(listOf(it.url.toUri().toFile()))
|
shareHelper.shareCbz(listOf(it.url.toUri().toFile()))
|
||||||
} else {
|
} else {
|
||||||
shareHelper.shareMangaLink(it)
|
shareHelper.shareMangaLink(it)
|
||||||
|
|||||||
@@ -93,15 +93,19 @@ class DetailsViewModel @Inject constructor(
|
|||||||
|
|
||||||
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
|
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
|
||||||
val manga = details.map { x -> x?.toManga() }
|
val manga = details.map { x -> x?.toManga() }
|
||||||
|
.withErrorHandling()
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
val history = historyRepository.observeOne(mangaId)
|
val history = historyRepository.observeOne(mangaId)
|
||||||
|
.withErrorHandling()
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
val favouriteCategories = interactor.observeFavourite(mangaId)
|
val favouriteCategories = interactor.observeFavourite(mangaId)
|
||||||
|
.withErrorHandling()
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet())
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet())
|
||||||
|
|
||||||
val isStatsAvailable = statsRepository.observeHasStats(mangaId)
|
val isStatsAvailable = statsRepository.observeHasStats(mangaId)
|
||||||
|
.withErrorHandling()
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
val remoteManga = MutableStateFlow<Manga?>(null)
|
val remoteManga = MutableStateFlow<Manga?>(null)
|
||||||
@@ -162,7 +166,7 @@ class DetailsViewModel @Inject constructor(
|
|||||||
|
|
||||||
val onMangaRemoved = MutableEventFlow<Manga>()
|
val onMangaRemoved = MutableEventFlow<Manga>()
|
||||||
val isScrobblingAvailable: Boolean
|
val isScrobblingAvailable: Boolean
|
||||||
get() = scrobblers.any { it.isAvailable }
|
get() = scrobblers.any { it.isEnabled }
|
||||||
|
|
||||||
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
@@ -393,7 +397,7 @@ class DetailsViewModel @Inject constructor(
|
|||||||
private fun getScrobbler(index: Int): Scrobbler? {
|
private fun getScrobbler(index: Int): Scrobbler? {
|
||||||
val info = scrobblingInfo.value.getOrNull(index)
|
val info = scrobblingInfo.value.getOrNull(index)
|
||||||
val scrobbler = if (info != null) {
|
val scrobbler = if (info != null) {
|
||||||
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable }
|
scrobblers.find { it.scrobblerService == info.scrobbler && it.isEnabled }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ data class HistoryInfo(
|
|||||||
|
|
||||||
val canContinue
|
val canContinue
|
||||||
get() = currentChapter >= 0
|
get() = currentChapter >= 0
|
||||||
|
|
||||||
|
val percent: Float
|
||||||
|
get() = if (history != null && (canContinue || isChapterMissing)) {
|
||||||
|
history.percent
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun HistoryInfo(
|
fun HistoryInfo(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
@@ -40,7 +41,6 @@ import org.koitharu.kotatsu.details.ui.withVolumeHeaders
|
|||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
@@ -218,7 +218,7 @@ class ChaptersFragment :
|
|||||||
var canSave = true
|
var canSave = true
|
||||||
var canDelete = true
|
var canDelete = true
|
||||||
items.forEach { (_, x) ->
|
items.forEach { (_, x) ->
|
||||||
val isLocal = x.isDownloaded || x.chapter.source == MangaSource.LOCAL
|
val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
|
||||||
if (isLocal) canSave = false else canDelete = false
|
if (isLocal) canSave = false else canDelete = false
|
||||||
}
|
}
|
||||||
menu.findItem(R.id.action_save).isVisible = canSave
|
menu.findItem(R.id.action_save).isVisible = canSave
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import okhttp3.OkHttpClient
|
|||||||
import okio.Path.Companion.toOkioPath
|
import okio.Path.Companion.toOkioPath
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
|
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
|
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.local.data.isFileUri
|
import org.koitharu.kotatsu.local.data.isFileUri
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||||
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
|
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.format
|
import org.koitharu.kotatsu.parsers.util.format
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
@@ -231,7 +231,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
|||||||
if (manga != null) {
|
if (manga != null) {
|
||||||
DetailsActivity.newIntent(context, manga)
|
DetailsActivity.newIntent(context, manga)
|
||||||
} else {
|
} else {
|
||||||
MangaListActivity.newIntent(context, MangaSource.LOCAL)
|
MangaListActivity.newIntent(context, LocalMangaSource)
|
||||||
},
|
},
|
||||||
PendingIntent.FLAG_CANCEL_CURRENT,
|
PendingIntent.FLAG_CANCEL_CURRENT,
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import okio.sink
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||||
import org.koitharu.kotatsu.core.model.ids
|
import org.koitharu.kotatsu.core.model.ids
|
||||||
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
@@ -177,7 +178,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
|
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
|
||||||
var output: LocalMangaOutput? = null
|
var output: LocalMangaOutput? = null
|
||||||
try {
|
try {
|
||||||
if (manga.source == MangaSource.LOCAL) {
|
if (manga.isLocal) {
|
||||||
manga = localMangaRepository.getRemoteManga(manga)
|
manga = localMangaRepository.getRemoteManga(manga)
|
||||||
?: error("Cannot obtain remote manga instance")
|
?: error("Cannot obtain remote manga instance")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,23 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
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.dao.MangaSourcesDao
|
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.core.model.isNsfw
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||||
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Reusable
|
@Reusable
|
||||||
@@ -29,39 +31,76 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val isNewSourcesAssimilated = AtomicBoolean(false)
|
||||||
private val dao: MangaSourcesDao
|
private val dao: MangaSourcesDao
|
||||||
get() = db.getSourcesDao()
|
get() = db.getSourcesDao()
|
||||||
|
|
||||||
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
|
private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
|
||||||
remove(MangaSource.LOCAL)
|
|
||||||
if (!BuildConfig.DEBUG) {
|
if (!BuildConfig.DEBUG) {
|
||||||
remove(MangaSource.DUMMY)
|
remove(MangaParserSource.DUMMY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val allMangaSources: Set<MangaSource>
|
val allMangaSources: Set<MangaParserSource>
|
||||||
get() = Collections.unmodifiableSet(remoteSources)
|
get() = Collections.unmodifiableSet(remoteSources)
|
||||||
|
|
||||||
suspend fun getEnabledSources(): List<MangaSource> {
|
suspend fun getEnabledSources(): List<MangaSource> {
|
||||||
|
assimilateNewSources()
|
||||||
val order = settings.sourcesSortOrder
|
val order = settings.sourcesSortOrder
|
||||||
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
|
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getDisabledSources(): Set<MangaSource> {
|
suspend fun getDisabledSources(): Set<MangaSource> {
|
||||||
|
assimilateNewSources()
|
||||||
val result = EnumSet.copyOf(remoteSources)
|
val result = EnumSet.copyOf(remoteSources)
|
||||||
val enabled = dao.findAllEnabledNames()
|
val enabled = dao.findAllEnabledNames()
|
||||||
for (name in enabled) {
|
for (name in enabled) {
|
||||||
val source = MangaSource(name)
|
val source = name.toMangaSourceOrNull() ?: continue
|
||||||
result.remove(source)
|
result.remove(source)
|
||||||
}
|
}
|
||||||
if (settings.isNsfwContentDisabled) {
|
|
||||||
result.removeAll { it.isNsfw() }
|
|
||||||
}
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getAvailableSources(
|
||||||
|
isDisabledOnly: Boolean,
|
||||||
|
isNewOnly: Boolean,
|
||||||
|
excludeBroken: Boolean,
|
||||||
|
types: Set<ContentType>,
|
||||||
|
query: String?,
|
||||||
|
locale: String?,
|
||||||
|
sortOrder: SourcesSortOrder?,
|
||||||
|
): List<MangaParserSource> {
|
||||||
|
assimilateNewSources()
|
||||||
|
val entities = dao.findAll().toMutableList()
|
||||||
|
if (isDisabledOnly) {
|
||||||
|
entities.removeAll { it.isEnabled }
|
||||||
|
}
|
||||||
|
if (isNewOnly) {
|
||||||
|
entities.retainAll { it.addedIn == BuildConfig.VERSION_CODE }
|
||||||
|
}
|
||||||
|
val sources = entities.toSources(
|
||||||
|
skipNsfwSources = settings.isNsfwContentDisabled,
|
||||||
|
sortOrder = sortOrder,
|
||||||
|
)
|
||||||
|
if (locale != null) {
|
||||||
|
sources.retainAll { it.locale == locale }
|
||||||
|
}
|
||||||
|
if (excludeBroken) {
|
||||||
|
sources.removeAll { it.isBroken }
|
||||||
|
}
|
||||||
|
if (types.isNotEmpty()) {
|
||||||
|
sources.retainAll { it.contentType in types }
|
||||||
|
}
|
||||||
|
if (!query.isNullOrEmpty()) {
|
||||||
|
sources.retainAll {
|
||||||
|
it.title.contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
fun observeIsEnabled(source: MangaSource): Flow<Boolean> {
|
fun observeIsEnabled(source: MangaSource): Flow<Boolean> {
|
||||||
return dao.observeIsEnabled(source.name)
|
return dao.observeIsEnabled(source.name).onStart { assimilateNewSources() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeEnabledSourcesCount(): Flow<Int> {
|
fun observeEnabledSourcesCount(): Flow<Int> {
|
||||||
@@ -69,8 +108,10 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
observeIsNsfwDisabled(),
|
observeIsNsfwDisabled(),
|
||||||
dao.observeEnabled(SourcesSortOrder.MANUAL),
|
dao.observeEnabled(SourcesSortOrder.MANUAL),
|
||||||
) { skipNsfw, sources ->
|
) { skipNsfw, sources ->
|
||||||
sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() }
|
sources.count {
|
||||||
}.distinctUntilChanged()
|
it.source.toMangaSourceOrNull()?.let { s -> !skipNsfw || !s.isNsfw() } == true
|
||||||
|
}
|
||||||
|
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAvailableSourcesCount(): Flow<Int> {
|
fun observeAvailableSourcesCount(): Flow<Int> {
|
||||||
@@ -82,7 +123,7 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
allMangaSources.count { x ->
|
allMangaSources.count { x ->
|
||||||
x.name !in enabled && (!skipNsfw || !x.isNsfw())
|
x.name !in enabled && (!skipNsfw || !x.isNsfw())
|
||||||
}
|
}
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged().onStart { assimilateNewSources() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
|
fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
|
||||||
@@ -92,18 +133,18 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
dao.observeEnabled(order).map {
|
dao.observeEnabled(order).map {
|
||||||
it.toSources(skipNsfw, order)
|
it.toSources(skipNsfw, order)
|
||||||
}
|
}
|
||||||
}.flatMapLatest { it }
|
}.flatMapLatest { it }.onStart { assimilateNewSources() }
|
||||||
|
|
||||||
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
|
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
|
||||||
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
|
||||||
for (entity in entities) {
|
for (entity in entities) {
|
||||||
val source = MangaSource(entity.source)
|
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||||
if (source in remoteSources) {
|
if (source in remoteSources) {
|
||||||
result.add(source to entity.isEnabled)
|
result.add(source to entity.isEnabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}.onStart { assimilateNewSources() }
|
||||||
|
|
||||||
suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle {
|
suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle {
|
||||||
setSourcesEnabledImpl(sources, isEnabled)
|
setSourcesEnabledImpl(sources, isEnabled)
|
||||||
@@ -114,6 +155,7 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) {
|
suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) {
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
|
assimilateNewSources()
|
||||||
for (s in remoteSources) {
|
for (s in remoteSources) {
|
||||||
dao.setEnabled(s.name, s in sources)
|
dao.setEnabled(s.name, s in sources)
|
||||||
}
|
}
|
||||||
@@ -135,31 +177,34 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest {
|
fun observeHasNewSources(): Flow<Boolean> = observeIsNsfwDisabled().map { skipNsfw ->
|
||||||
if (it) {
|
val sources = dao.findAllFromVersion(BuildConfig.VERSION_CODE).toSources(skipNsfw, null)
|
||||||
combine(
|
sources.isNotEmpty() && sources.size != remoteSources.size
|
||||||
dao.observeAll(),
|
}.onStart { assimilateNewSources() }
|
||||||
observeIsNsfwDisabled(),
|
|
||||||
) { entities, skipNsfw ->
|
fun observeHasNewSourcesForBadge(): Flow<Boolean> = combine(
|
||||||
val result = EnumSet.copyOf(remoteSources)
|
settings.observeAsFlow(AppSettings.KEY_SOURCES_VERSION) { sourcesVersion },
|
||||||
for (e in entities) {
|
observeIsNsfwDisabled(),
|
||||||
result.remove(MangaSource(e.source))
|
) { version, skipNsfw ->
|
||||||
}
|
if (version < BuildConfig.VERSION_CODE) {
|
||||||
if (skipNsfw) {
|
val sources = dao.findAllFromVersion(version).toSources(skipNsfw, null)
|
||||||
result.removeAll { x -> x.isNsfw() }
|
sources.isNotEmpty()
|
||||||
}
|
|
||||||
result
|
|
||||||
}.distinctUntilChanged()
|
|
||||||
} else {
|
} else {
|
||||||
assimilateNewSources()
|
false
|
||||||
flowOf(emptySet())
|
|
||||||
}
|
}
|
||||||
|
}.onStart { assimilateNewSources() }
|
||||||
|
|
||||||
|
fun clearNewSourcesBadge() {
|
||||||
|
settings.sourcesVersion = BuildConfig.VERSION_CODE
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun assimilateNewSources(): Set<MangaSource> {
|
private suspend fun assimilateNewSources(): Boolean {
|
||||||
|
if (isNewSourcesAssimilated.getAndSet(true)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
val new = getNewSources()
|
val new = getNewSources()
|
||||||
if (new.isEmpty()) {
|
if (new.isEmpty()) {
|
||||||
return emptySet()
|
return false
|
||||||
}
|
}
|
||||||
var maxSortKey = dao.getMaxSortKey()
|
var maxSortKey = dao.getMaxSortKey()
|
||||||
val entities = new.map { x ->
|
val entities = new.map { x ->
|
||||||
@@ -167,17 +212,15 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
source = x.name,
|
source = x.name,
|
||||||
isEnabled = false,
|
isEnabled = false,
|
||||||
sortKey = ++maxSortKey,
|
sortKey = ++maxSortKey,
|
||||||
|
addedIn = BuildConfig.VERSION_CODE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
dao.insertIfAbsent(entities)
|
dao.insertIfAbsent(entities)
|
||||||
if (settings.isNsfwContentDisabled) {
|
return true
|
||||||
new.removeAll { x -> x.isNsfw() }
|
|
||||||
}
|
|
||||||
return new
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun isSetupRequired(): Boolean {
|
suspend fun isSetupRequired(): Boolean {
|
||||||
return dao.findAll().isEmpty()
|
return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
|
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
|
||||||
@@ -192,11 +235,11 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getNewSources(): MutableSet<MangaSource> {
|
private suspend fun getNewSources(): MutableSet<out MangaSource> {
|
||||||
val entities = dao.findAll()
|
val entities = dao.findAll()
|
||||||
val result = EnumSet.copyOf(remoteSources)
|
val result = EnumSet.copyOf(remoteSources)
|
||||||
for (e in entities) {
|
for (e in entities) {
|
||||||
result.remove(MangaSource(e.source))
|
result.remove(e.source.toMangaSourceOrNull() ?: continue)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -204,10 +247,10 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
private fun List<MangaSourceEntity>.toSources(
|
private fun List<MangaSourceEntity>.toSources(
|
||||||
skipNsfwSources: Boolean,
|
skipNsfwSources: Boolean,
|
||||||
sortOrder: SourcesSortOrder?,
|
sortOrder: SourcesSortOrder?,
|
||||||
): List<MangaSource> {
|
): MutableList<MangaParserSource> {
|
||||||
val result = ArrayList<MangaSource>(size)
|
val result = ArrayList<MangaParserSource>(size)
|
||||||
for (entity in this) {
|
for (entity in this) {
|
||||||
val source = MangaSource(entity.source)
|
val source = entity.source.toMangaSourceOrNull() ?: continue
|
||||||
if (skipNsfwSources && source.isNsfw()) {
|
if (skipNsfwSources && source.isNsfw()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -225,11 +268,9 @@ class MangaSourcesRepository @Inject constructor(
|
|||||||
isNsfwContentDisabled
|
isNsfwContentDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
|
|
||||||
isNewSourcesTipEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) {
|
private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) {
|
||||||
sourcesSortOrder
|
sourcesSortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.explore.domain
|
package org.koitharu.kotatsu.explore.domain
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.model.isNsfw
|
||||||
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
|
||||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||||
@@ -7,7 +8,6 @@ import org.koitharu.kotatsu.core.util.ext.asArrayList
|
|||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
@@ -45,7 +45,7 @@ class ExploreRepository @Inject constructor(
|
|||||||
|
|
||||||
suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga {
|
suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga {
|
||||||
val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f)
|
val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f)
|
||||||
val skipNsfw = settings.isSuggestionsExcludeNsfw && source.contentType != ContentType.HENTAI
|
val skipNsfw = settings.isSuggestionsExcludeNsfw && !source.isNsfw()
|
||||||
val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
|
val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
|
||||||
if (it in tagsBlacklist) null else it.title
|
if (it in tagsBlacklist) null else it.title
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||||
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
|
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
@@ -27,7 +28,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|||||||
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||||
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
|
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
@@ -40,13 +40,11 @@ import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
|
|||||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
import org.koitharu.kotatsu.list.ui.model.TipModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
|
||||||
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
||||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -56,7 +54,7 @@ class ExploreFragment :
|
|||||||
BaseFragment<FragmentExploreBinding>(),
|
BaseFragment<FragmentExploreBinding>(),
|
||||||
RecyclerViewOwner,
|
RecyclerViewOwner,
|
||||||
ExploreListEventListener,
|
ExploreListEventListener,
|
||||||
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener, ListSelectionController.Callback2 {
|
OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback2 {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var coil: ImageLoader
|
lateinit var coil: ImageLoader
|
||||||
@@ -74,7 +72,7 @@ class ExploreFragment :
|
|||||||
|
|
||||||
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view ->
|
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this) { manga, view ->
|
||||||
startActivity(DetailsActivity.newIntent(view.context, manga))
|
startActivity(DetailsActivity.newIntent(view.context, manga))
|
||||||
}
|
}
|
||||||
sourceSelectionController = ListSelectionController(
|
sourceSelectionController = ListSelectionController(
|
||||||
@@ -124,21 +122,9 @@ class ExploreFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrimaryButtonClick(tipView: TipView) {
|
|
||||||
when ((tipView.tag as? TipModel)?.key) {
|
|
||||||
ExploreViewModel.TIP_NEW_SOURCES -> NewSourcesDialogFragment.show(childFragmentManager)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSecondaryButtonClick(tipView: TipView) {
|
|
||||||
when ((tipView.tag as? TipModel)?.key) {
|
|
||||||
ExploreViewModel.TIP_NEW_SOURCES -> viewModel.discardNewSources()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
val intent = when (v.id) {
|
val intent = when (v.id) {
|
||||||
R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL)
|
R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
|
||||||
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
|
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
|
||||||
R.id.button_more -> SuggestionsActivity.newIntent(v.context)
|
R.id.button_more -> SuggestionsActivity.newIntent(v.context)
|
||||||
R.id.button_downloads -> DownloadsActivity.newIntent(v.context)
|
R.id.button_downloads -> DownloadsActivity.newIntent(v.context)
|
||||||
@@ -188,7 +174,7 @@ class ExploreFragment :
|
|||||||
|
|
||||||
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
|
||||||
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id ->
|
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id ->
|
||||||
MangaSource.entries.getOrNull(id.toInt())
|
MangaParserSource.entries.getOrNull(id.toInt()) // TODO
|
||||||
}
|
}
|
||||||
if (selectedSources.isEmpty()) {
|
if (selectedSources.isEmpty()) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -102,12 +102,6 @@ class ExploreViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun discardNewSources() {
|
|
||||||
launchJob(Dispatchers.Default) {
|
|
||||||
sourcesRepository.assimilateNewSources()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestPinShortcut(source: MangaSource) {
|
fun requestPinShortcut(source: MangaSource) {
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
shortcutManager.requestPinShortcut(source)
|
shortcutManager.requestPinShortcut(source)
|
||||||
@@ -124,7 +118,7 @@ class ExploreViewModel @Inject constructor(
|
|||||||
getSuggestionFlow(),
|
getSuggestionFlow(),
|
||||||
isGrid,
|
isGrid,
|
||||||
isRandomLoading,
|
isRandomLoading,
|
||||||
sourcesRepository.observeNewSources(),
|
sourcesRepository.observeHasNewSourcesForBadge(),
|
||||||
) { content, suggestions, grid, randomLoading, newSources ->
|
) { content, suggestions, grid, randomLoading, newSources ->
|
||||||
buildList(content, suggestions, grid, randomLoading, newSources)
|
buildList(content, suggestions, grid, randomLoading, newSources)
|
||||||
}.withErrorHandling()
|
}.withErrorHandling()
|
||||||
@@ -134,7 +128,7 @@ class ExploreViewModel @Inject constructor(
|
|||||||
recommendation: List<Manga>,
|
recommendation: List<Manga>,
|
||||||
isGrid: Boolean,
|
isGrid: Boolean,
|
||||||
randomLoading: Boolean,
|
randomLoading: Boolean,
|
||||||
newSources: Set<MangaSource>,
|
hasNewSources: Boolean,
|
||||||
): List<ListModel> {
|
): List<ListModel> {
|
||||||
val result = ArrayList<ListModel>(sources.size + 3)
|
val result = ArrayList<ListModel>(sources.size + 3)
|
||||||
result += ExploreButtons(randomLoading)
|
result += ExploreButtons(randomLoading)
|
||||||
@@ -146,7 +140,7 @@ class ExploreViewModel @Inject constructor(
|
|||||||
result += ListHeader(
|
result += ListHeader(
|
||||||
textRes = R.string.remote_sources,
|
textRes = R.string.remote_sources,
|
||||||
buttonTextRes = R.string.catalog,
|
buttonTextRes = R.string.catalog,
|
||||||
badge = if (newSources.isNotEmpty()) "" else null,
|
badge = if (hasNewSources) "" else null,
|
||||||
)
|
)
|
||||||
sources.mapTo(result) { MangaSourceItem(it, isGrid) }
|
sources.mapTo(result) { MangaSourceItem(it, isGrid) }
|
||||||
} else {
|
} else {
|
||||||
@@ -191,6 +185,5 @@ class ExploreViewModel @Inject constructor(
|
|||||||
|
|
||||||
private const val TIP_SUGGESTIONS = "suggestions"
|
private const val TIP_SUGGESTIONS = "suggestions"
|
||||||
private const val SUGGESTIONS_COUNT = 8
|
private const val SUGGESTIONS_COUNT = 8
|
||||||
const val TIP_NEW_SOURCES = "new_sources"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
|
||||||
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
|
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.tipAD
|
|
||||||
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
|
||||||
|
|
||||||
@@ -18,7 +16,6 @@ class ExploreAdapter(
|
|||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
listener: ExploreListEventListener,
|
listener: ExploreListEventListener,
|
||||||
tipClickListener: TipView.OnButtonClickListener,
|
|
||||||
clickListener: OnListItemClickListener<MangaSourceItem>,
|
clickListener: OnListItemClickListener<MangaSourceItem>,
|
||||||
mangaClickListener: OnListItemClickListener<Manga>,
|
mangaClickListener: OnListItemClickListener<Manga>,
|
||||||
) : BaseListAdapter<ListModel>() {
|
) : BaseListAdapter<ListModel>() {
|
||||||
@@ -34,6 +31,5 @@ class ExploreAdapter(
|
|||||||
addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
|
addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
|
||||||
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
|
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
addDelegate(ListItemType.TIP, tipAD(tipClickListener))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.explore.ui.model
|
package org.koitharu.kotatsu.explore.ui.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
@@ -8,8 +9,7 @@ data class MangaSourceItem(
|
|||||||
val isGrid: Boolean,
|
val isGrid: Boolean,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
val id: Long
|
val id: Long = source.name.longHashCode()
|
||||||
get() = source.ordinal.toLong()
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
return other is MangaSourceItem && other.source == source
|
return other is MangaSourceItem && other.source == source
|
||||||
|
|||||||
@@ -27,15 +27,20 @@ abstract class FavouritesDao {
|
|||||||
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
|
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
|
||||||
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
|
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
|
||||||
|
|
||||||
fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> {
|
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||||
val orderBy = getOrderBy(order)
|
val orderBy = getOrderBy(order)
|
||||||
|
val query = buildString {
|
||||||
@Language("RoomSql")
|
append(
|
||||||
val query = SimpleSQLiteQuery(
|
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||||
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
)
|
||||||
)
|
append(orderBy)
|
||||||
return observeAllImpl(query)
|
if (limit > 0) {
|
||||||
|
append(" LIMIT ")
|
||||||
|
append(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@@ -52,16 +57,21 @@ abstract class FavouritesDao {
|
|||||||
)
|
)
|
||||||
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
|
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
|
||||||
|
|
||||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> {
|
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
|
||||||
val orderBy = getOrderBy(order)
|
val orderBy = getOrderBy(order)
|
||||||
|
val query = buildString {
|
||||||
|
append(
|
||||||
|
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
||||||
|
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
|
||||||
|
)
|
||||||
|
append(orderBy)
|
||||||
|
if (limit > 0) {
|
||||||
|
append(" LIMIT ")
|
||||||
|
append(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Language("RoomSql")
|
return observeAllImpl(SimpleSQLiteQuery(query, arrayOf<Any>(categoryId)))
|
||||||
val query = SimpleSQLiteQuery(
|
|
||||||
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
|
|
||||||
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
|
|
||||||
arrayOf<Any>(categoryId),
|
|
||||||
)
|
|
||||||
return observeAllImpl(query)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
|
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ class FavouritesRepository @Inject constructor(
|
|||||||
return entities.toMangaList()
|
return entities.toMangaList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAll(order: ListSortOrder): Flow<List<Manga>> {
|
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||||
return db.getFavouritesDao().observeAll(order)
|
return db.getFavouritesDao().observeAll(order, limit)
|
||||||
.mapItems { it.toManga() }
|
.mapItems { it.toManga() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,14 +48,14 @@ class FavouritesRepository @Inject constructor(
|
|||||||
return entities.toMangaList()
|
return entities.toMangaList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> {
|
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<Manga>> {
|
||||||
return db.getFavouritesDao().observeAll(categoryId, order)
|
return db.getFavouritesDao().observeAll(categoryId, order, limit)
|
||||||
.mapItems { it.toManga() }
|
.mapItems { it.toManga() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAll(categoryId: Long): Flow<List<Manga>> {
|
fun observeAll(categoryId: Long, limit: Int): Flow<List<Manga>> {
|
||||||
return observeOrder(categoryId)
|
return observeOrder(categoryId)
|
||||||
.flatMapLatest { order -> observeAll(categoryId, order) }
|
.flatMapLatest { order -> observeAll(categoryId, order, limit) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeMangaCount(): Flow<Int> {
|
fun observeMangaCount(): Flow<Int> {
|
||||||
@@ -63,12 +63,6 @@ class FavouritesRepository @Inject constructor(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCategories(): List<FavouriteCategory> {
|
|
||||||
return db.getFavouriteCategoriesDao().findAll().map {
|
|
||||||
it.toFavouriteCategory()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun observeCategories(): Flow<List<FavouriteCategory>> {
|
fun observeCategories(): Flow<List<FavouriteCategory>> {
|
||||||
return db.getFavouriteCategoriesDao().observeAll().mapItems {
|
return db.getFavouriteCategoriesDao().observeAll().mapItems {
|
||||||
it.toFavouriteCategory()
|
it.toFavouriteCategory()
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.favourites.domain.model
|
package org.koitharu.kotatsu.favourites.domain.model
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.find
|
|
||||||
|
|
||||||
data class Cover(
|
data class Cover(
|
||||||
val url: String,
|
val url: String,
|
||||||
val source: String,
|
val source: String,
|
||||||
) {
|
) {
|
||||||
val mangaSource: MangaSource?
|
val mangaSource by lazy { MangaSource(source) }
|
||||||
get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
|
|||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
|
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
|
||||||
@@ -33,7 +33,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
|
|||||||
binding.recyclerView.isVP2BugWorkaroundEnabled = true
|
binding.recyclerView.isVP2BugWorkaroundEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||||
|
|
||||||
override fun onFilterClick(view: View?) {
|
override fun onFilterClick(view: View?) {
|
||||||
val menu = PopupMenu(view?.context ?: return, view)
|
val menu = PopupMenu(view?.context ?: return, view)
|
||||||
@@ -57,9 +57,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
|
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
|
||||||
it.source == MangaSource.LOCAL
|
|
||||||
}
|
|
||||||
return super.onPrepareActionMode(controller, mode, menu)
|
return super.onPrepareActionMode(controller, mode, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
|
|||||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||||
import org.koitharu.kotatsu.list.ui.model.toUi
|
import org.koitharu.kotatsu.list.ui.model.toUi
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 20
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class FavouritesListViewModel @Inject constructor(
|
class FavouritesListViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
@@ -46,6 +49,8 @@ class FavouritesListViewModel @Inject constructor(
|
|||||||
|
|
||||||
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
||||||
private val refreshTrigger = MutableStateFlow(Any())
|
private val refreshTrigger = MutableStateFlow(Any())
|
||||||
|
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||||
|
private val isReady = AtomicBoolean(false)
|
||||||
|
|
||||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
|
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
|
||||||
@@ -61,13 +66,7 @@ class FavouritesListViewModel @Inject constructor(
|
|||||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
override val content = combine(
|
override val content = combine(
|
||||||
if (categoryId == NO_ID) {
|
observeFavorites(),
|
||||||
sortOrder.filterNotNull().flatMapLatest {
|
|
||||||
repository.observeAll(it)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
repository.observeAll(categoryId)
|
|
||||||
},
|
|
||||||
listMode,
|
listMode,
|
||||||
refreshTrigger,
|
refreshTrigger,
|
||||||
) { list, mode, _ ->
|
) { list, mode, _ ->
|
||||||
@@ -85,7 +84,10 @@ class FavouritesListViewModel @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> list.toUi(mode, listExtraProvider)
|
else -> {
|
||||||
|
isReady.set(true)
|
||||||
|
list.toUi(mode, listExtraProvider)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.catch {
|
}.catch {
|
||||||
emit(listOf(it.toErrorState(canRetry = false)))
|
emit(listOf(it.toErrorState(canRetry = false)))
|
||||||
@@ -126,4 +128,19 @@ class FavouritesListViewModel @Inject constructor(
|
|||||||
repository.setCategoryOrder(categoryId, order)
|
repository.setCategoryOrder(categoryId, order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun requestMoreItems() {
|
||||||
|
if (isReady.compareAndSet(true, false)) {
|
||||||
|
limit.value += PAGE_SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeFavorites() = if (categoryId == NO_ID) {
|
||||||
|
combine(sortOrder.filterNotNull(), limit, ::Pair)
|
||||||
|
.flatMapLatest { repository.observeAll(it.first, it.second) }
|
||||||
|
} else {
|
||||||
|
limit.flatMapLatest {
|
||||||
|
repository.observeAll(categoryId, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
|
|||||||
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
|
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
@@ -257,7 +258,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
}
|
}
|
||||||
oldValue.copy(
|
oldValue.copy(
|
||||||
tagsExclude = newTags,
|
tagsExclude = newTags,
|
||||||
tags = oldValue.tags - newTags
|
tags = oldValue.tags - newTags,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,7 +309,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
currentState.update { oldValue ->
|
currentState.update { oldValue ->
|
||||||
oldValue.copy(
|
oldValue.copy(
|
||||||
tags = tags,
|
tags = tags,
|
||||||
tagsExclude = oldValue.tagsExclude - tags
|
tagsExclude = oldValue.tagsExclude - tags,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,9 +392,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
val result = LinkedList<ChipsView.ChipModel>()
|
val result = LinkedList<ChipsView.ChipModel>()
|
||||||
for (tag in tags) {
|
for (tag in tags) {
|
||||||
val model = ChipsView.ChipModel(
|
val model = ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = tag.title,
|
title = tag.title,
|
||||||
icon = 0,
|
|
||||||
isCheckable = true,
|
isCheckable = true,
|
||||||
isChecked = selectedTags.remove(tag),
|
isChecked = selectedTags.remove(tag),
|
||||||
data = tag,
|
data = tag,
|
||||||
@@ -406,9 +405,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
}
|
}
|
||||||
for (tag in selectedTags) {
|
for (tag in selectedTags) {
|
||||||
val model = ChipsView.ChipModel(
|
val model = ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = tag.title,
|
title = tag.title,
|
||||||
icon = 0,
|
|
||||||
isCheckable = true,
|
isCheckable = true,
|
||||||
isChecked = true,
|
isChecked = true,
|
||||||
data = tag,
|
data = tag,
|
||||||
@@ -455,7 +452,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
|
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
|
||||||
val result = TreeSet(TagTitleComparator(repository.source.locale))
|
val result = TreeSet(TagTitleComparator((repository.source as? MangaParserSource)?.locale))
|
||||||
result.addAll(secondary)
|
result.addAll(secondary)
|
||||||
result.addAll(primary)
|
result.addAll(primary)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun moreTagsChip() = ChipsView.ChipModel(
|
private fun moreTagsChip() = ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = getString(R.string.more),
|
title = getString(R.string.more),
|
||||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||||
isCheckable = false,
|
|
||||||
isChecked = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.ui.model.titleRes
|
|||||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||||
@@ -29,7 +30,6 @@ import org.koitharu.kotatsu.parsers.model.ContentRating
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -122,10 +122,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
b.spinnerLocale.context,
|
b.spinnerLocale.context,
|
||||||
android.R.layout.simple_spinner_dropdown_item,
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
android.R.id.text1,
|
android.R.id.text1,
|
||||||
value.availableItems.map {
|
value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
|
||||||
it?.getDisplayLanguage(it)?.toTitleCase(it)
|
|
||||||
?: b.spinnerLocale.context.getString(R.string.various_languages)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
val selectedIndex = value.availableItems.indexOf(selected)
|
val selectedIndex = value.availableItems.indexOf(selected)
|
||||||
if (selectedIndex >= 0) {
|
if (selectedIndex >= 0) {
|
||||||
@@ -144,9 +141,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
|
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
|
||||||
value.selectedItems.mapTo(chips) { tag ->
|
value.selectedItems.mapTo(chips) { tag ->
|
||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = tag.title,
|
title = tag.title,
|
||||||
icon = 0,
|
|
||||||
isCheckable = true,
|
isCheckable = true,
|
||||||
isChecked = true,
|
isChecked = true,
|
||||||
data = tag,
|
data = tag,
|
||||||
@@ -155,9 +150,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
value.availableItems.mapNotNullTo(chips) { tag ->
|
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||||
if (tag !in value.selectedItems) {
|
if (tag !in value.selectedItems) {
|
||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = tag.title,
|
title = tag.title,
|
||||||
icon = 0,
|
|
||||||
isCheckable = true,
|
isCheckable = true,
|
||||||
isChecked = false,
|
isChecked = false,
|
||||||
data = tag,
|
data = tag,
|
||||||
@@ -168,12 +161,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
}
|
}
|
||||||
chips.add(
|
chips.add(
|
||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = getString(R.string.more),
|
title = getString(R.string.more),
|
||||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||||
isCheckable = false,
|
|
||||||
isChecked = false,
|
|
||||||
data = null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
b.chipsGenres.setChips(chips)
|
b.chipsGenres.setChips(chips)
|
||||||
@@ -200,9 +189,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
value.availableItems.mapNotNullTo(chips) { tag ->
|
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||||
if (tag !in value.selectedItems) {
|
if (tag !in value.selectedItems) {
|
||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = tag.title,
|
title = tag.title,
|
||||||
icon = 0,
|
|
||||||
isCheckable = true,
|
isCheckable = true,
|
||||||
isChecked = false,
|
isChecked = false,
|
||||||
data = tag,
|
data = tag,
|
||||||
@@ -213,12 +200,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
}
|
}
|
||||||
chips.add(
|
chips.add(
|
||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = getString(R.string.more),
|
title = getString(R.string.more),
|
||||||
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||||
isCheckable = false,
|
|
||||||
isChecked = false,
|
|
||||||
data = null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
b.chipsGenresExclude.setChips(chips)
|
b.chipsGenresExclude.setChips(chips)
|
||||||
@@ -233,9 +216,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
}
|
}
|
||||||
val chips = value.availableItems.map { state ->
|
val chips = value.availableItems.map { state ->
|
||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = getString(state.titleResId),
|
title = getString(state.titleResId),
|
||||||
icon = 0,
|
|
||||||
isCheckable = true,
|
isCheckable = true,
|
||||||
isChecked = state in value.selectedItems,
|
isChecked = state in value.selectedItems,
|
||||||
data = state,
|
data = state,
|
||||||
@@ -253,9 +234,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
}
|
}
|
||||||
val chips = value.availableItems.map { contentRating ->
|
val chips = value.availableItems.map { contentRating ->
|
||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = 0,
|
|
||||||
title = getString(contentRating.titleResId),
|
title = getString(contentRating.titleResId),
|
||||||
icon = 0,
|
|
||||||
isCheckable = true,
|
isCheckable = true,
|
||||||
isChecked = contentRating in value.selectedItems,
|
isChecked = contentRating in value.selectedItems,
|
||||||
data = contentRating,
|
data = contentRating,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.room.Transaction
|
|||||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.intellij.lang.annotations.Language
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||||
|
|
||||||
@@ -28,8 +27,7 @@ abstract class HistoryDao {
|
|||||||
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
|
||||||
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
|
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
|
||||||
|
|
||||||
// TODO pagination
|
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<HistoryWithManga>> {
|
||||||
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
|
|
||||||
val orderBy = when (order) {
|
val orderBy = when (order) {
|
||||||
ListSortOrder.LAST_READ -> "history.updated_at DESC"
|
ListSortOrder.LAST_READ -> "history.updated_at DESC"
|
||||||
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
|
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
|
||||||
@@ -43,13 +41,18 @@ abstract class HistoryDao {
|
|||||||
ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
|
ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
|
||||||
else -> throw IllegalArgumentException("Sort order $order is not supported")
|
else -> throw IllegalArgumentException("Sort order $order is not supported")
|
||||||
}
|
}
|
||||||
|
val query = buildString {
|
||||||
@Language("RoomSql")
|
append(
|
||||||
val query = SimpleSQLiteQuery(
|
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
||||||
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
|
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY ",
|
||||||
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy",
|
)
|
||||||
)
|
append(orderBy)
|
||||||
return observeAllImpl(query)
|
if (limit > 0) {
|
||||||
|
append(" LIMIT ")
|
||||||
|
append(limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return observeAllImpl(SimpleSQLiteQuery(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query("SELECT manga_id FROM history WHERE deleted_at = 0")
|
@Query("SELECT manga_id FROM history WHERE deleted_at = 0")
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ class HistoryRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeAllWithHistory(order: ListSortOrder): Flow<List<MangaWithHistory>> {
|
fun observeAllWithHistory(order: ListSortOrder, limit: Int): Flow<List<MangaWithHistory>> {
|
||||||
return db.getHistoryDao().observeAll(order).mapItems {
|
return db.getHistoryDao().observeAll(order, limit).mapItems {
|
||||||
MangaWithHistory(
|
MangaWithHistory(
|
||||||
it.manga.toManga(it.tags.toMangaTags()),
|
it.manga.toManga(it.tags.toMangaTags()),
|
||||||
it.history.toMangaHistory(),
|
it.history.toMangaHistory(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.fragment.app.viewModels
|
|||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.os.NetworkManageIntent
|
import org.koitharu.kotatsu.core.os.NetworkManageIntent
|
||||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||||
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
|
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
|
||||||
@@ -17,7 +18,6 @@ import org.koitharu.kotatsu.core.util.ext.observe
|
|||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class HistoryListFragment : MangaListFragment() {
|
class HistoryListFragment : MangaListFragment() {
|
||||||
@@ -32,7 +32,7 @@ class HistoryListFragment : MangaListFragment() {
|
|||||||
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onScrolledToEnd() = Unit
|
override fun onScrolledToEnd() = viewModel.requestMoreItems()
|
||||||
|
|
||||||
override fun onEmptyActionClick() {
|
override fun onEmptyActionClick() {
|
||||||
startActivity(NetworkManageIntent())
|
startActivity(NetworkManageIntent())
|
||||||
@@ -44,9 +44,7 @@ class HistoryListFragment : MangaListFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
|
||||||
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
|
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
|
||||||
it.source == MangaSource.LOCAL
|
|
||||||
}
|
|
||||||
return super.onPrepareActionMode(controller, mode, menu)
|
return super.onPrepareActionMode(controller, mode, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.history.ui
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -43,8 +44,11 @@ import org.koitharu.kotatsu.list.ui.model.toListModel
|
|||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 20
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HistoryListViewModel @Inject constructor(
|
class HistoryListViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
@@ -62,8 +66,11 @@ class HistoryListViewModel @Inject constructor(
|
|||||||
valueProducer = { historySortOrder },
|
valueProducer = { historySortOrder },
|
||||||
)
|
)
|
||||||
|
|
||||||
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_HISTORY) { historyListMode }
|
override val listMode = settings.observeAsStateFlow(
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.historyListMode)
|
scope = viewModelScope + Dispatchers.Default,
|
||||||
|
key = AppSettings.KEY_LIST_MODE_HISTORY,
|
||||||
|
valueProducer = { historyListMode },
|
||||||
|
)
|
||||||
|
|
||||||
private val isGroupingEnabled = settings.observeAsFlow(
|
private val isGroupingEnabled = settings.observeAsFlow(
|
||||||
key = AppSettings.KEY_HISTORY_GROUPING,
|
key = AppSettings.KEY_HISTORY_GROUPING,
|
||||||
@@ -72,6 +79,9 @@ class HistoryListViewModel @Inject constructor(
|
|||||||
g && s.isGroupingSupported()
|
g && s.isGroupingSupported()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||||
|
private val isReady = AtomicBoolean(false)
|
||||||
|
|
||||||
val isStatsEnabled = settings.observeAsStateFlow(
|
val isStatsEnabled = settings.observeAsStateFlow(
|
||||||
scope = viewModelScope + Dispatchers.Default,
|
scope = viewModelScope + Dispatchers.Default,
|
||||||
key = AppSettings.KEY_STATS_ENABLED,
|
key = AppSettings.KEY_STATS_ENABLED,
|
||||||
@@ -79,7 +89,7 @@ class HistoryListViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
override val content = combine(
|
override val content = combine(
|
||||||
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
|
observeHistory(),
|
||||||
isGroupingEnabled,
|
isGroupingEnabled,
|
||||||
listMode,
|
listMode,
|
||||||
networkState,
|
networkState,
|
||||||
@@ -95,7 +105,10 @@ class HistoryListViewModel @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> mapList(list, grouped, mode, online, incognito)
|
else -> {
|
||||||
|
isReady.set(true)
|
||||||
|
mapList(list, grouped, mode, online, incognito)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.onStart {
|
}.onStart {
|
||||||
loadingCounter.increment()
|
loadingCounter.increment()
|
||||||
@@ -138,6 +151,15 @@ class HistoryListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun requestMoreItems() {
|
||||||
|
if (isReady.compareAndSet(true, false)) {
|
||||||
|
limit.value += PAGE_SIZE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeHistory() = combine(sortOrder, limit, ::Pair)
|
||||||
|
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second) }
|
||||||
|
|
||||||
private suspend fun mapList(
|
private suspend fun mapList(
|
||||||
list: List<MangaWithHistory>,
|
list: List<MangaWithHistory>,
|
||||||
grouped: Boolean,
|
grouped: Boolean,
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import android.net.Uri
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.activity.viewModels
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.marginBottom
|
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.CachePolicy
|
import coil.request.CachePolicy
|
||||||
import coil.request.ErrorResult
|
import coil.request.ErrorResult
|
||||||
@@ -20,17 +21,26 @@ import coil.request.SuccessResult
|
|||||||
import coil.target.ViewTarget
|
import coil.target.ViewTarget
|
||||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
|
||||||
|
import org.koitharu.kotatsu.core.util.ShareHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
|
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
||||||
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
|
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listener, View.OnClickListener {
|
class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listener, View.OnClickListener {
|
||||||
@@ -39,27 +49,45 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
|||||||
lateinit var coil: ImageLoader
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
private var errorBinding: ItemErrorStateBinding? = null
|
private var errorBinding: ItemErrorStateBinding? = null
|
||||||
|
private val viewModel: ImageViewModel by viewModels()
|
||||||
|
private lateinit var menuMediator: PopupMenuMediator
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
||||||
viewBinding.buttonBack.setOnClickListener(this)
|
viewBinding.buttonBack.setOnClickListener(this)
|
||||||
loadImage(intent.data)
|
viewBinding.buttonMenu.setOnClickListener(this)
|
||||||
|
val imageUrl = requireNotNull(intent.data)
|
||||||
|
|
||||||
|
val menuProvider = ImageMenuProvider(
|
||||||
|
activity = this,
|
||||||
|
snackbarHost = viewBinding.root,
|
||||||
|
viewModel = viewModel,
|
||||||
|
)
|
||||||
|
menuMediator = PopupMenuMediator(menuProvider)
|
||||||
|
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
|
||||||
|
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null))
|
||||||
|
viewModel.onImageSaved.observeEvent(this, ::onImageSaved)
|
||||||
|
loadImage(imageUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
with(viewBinding.buttonBack) {
|
viewBinding.buttonBack.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
topMargin = insets.top + bottomMargin
|
||||||
topMargin = insets.top + marginBottom
|
leftMargin = insets.left + bottomMargin
|
||||||
leftMargin = insets.left + marginBottom
|
rightMargin = insets.right + bottomMargin
|
||||||
rightMargin = insets.right + marginBottom
|
}
|
||||||
}
|
viewBinding.buttonMenu.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
|
topMargin = insets.top + bottomMargin
|
||||||
|
leftMargin = insets.left + bottomMargin
|
||||||
|
rightMargin = insets.right + bottomMargin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
when (v.id) {
|
when (v.id) {
|
||||||
R.id.button_back -> dispatchNavigateUp()
|
R.id.button_back -> dispatchNavigateUp()
|
||||||
|
R.id.button_menu -> menuMediator.onLongClick(v)
|
||||||
else -> loadImage(intent.data)
|
else -> loadImage(intent.data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,11 +120,34 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
|||||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||||
.lifecycle(this)
|
.lifecycle(this)
|
||||||
.listener(this)
|
.listener(this)
|
||||||
.tag(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE))
|
.source(MangaSource(intent.getStringExtra(EXTRA_SOURCE)))
|
||||||
.target(SsivTarget(viewBinding.ssiv))
|
.target(SsivTarget(viewBinding.ssiv))
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onImageSaved(uri: Uri) {
|
||||||
|
Snackbar.make(viewBinding.root, R.string.page_saved, Snackbar.LENGTH_LONG)
|
||||||
|
.setAction(R.string.share) {
|
||||||
|
ShareHelper(this).shareImage(uri)
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
|
val button = viewBinding.buttonMenu
|
||||||
|
button.isClickable = !isLoading
|
||||||
|
if (isLoading) {
|
||||||
|
button.setImageDrawable(
|
||||||
|
CircularProgressDrawable(this).also {
|
||||||
|
it.setStyle(CircularProgressDrawable.LARGE)
|
||||||
|
it.setColorSchemeColors(getThemeColor(com.google.android.material.R.attr.colorControlNormal))
|
||||||
|
it.start()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
button.setImageResource(materialR.drawable.abc_ic_menu_overflow_material)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class SsivTarget(
|
private class SsivTarget(
|
||||||
override val view: SubsamplingScaleImageView,
|
override val view: SubsamplingScaleImageView,
|
||||||
) : ViewTarget<SubsamplingScaleImageView> {
|
) : ViewTarget<SubsamplingScaleImageView> {
|
||||||
@@ -124,12 +175,12 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val EXTRA_SOURCE = "source"
|
const val EXTRA_SOURCE = "source"
|
||||||
|
|
||||||
fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
|
fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
|
||||||
return Intent(context, ImageActivity::class.java)
|
return Intent(context, ImageActivity::class.java)
|
||||||
.setData(Uri.parse(url))
|
.setData(Uri.parse(url))
|
||||||
.putExtra(EXTRA_SOURCE, source)
|
.putExtra(EXTRA_SOURCE, source?.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package org.koitharu.kotatsu.image.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||||
|
import org.koitharu.kotatsu.local.data.isZipUri
|
||||||
|
|
||||||
|
class ImageMenuProvider(
|
||||||
|
private val activity: ComponentActivity,
|
||||||
|
private val snackbarHost: View,
|
||||||
|
private val viewModel: ImageViewModel,
|
||||||
|
) : MenuProvider {
|
||||||
|
|
||||||
|
private val permissionLauncher = activity.registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { isGranted ->
|
||||||
|
if (isGranted) {
|
||||||
|
saveImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val saveLauncher = activity.registerForActivityResult(
|
||||||
|
ActivityResultContracts.CreateDocument("image/png"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
viewModel.saveImage(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menuInflater.inflate(R.menu.opt_image, menu)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||||
|
R.id.action_save -> {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
} else {
|
||||||
|
saveImage()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveImage() {
|
||||||
|
val name = activity.intent.data?.let {
|
||||||
|
if (it.isZipUri()) {
|
||||||
|
it.fragment
|
||||||
|
} else {
|
||||||
|
it.lastPathSegment
|
||||||
|
}?.substringBeforeLast('.')?.plus(".png")
|
||||||
|
}
|
||||||
|
if (name == null || !saveLauncher.tryLaunch(name)) {
|
||||||
|
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package org.koitharu.kotatsu.image.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.CachePolicy
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ImageViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val savedStateHandle: SavedStateHandle,
|
||||||
|
private val coil: ImageLoader,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val onImageSaved = MutableEventFlow<Uri>()
|
||||||
|
|
||||||
|
fun saveImage(destination: Uri) {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
val request = ImageRequest.Builder(context)
|
||||||
|
.memoryCachePolicy(CachePolicy.READ_ONLY)
|
||||||
|
.data(savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA))
|
||||||
|
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||||
|
.source(savedStateHandle[ImageActivity.EXTRA_SOURCE])
|
||||||
|
.build()
|
||||||
|
val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap()
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
context.contentResolver.openOutputStream(destination)?.use { output ->
|
||||||
|
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
|
||||||
|
} ?: error("Cannot open output stream")
|
||||||
|
}
|
||||||
|
onImageSaved.call(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,9 +37,6 @@ suspend fun Manga.toListDetailedModel(
|
|||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
tint = extraProvider?.getTagTint(it) ?: 0,
|
tint = extraProvider?.getTagTint(it) ?: 0,
|
||||||
title = it.title,
|
title = it.title,
|
||||||
icon = 0,
|
|
||||||
isCheckable = false,
|
|
||||||
isChecked = false,
|
|
||||||
data = it,
|
data = it,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,10 +85,7 @@ class PreviewViewModel @Inject constructor(
|
|||||||
ChipsView.ChipModel(
|
ChipsView.ChipModel(
|
||||||
title = tag.title,
|
title = tag.title,
|
||||||
tint = extraProvider.getTagTint(tag),
|
tint = extraProvider.getTagTint(tag),
|
||||||
icon = 0,
|
|
||||||
data = tag,
|
data = tag,
|
||||||
isCheckable = false,
|
|
||||||
isChecked = false,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.channelFlow
|
|||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
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
|
||||||
@@ -29,7 +30,6 @@ 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.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
@@ -49,7 +49,7 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) : MangaRepository {
|
) : MangaRepository {
|
||||||
|
|
||||||
override val source = MangaSource.LOCAL
|
override val source = LocalMangaSource
|
||||||
private val locks = MultiMutex<Long>()
|
private val locks = MultiMutex<Long>()
|
||||||
private val localMappingCache = LocalMangaMappingCache()
|
private val localMappingCache = LocalMangaMappingCache()
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = when {
|
override suspend fun getDetails(manga: Manga): Manga = when {
|
||||||
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) {
|
!manga.isLocal -> requireNotNull(findSavedManga(manga)?.manga) {
|
||||||
"Manga is not local or saved"
|
"Manga is not local or saved"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,9 +227,11 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
}.filterNotNullTo(ArrayList(files.size))
|
}.filterNotNullTo(ArrayList(files.size))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir ->
|
private suspend fun getAllFiles() = storageManager.getReadableDirs()
|
||||||
dir.children()
|
.asSequence()
|
||||||
}
|
.flatMap { dir ->
|
||||||
|
dir.children().filterNot { it.isHidden }
|
||||||
|
}
|
||||||
|
|
||||||
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.annotation.WorkerThread
|
|||||||
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.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
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
|
||||||
@@ -58,7 +59,7 @@ class MangaIndex(source: String?) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
|
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
|
||||||
val source = MangaSource.valueOf(json.getString("source"))
|
val source = MangaSource(json.getString("source"))
|
||||||
Manga(
|
Manga(
|
||||||
id = json.getLong("id"),
|
id = json.getLong("id"),
|
||||||
title = json.getString("title"),
|
title = json.getString("title"),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.core.net.toFile
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||||
import org.koitharu.kotatsu.core.util.ext.children
|
import org.koitharu.kotatsu.core.util.ext.children
|
||||||
import org.koitharu.kotatsu.core.util.ext.creationTime
|
import org.koitharu.kotatsu.core.util.ext.creationTime
|
||||||
@@ -18,7 +19,6 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|||||||
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
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.TreeMap
|
import java.util.TreeMap
|
||||||
@@ -47,7 +47,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
|||||||
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
|
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
|
||||||
)
|
)
|
||||||
val manga = info?.copy2(
|
val manga = info?.copy2(
|
||||||
source = MangaSource.LOCAL,
|
source = LocalMangaSource,
|
||||||
url = mangaUri,
|
url = mangaUri,
|
||||||
coverUrl = cover,
|
coverUrl = cover,
|
||||||
largeCoverUrl = cover,
|
largeCoverUrl = cover,
|
||||||
@@ -59,14 +59,14 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
|||||||
// old downloads
|
// old downloads
|
||||||
chapterFiles.values.elementAtOrNull(i)
|
chapterFiles.values.elementAtOrNull(i)
|
||||||
} ?: return@mapIndexedNotNull null
|
} ?: return@mapIndexedNotNull null
|
||||||
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
|
c.copy(url = file.toUri().toString(), source = LocalMangaSource)
|
||||||
},
|
},
|
||||||
) ?: Manga(
|
) ?: Manga(
|
||||||
id = root.absolutePath.longHashCode(),
|
id = root.absolutePath.longHashCode(),
|
||||||
title = root.name.toHumanReadable(),
|
title = root.name.toHumanReadable(),
|
||||||
url = mangaUri,
|
url = mangaUri,
|
||||||
publicUrl = mangaUri,
|
publicUrl = mangaUri,
|
||||||
source = MangaSource.LOCAL,
|
source = LocalMangaSource,
|
||||||
coverUrl = findFirstImageEntry().orEmpty(),
|
coverUrl = findFirstImageEntry().orEmpty(),
|
||||||
chapters = chapterFiles.values.mapIndexed { i, f ->
|
chapters = chapterFiles.values.mapIndexed { i, f ->
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
@@ -74,7 +74,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
|||||||
name = f.nameWithoutExtension.toHumanReadable(),
|
name = f.nameWithoutExtension.toHumanReadable(),
|
||||||
number = 0f,
|
number = 0f,
|
||||||
volume = 0,
|
volume = 0,
|
||||||
source = MangaSource.LOCAL,
|
source = LocalMangaSource,
|
||||||
uploadDate = f.creationTime,
|
uploadDate = f.creationTime,
|
||||||
url = f.toUri().toString(),
|
url = f.toUri().toString(),
|
||||||
scanlator = null,
|
scanlator = null,
|
||||||
@@ -106,7 +106,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
|||||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||||
.map {
|
.map {
|
||||||
val pageUri = it.toUri().toString()
|
val pageUri = it.toUri().toString()
|
||||||
MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL)
|
MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ZipFile(file).use { zip ->
|
ZipFile(file).use { zip ->
|
||||||
@@ -121,7 +121,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
|||||||
id = pageUri.longHashCode(),
|
id = pageUri.longHashCode(),
|
||||||
url = pageUri,
|
url = pageUri,
|
||||||
preview = null,
|
preview = null,
|
||||||
source = MangaSource.LOCAL,
|
source = LocalMangaSource,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.core.net.toFile
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||||
import org.koitharu.kotatsu.core.util.ext.readText
|
import org.koitharu.kotatsu.core.util.ext.readText
|
||||||
@@ -17,7 +18,6 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|||||||
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
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Enumeration
|
import java.util.Enumeration
|
||||||
@@ -47,12 +47,12 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
|||||||
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
|
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
|
||||||
)
|
)
|
||||||
return@use info.copy2(
|
return@use info.copy2(
|
||||||
source = MangaSource.LOCAL,
|
source = LocalMangaSource,
|
||||||
url = fileUri,
|
url = fileUri,
|
||||||
coverUrl = cover,
|
coverUrl = cover,
|
||||||
largeCoverUrl = cover,
|
largeCoverUrl = cover,
|
||||||
chapters = info.chapters?.map { c ->
|
chapters = info.chapters?.map { c ->
|
||||||
c.copy(url = fileUri, source = MangaSource.LOCAL)
|
c.copy(url = fileUri, source = LocalMangaSource)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
|||||||
title = title,
|
title = title,
|
||||||
url = fileUri,
|
url = fileUri,
|
||||||
publicUrl = fileUri,
|
publicUrl = fileUri,
|
||||||
source = MangaSource.LOCAL,
|
source = LocalMangaSource,
|
||||||
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
|
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
|
||||||
chapters = chapters.sortedWith(AlphanumComparator())
|
chapters = chapters.sortedWith(AlphanumComparator())
|
||||||
.mapIndexed { i, s ->
|
.mapIndexed { i, s ->
|
||||||
@@ -79,7 +79,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
|||||||
name = s.ifEmpty { title },
|
name = s.ifEmpty { title },
|
||||||
number = 0f,
|
number = 0f,
|
||||||
volume = 0,
|
volume = 0,
|
||||||
source = MangaSource.LOCAL,
|
source = LocalMangaSource,
|
||||||
uploadDate = 0L,
|
uploadDate = 0L,
|
||||||
url = uriBuilder.fragment(s).build().toString(),
|
url = uriBuilder.fragment(s).build().toString(),
|
||||||
scanlator = null,
|
scanlator = null,
|
||||||
@@ -135,7 +135,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
|||||||
id = entryUri.longHashCode(),
|
id = entryUri.longHashCode(),
|
||||||
url = entryUri,
|
url = entryUri,
|
||||||
preview = null,
|
preview = null,
|
||||||
source = MangaSource.LOCAL,
|
source = LocalMangaSource,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user