Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c218ae0baa | |||
| 5820b2f511 | |||
| 79c2bf17fd | |||
| 78aa4d76db | |||
| e2f3ba19b8 | |||
| 41045686fc | |||
| 8b0b375dfe | |||
| c7c23b9768 | |||
| 33190ae3ea | |||
| 03590f4b82 | |||
| cbcf98e1d4 | |||
| e3a80b5a6d | |||
| 66dc5a9597 | |||
| cb6bf91dd3 | |||
| fb815abad0 | |||
| 8ef7580097 | |||
| 197393fbd1 | |||
|
|
51ef6e3c78 | ||
|
|
663277fe6f | ||
|
|
332a38d674 | ||
| e9410a2f54 | |||
| b5fa2bd660 | |||
| e56c61d834 | |||
| 677f71dd84 | |||
|
|
3f90f88600 | ||
| 229a7c70d9 | |||
| a2dbec98f9 | |||
| 3a02f8090e | |||
| 17519db44e | |||
| 99186bf269 | |||
| 9a65e40be1 | |||
| f0add59f99 | |||
| f18c182a6a | |||
| 68e9588f24 | |||
| eea427216d | |||
| 8e9b89f6f0 | |||
| 4f3281be99 | |||
|
|
eb56a82702 | ||
|
|
089ccc9d15 | ||
|
|
12c1365513 | ||
|
|
7ecf9316e3 | ||
|
|
12e98ec36a | ||
|
|
22977fc7bc | ||
|
|
b387a49a4e | ||
|
|
dbbb0d0f64 | ||
|
|
0bbf2b752f | ||
|
|
14c1eacffa | ||
|
|
c2a0525bb8 | ||
|
|
4f502e580c | ||
|
|
7cb303966a | ||
|
|
3f0431f88b | ||
|
|
aad5601df1 | ||
| 8f2cf8141a | |||
| eefd1129f7 | |||
| 5ed0f8b5a6 | |||
| 9b4aa4fd64 | |||
| bbb226791b | |||
| 30ac4435d4 | |||
| 1b9dfe1901 | |||
| 808a6efd8f | |||
| 66ed19ed5a | |||
| 527a3cbd09 | |||
| f22963b315 | |||
| 2ce5cb524f | |||
| 4cbc6392fb | |||
| 049f9fa625 | |||
| c853fae820 | |||
| dd1d84a4fe | |||
| 1569aa5dd5 | |||
| 51cd88eded | |||
|
|
bf386deef0 | ||
|
|
5c80cdee81 | ||
|
|
b29fbb37cd | ||
|
|
589831beef | ||
|
|
0f5d153543 | ||
|
|
ab1c99d132 |
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -14,7 +14,6 @@
|
|||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
<option name="resolveModulePerSourceSet" value="false" />
|
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 31
|
targetSdkVersion 31
|
||||||
versionCode 370
|
versionCode 380
|
||||||
versionName '2.0-b2'
|
versionName '2.1.4'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -24,10 +24,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = '.debug'
|
applicationIdSuffix = '.debug'
|
||||||
@@ -45,6 +41,17 @@ android {
|
|||||||
sourceSets {
|
sourceSets {
|
||||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
}
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
|
freeCompilerArgs += [
|
||||||
|
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
|
'-Xopt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
|
]
|
||||||
|
}
|
||||||
lintOptions {
|
lintOptions {
|
||||||
disable 'MissingTranslation'
|
disable 'MissingTranslation'
|
||||||
abortOnError false
|
abortOnError false
|
||||||
@@ -54,67 +61,57 @@ android {
|
|||||||
unitTests.returnDefaultValues = false
|
unitTests.returnDefaultValues = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
|
||||||
freeCompilerArgs += [
|
|
||||||
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
|
||||||
'-Xopt-in=kotlinx.coroutines.FlowPreview',
|
|
||||||
'-Xopt-in=org.koin.core.component.KoinApiExtension'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
||||||
|
|
||||||
implementation 'androidx.core:core-ktx:1.6.0'
|
implementation 'androidx.core:core-ktx:1.7.0'
|
||||||
implementation 'androidx.activity:activity-ktx:1.3.1'
|
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
implementation 'androidx.fragment:fragment-ktx:1.4.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
|
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.3.1'
|
implementation 'androidx.lifecycle:lifecycle-service:2.4.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.3.1'
|
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
implementation 'androidx.preference:preference-ktx:1.1.1'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.7.0'
|
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||||
implementation 'com.google.android.material:material:1.4.0'
|
implementation 'com.google.android.material:material:1.4.0'
|
||||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1'
|
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
||||||
|
|
||||||
implementation 'androidx.room:room-runtime:2.3.0'
|
implementation 'androidx.room:room-runtime:2.4.1'
|
||||||
implementation 'androidx.room:room-ktx:2.3.0'
|
implementation 'androidx.room:room-ktx:2.4.1'
|
||||||
kapt 'androidx.room:room-compiler:2.3.0'
|
kapt 'androidx.room:room-compiler:2.4.1'
|
||||||
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
|
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
|
||||||
implementation 'com.squareup.okio:okio:2.10.0'
|
implementation 'com.squareup.okio:okio:2.10.0'
|
||||||
implementation 'org.jsoup:jsoup:1.14.3'
|
implementation 'org.jsoup:jsoup:1.14.3'
|
||||||
|
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1'
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1'
|
||||||
|
|
||||||
implementation 'io.insert-koin:koin-android:3.1.2'
|
implementation 'io.insert-koin:koin-android:3.1.4'
|
||||||
implementation 'io.coil-kt:coil-base:1.4.0'
|
implementation 'io.coil-kt:coil-base:1.4.0'
|
||||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.3'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'com.google.truth:truth:1.1.3'
|
testImplementation 'com.google.truth:truth:1.1.3'
|
||||||
testImplementation 'org.json:json:20210307'
|
testImplementation 'org.json:json:20211205'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.2'
|
testImplementation 'io.insert-koin:koin-test-junit4:3.1.4'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||||
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
|
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
|
||||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
|
||||||
androidTestImplementation 'androidx.room:room-testing:2.3.0'
|
androidTestImplementation 'androidx.room:room-testing:2.4.1'
|
||||||
androidTestImplementation 'com.google.truth:truth:1.1.3'
|
androidTestImplementation 'com.google.truth:truth:1.1.3'
|
||||||
}
|
}
|
||||||
5
app/proguard-rules.pro
vendored
5
app/proguard-rules.pro
vendored
@@ -5,9 +5,8 @@
|
|||||||
public static void checkReturnedValueIsNotNull(...);
|
public static void checkReturnedValueIsNotNull(...);
|
||||||
public static void checkFieldIsNotNull(...);
|
public static void checkFieldIsNotNull(...);
|
||||||
public static void checkParameterIsNotNull(...);
|
public static void checkParameterIsNotNull(...);
|
||||||
|
public static void checkNotNullParameter(...);
|
||||||
}
|
}
|
||||||
|
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
|
||||||
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
||||||
-keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository {
|
|
||||||
public <init>(...);
|
|
||||||
}
|
|
||||||
-dontwarn okhttp3.internal.platform.ConscryptPlatform
|
-dontwarn okhttp3.internal.platform.ConscryptPlatform
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.default_searchable"
|
android:name="android.app.default_searchable"
|
||||||
android:value=".ui.search.SearchActivity" />
|
android:value="org.koitharu.kotatsu.ui.search.SearchActivity" />
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"
|
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"
|
||||||
@@ -94,11 +94,12 @@
|
|||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".settings.protect.ProtectSetupActivity"
|
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||||
android:label="@string/downloads" />
|
android:label="@string/downloads" />
|
||||||
|
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
@@ -65,7 +66,7 @@ class KotatsuApp : Application() {
|
|||||||
trackerModule,
|
trackerModule,
|
||||||
settingsModule,
|
settingsModule,
|
||||||
readerModule,
|
readerModule,
|
||||||
appWidgetModule
|
appWidgetModule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,5 +87,12 @@ class KotatsuApp : Application() {
|
|||||||
.penaltyLog()
|
.penaltyLog()
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||||
|
.penaltyDeath()
|
||||||
|
.detectFragmentReuse()
|
||||||
|
.detectWrongFragmentContainer()
|
||||||
|
.detectRetainInstanceUsage()
|
||||||
|
.detectSetUserVisibleHint()
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import android.net.Uri
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import org.koitharu.kotatsu.core.model.Manga
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
|
|
||||||
data class MangaIntent(
|
class MangaIntent(
|
||||||
val manga: Manga?,
|
val manga: Manga?,
|
||||||
val mangaId: Long,
|
val mangaId: Long,
|
||||||
val uri: Uri?
|
val uri: Uri?
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
package org.koitharu.kotatsu.base.domain
|
package org.koitharu.kotatsu.base.domain
|
||||||
|
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.GraphQLException
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
import org.koitharu.kotatsu.utils.ext.await
|
import org.koitharu.kotatsu.utils.ext.await
|
||||||
|
import org.koitharu.kotatsu.utils.ext.parseJson
|
||||||
|
|
||||||
|
|
||||||
open class MangaLoaderContext(
|
open class MangaLoaderContext(
|
||||||
private val okHttp: OkHttpClient,
|
private val okHttp: OkHttpClient,
|
||||||
val cookieJar: CookieJar
|
val cookieJar: CookieJar,
|
||||||
) : KoinComponent {
|
) : KoinComponent {
|
||||||
|
|
||||||
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
||||||
@@ -24,7 +30,7 @@ open class MangaLoaderContext(
|
|||||||
|
|
||||||
suspend fun httpPost(
|
suspend fun httpPost(
|
||||||
url: String,
|
url: String,
|
||||||
form: Map<String, String>
|
form: Map<String, String>,
|
||||||
): Response {
|
): Response {
|
||||||
val body = FormBody.Builder()
|
val body = FormBody.Builder()
|
||||||
form.forEach { (k, v) ->
|
form.forEach { (k, v) ->
|
||||||
@@ -38,7 +44,7 @@ open class MangaLoaderContext(
|
|||||||
|
|
||||||
suspend fun httpPost(
|
suspend fun httpPost(
|
||||||
url: String,
|
url: String,
|
||||||
payload: String
|
payload: String,
|
||||||
): Response {
|
): Response {
|
||||||
val body = FormBody.Builder()
|
val body = FormBody.Builder()
|
||||||
payload.split('&').forEach {
|
payload.split('&').forEach {
|
||||||
@@ -55,10 +61,24 @@ open class MangaLoaderContext(
|
|||||||
return okHttp.newCall(request.build()).await()
|
return okHttp.newCall(request.build()).await()
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
|
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
|
||||||
|
val body = JSONObject()
|
||||||
private companion object {
|
body.put("operationName", null)
|
||||||
|
body.put("variables", JSONObject())
|
||||||
private const val SCHEME_HTTP = "http"
|
body.put("query", "{${query}}")
|
||||||
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
|
val requestBody = body.toString().toRequestBody(mediaType)
|
||||||
|
val request = Request.Builder()
|
||||||
|
.post(requestBody)
|
||||||
|
.url(endpoint)
|
||||||
|
val json = okHttp.newCall(request.build()).await().parseJson()
|
||||||
|
json.optJSONArray("errors")?.let {
|
||||||
|
if (it.length() != 0) {
|
||||||
|
throw GraphQLException(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ object MangaProviderFactory {
|
|||||||
fun getSources(settings: AppSettings, includeHidden: Boolean): List<MangaSource> {
|
fun getSources(settings: AppSettings, includeHidden: Boolean): List<MangaSource> {
|
||||||
val list = MangaSource.values().toList() - MangaSource.LOCAL
|
val list = MangaSource.values().toList() - MangaSource.LOCAL
|
||||||
val order = settings.sourcesOrder
|
val order = settings.sourcesOrder
|
||||||
val hidden = settings.hiddenSources
|
|
||||||
val sorted = list.sortedBy { x ->
|
val sorted = list.sortedBy { x ->
|
||||||
val e = order.indexOf(x.ordinal)
|
val e = order.indexOf(x.ordinal)
|
||||||
if (e == -1) order.size + x.ordinal else e
|
if (e == -1) order.size + x.ordinal else e
|
||||||
@@ -16,6 +15,7 @@ object MangaProviderFactory {
|
|||||||
return if (includeHidden) {
|
return if (includeHidden) {
|
||||||
sorted
|
sorted
|
||||||
} else {
|
} else {
|
||||||
|
val hidden = settings.hiddenSources
|
||||||
sorted.filterNot { x ->
|
sorted.filterNot { x ->
|
||||||
x.name in hidden
|
x.name in hidden
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.base.domain
|
|||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import androidx.annotation.WorkerThread
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
@@ -23,18 +25,18 @@ object MangaUtils : KoinComponent {
|
|||||||
* Automatic determine type of manga by page size
|
* Automatic determine type of manga by page size
|
||||||
* @return ReaderMode.WEBTOON if page is wide
|
* @return ReaderMode.WEBTOON if page is wide
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? {
|
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? {
|
||||||
try {
|
try {
|
||||||
val page = pages.medianOrNull() ?: return null
|
val page = pages.medianOrNull() ?: return null
|
||||||
val url = page.source.repository.getPageUrl(page)
|
val url = page.source.repository.getPageUrl(page)
|
||||||
val uri = Uri.parse(url)
|
val uri = Uri.parse(url)
|
||||||
val size = if (uri.scheme == "cbz") {
|
val size = if (uri.scheme == "cbz") {
|
||||||
val zip = ZipFile(uri.schemeSpecificPart)
|
runInterruptible(Dispatchers.IO) {
|
||||||
val entry = zip.getEntry(uri.fragment)
|
val zip = ZipFile(uri.schemeSpecificPart)
|
||||||
zip.getInputStream(entry).use {
|
val entry = zip.getEntry(uri.fragment)
|
||||||
getBitmapSize(it)
|
zip.getInputStream(entry).use {
|
||||||
|
getBitmapSize(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val client = get<OkHttpClient>()
|
val client = get<OkHttpClient>()
|
||||||
@@ -45,7 +47,9 @@ object MangaUtils : KoinComponent {
|
|||||||
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
||||||
.build()
|
.build()
|
||||||
client.newCall(request).await().use {
|
client.newCall(request).await().use {
|
||||||
getBitmapSize(it.body?.byteStream())
|
withContext(Dispatchers.IO) {
|
||||||
|
getBitmapSize(it.body?.byteStream())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return size.width * 2 < size.height
|
return size.width * 2 < size.height
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.view.ViewGroup
|
|||||||
import android.widget.BaseAdapter
|
import android.widget.BaseAdapter
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ import android.text.InputFilter
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import org.koitharu.kotatsu.databinding.DialogInputBinding
|
import org.koitharu.kotatsu.databinding.DialogInputBinding
|
||||||
|
|
||||||
class TextInputDialog private constructor(
|
class TextInputDialog private constructor(
|
||||||
private val delegate: AlertDialog
|
private val delegate: AlertDialog,
|
||||||
) : DialogInterface by delegate {
|
) : DialogInterface by delegate {
|
||||||
|
|
||||||
fun show() = delegate.show()
|
fun show() = delegate.show()
|
||||||
@@ -33,7 +32,7 @@ class TextInputDialog private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setHint(@StringRes hintResId: Int): Builder {
|
fun setHint(@StringRes hintResId: Int): Builder {
|
||||||
binding.inputLayout.hint = binding.root.context.getString(hintResId)
|
binding.inputEdit.hint = binding.root.context.getString(hintResId)
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ class TextInputDialog private constructor(
|
|||||||
listener: (DialogInterface, String) -> Unit
|
listener: (DialogInterface, String) -> Unit
|
||||||
): Builder {
|
): Builder {
|
||||||
delegate.setPositiveButton(textId) { dialog, _ ->
|
delegate.setPositiveButton(textId) { dialog, _ ->
|
||||||
listener(dialog, binding.inputEdit.text.toString().orEmpty())
|
listener(dialog, binding.inputEdit.text?.toString().orEmpty())
|
||||||
}
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.base.ui.list
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
class AdapterUpdater<T>(oldList: List<T>, newList: List<T>, getId: (T) -> Long) {
|
|
||||||
|
|
||||||
private val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
getId(oldList[oldItemPosition]) == getId(newList[newItemPosition])
|
|
||||||
|
|
||||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
|
||||||
Objects.equals(oldList[oldItemPosition], newList[newItemPosition])
|
|
||||||
|
|
||||||
override fun getOldListSize() = oldList.size
|
|
||||||
|
|
||||||
override fun getNewListSize() = newList.size
|
|
||||||
})
|
|
||||||
|
|
||||||
operator fun invoke(adapter: RecyclerView.Adapter<*>) {
|
|
||||||
diff.dispatchUpdatesTo(adapter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.base.ui.list
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.viewbinding.ViewBinding
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
|
|
||||||
@Deprecated("")
|
|
||||||
abstract class BaseViewHolder<T, E, B : ViewBinding> protected constructor(val binding: B) :
|
|
||||||
RecyclerView.ViewHolder(binding.root), KoinComponent {
|
|
||||||
|
|
||||||
var boundData: T? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
val context get() = itemView.context!!
|
|
||||||
|
|
||||||
fun bind(data: T, extra: E) {
|
|
||||||
boundData = data
|
|
||||||
onBind(data, extra)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requireData(): T {
|
|
||||||
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun onRecycled() = Unit
|
|
||||||
|
|
||||||
abstract fun onBind(data: T, extra: E)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list.decor
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.view.children
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getThemeDrawable
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||||
|
|
||||||
|
private val bounds = Rect()
|
||||||
|
private val divider = context.getThemeDrawable(android.R.attr.listDivider)
|
||||||
|
|
||||||
|
override fun getItemOffsets(
|
||||||
|
outRect: Rect,
|
||||||
|
view: View,
|
||||||
|
parent: RecyclerView,
|
||||||
|
state: RecyclerView.State,
|
||||||
|
) {
|
||||||
|
outRect.set(0, divider?.intrinsicHeight ?: 0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO implement for horizontal lists on demand
|
||||||
|
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
||||||
|
if (parent.layoutManager == null || divider == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
canvas.save()
|
||||||
|
val left: Int
|
||||||
|
val right: Int
|
||||||
|
if (parent.clipToPadding) {
|
||||||
|
left = parent.paddingLeft
|
||||||
|
right = parent.width - parent.paddingRight
|
||||||
|
canvas.clipRect(
|
||||||
|
left, parent.paddingTop, right,
|
||||||
|
parent.height - parent.paddingBottom
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
left = 0
|
||||||
|
right = parent.width
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous: RecyclerView.ViewHolder? = null
|
||||||
|
for (child in parent.children) {
|
||||||
|
val holder = parent.getChildViewHolder(child)
|
||||||
|
if (previous != null && shouldDrawDivider(previous, holder)) {
|
||||||
|
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||||
|
val top: Int = bounds.top + child.translationY.roundToInt()
|
||||||
|
val bottom: Int = top + divider.intrinsicHeight
|
||||||
|
divider.setBounds(left, top, right, bottom)
|
||||||
|
divider.draw(canvas)
|
||||||
|
}
|
||||||
|
previous = holder
|
||||||
|
}
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun shouldDrawDivider(
|
||||||
|
above: RecyclerView.ViewHolder,
|
||||||
|
below: RecyclerView.ViewHolder,
|
||||||
|
): Boolean
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.base.ui.list.decor
|
|
||||||
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Rect
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.core.view.children
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.utils.ext.inflate
|
|
||||||
import kotlin.math.max
|
|
||||||
|
|
||||||
/**
|
|
||||||
* https://github.com/paetztm/recycler_view_headers
|
|
||||||
*/
|
|
||||||
class SectionItemDecoration(
|
|
||||||
private val isSticky: Boolean,
|
|
||||||
private val callback: Callback
|
|
||||||
) : RecyclerView.ItemDecoration() {
|
|
||||||
|
|
||||||
private var headerView: TextView? = null
|
|
||||||
private var headerOffset: Int = 0
|
|
||||||
|
|
||||||
override fun getItemOffsets(
|
|
||||||
outRect: Rect,
|
|
||||||
view: View,
|
|
||||||
parent: RecyclerView,
|
|
||||||
state: RecyclerView.State
|
|
||||||
) {
|
|
||||||
if (headerOffset == 0) {
|
|
||||||
headerOffset = parent.resources.getDimensionPixelSize(R.dimen.header_height)
|
|
||||||
}
|
|
||||||
val pos = parent.getChildAdapterPosition(view)
|
|
||||||
outRect.set(0, if (callback.isSection(pos)) headerOffset else 0, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
|
||||||
super.onDrawOver(c, parent, state)
|
|
||||||
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_filter_header).also {
|
|
||||||
headerView = it
|
|
||||||
}
|
|
||||||
fixLayoutSize(textView, parent)
|
|
||||||
|
|
||||||
for (child in parent.children) {
|
|
||||||
val pos = parent.getChildAdapterPosition(child)
|
|
||||||
if (callback.isSection(pos)) {
|
|
||||||
textView.text = callback.getSectionTitle(pos) ?: continue
|
|
||||||
c.save()
|
|
||||||
if (isSticky) {
|
|
||||||
c.translate(
|
|
||||||
0f,
|
|
||||||
max(0f, (child.top - textView.height).toFloat())
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
c.translate(
|
|
||||||
0f,
|
|
||||||
(child.top - textView.height).toFloat()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
textView.draw(c)
|
|
||||||
c.restore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measures the header view to make sure its size is greater than 0 and will be drawn
|
|
||||||
* https://yoda.entelect.co.za/view/9627/how-to-android-recyclerview-item-decorations
|
|
||||||
*/
|
|
||||||
private fun fixLayoutSize(view: View, parent: ViewGroup) {
|
|
||||||
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
|
|
||||||
val heightSpec =
|
|
||||||
View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
|
|
||||||
|
|
||||||
val childWidth = ViewGroup.getChildMeasureSpec(
|
|
||||||
widthSpec,
|
|
||||||
parent.paddingLeft + parent.paddingRight,
|
|
||||||
view.layoutParams.width
|
|
||||||
)
|
|
||||||
val childHeight = ViewGroup.getChildMeasureSpec(
|
|
||||||
heightSpec,
|
|
||||||
parent.paddingTop + parent.paddingBottom,
|
|
||||||
view.layoutParams.height
|
|
||||||
)
|
|
||||||
view.measure(childWidth, childHeight)
|
|
||||||
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Callback {
|
|
||||||
|
|
||||||
fun isSection(position: Int): Boolean
|
|
||||||
|
|
||||||
fun getSectionTitle(position: Int): CharSequence?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -96,11 +96,32 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ChipModel(
|
class ChipModel(
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int,
|
||||||
val title: CharSequence,
|
val title: CharSequence,
|
||||||
val data: Any? = null
|
val data: Any? = null
|
||||||
)
|
) {
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as ChipModel
|
||||||
|
|
||||||
|
if (icon != other.icon) return false
|
||||||
|
if (title != other.title) return false
|
||||||
|
if (data != other.data) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = icon
|
||||||
|
result = 31 * result + title.hashCode()
|
||||||
|
result = 31 * result + data.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun interface OnChipClickListener {
|
fun interface OnChipClickListener {
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import android.widget.LinearLayout
|
|||||||
import androidx.appcompat.widget.AppCompatImageView
|
import androidx.appcompat.widget.AppCompatImageView
|
||||||
import androidx.core.content.withStyledAttributes
|
import androidx.core.content.withStyledAttributes
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.utils.ext.resolveAdjustedSize
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
||||||
class CoverImageView @JvmOverloads constructor(
|
class CoverImageView @JvmOverloads constructor(
|
||||||
@@ -17,47 +17,22 @@ class CoverImageView @JvmOverloads constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) {
|
context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) {
|
||||||
orientation = getInt(R.styleable.CoverImageView_android_orientation, HORIZONTAL)
|
orientation = getInt(R.styleable.CoverImageView_android_orientation, orientation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
val w: Int
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||||
val h: Int
|
val desiredWidth: Int
|
||||||
|
val desiredHeight: Int
|
||||||
if (orientation == VERTICAL) {
|
if (orientation == VERTICAL) {
|
||||||
val desiredHeight = (drawable?.intrinsicHeight?.coerceAtLeast(0) ?: 0) +
|
desiredHeight = measuredHeight
|
||||||
paddingTop + paddingBottom
|
desiredWidth = (desiredHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt()
|
||||||
h = resolveAdjustedSize(
|
|
||||||
desiredHeight.coerceAtLeast(suggestedMinimumHeight),
|
|
||||||
maxHeight,
|
|
||||||
heightMeasureSpec
|
|
||||||
)
|
|
||||||
val desiredWidth =
|
|
||||||
(h * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).toInt() + paddingLeft + paddingRight
|
|
||||||
w = resolveAdjustedSize(
|
|
||||||
desiredWidth.coerceAtLeast(suggestedMinimumWidth),
|
|
||||||
maxWidth,
|
|
||||||
widthMeasureSpec
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
val desiredWidth = (drawable?.intrinsicWidth?.coerceAtLeast(0) ?: 0) +
|
desiredWidth = measuredWidth
|
||||||
paddingLeft + paddingRight
|
desiredHeight = (desiredWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt()
|
||||||
w = resolveAdjustedSize(
|
|
||||||
desiredWidth.coerceAtLeast(suggestedMinimumWidth),
|
|
||||||
maxWidth,
|
|
||||||
widthMeasureSpec
|
|
||||||
)
|
|
||||||
val desiredHeight =
|
|
||||||
(w * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt() + paddingTop + paddingBottom
|
|
||||||
h = resolveAdjustedSize(
|
|
||||||
desiredHeight.coerceAtLeast(suggestedMinimumHeight),
|
|
||||||
maxHeight,
|
|
||||||
heightMeasureSpec
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
val widthSize = resolveSizeAndState(w, widthMeasureSpec, 0)
|
setMeasuredDimension(desiredWidth, desiredHeight)
|
||||||
val heightSize = resolveSizeAndState(h, heightMeasureSpec, 0)
|
|
||||||
setMeasuredDimension(widthSize, heightSize)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -92,8 +92,16 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
binding.appbar.updatePadding(top = insets.top)
|
binding.appbar.updatePadding(
|
||||||
binding.webView.updatePadding(bottom = insets.bottom)
|
top = insets.top,
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
)
|
||||||
|
binding.root.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
bottom = insets.bottom,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.backup
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -33,8 +34,7 @@ class BackupArchive(file: File) : MutableZipFile(file) {
|
|||||||
|
|
||||||
private const val DIR_BACKUPS = "backups"
|
private const val DIR_BACKUPS = "backups"
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
|
||||||
suspend fun createNew(context: Context): BackupArchive = withContext(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
val dir = context.run {
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.backup
|
|||||||
|
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
|
||||||
data class BackupEntry(
|
class BackupEntry(
|
||||||
val name: String,
|
val name: String,
|
||||||
val data: JSONArray
|
val data: JSONArray
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.core.model.MangaState
|
|||||||
import org.koitharu.kotatsu.core.model.MangaTag
|
import org.koitharu.kotatsu.core.model.MangaTag
|
||||||
|
|
||||||
@Entity(tableName = "manga")
|
@Entity(tableName = "manga")
|
||||||
data class MangaEntity(
|
class MangaEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@ColumnInfo(name = "manga_id") val id: Long,
|
@ColumnInfo(name = "manga_id") val id: Long,
|
||||||
@ColumnInfo(name = "title") val title: String,
|
@ColumnInfo(name = "title") val title: String,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import androidx.room.PrimaryKey
|
|||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE
|
||||||
)]
|
)]
|
||||||
)
|
)
|
||||||
data class MangaPrefsEntity(
|
class MangaPrefsEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||||
@ColumnInfo(name = "mode") val mode: Int
|
@ColumnInfo(name = "mode") val mode: Int
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import androidx.room.ForeignKey
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
data class MangaTagsEntity(
|
class MangaTagsEntity(
|
||||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||||
@ColumnInfo(name = "tag_id", index = true) val tagId: Long
|
@ColumnInfo(name = "tag_id", index = true) val tagId: Long
|
||||||
)
|
)
|
||||||
@@ -5,7 +5,7 @@ import androidx.room.Junction
|
|||||||
import androidx.room.Relation
|
import androidx.room.Relation
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||||
|
|
||||||
data class MangaWithTags(
|
class MangaWithTags(
|
||||||
@Embedded val manga: MangaEntity,
|
@Embedded val manga: MangaEntity,
|
||||||
@Relation(
|
@Relation(
|
||||||
parentColumn = "manga_id",
|
parentColumn = "manga_id",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import androidx.room.PrimaryKey
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
data class SuggestionEntity(
|
class SuggestionEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||||
@ColumnInfo(name = "relevance") val relevance: Float,
|
@ColumnInfo(name = "relevance") val relevance: Float,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.MangaTag
|
|||||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||||
|
|
||||||
@Entity(tableName = "tags")
|
@Entity(tableName = "tags")
|
||||||
data class TagEntity(
|
class TagEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@ColumnInfo(name = "tag_id") val id: Long,
|
@ColumnInfo(name = "tag_id") val id: Long,
|
||||||
@ColumnInfo(name = "title") val title: String,
|
@ColumnInfo(name = "title") val title: String,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import androidx.room.PrimaryKey
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
data class TrackEntity(
|
class TrackEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||||
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
|
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import androidx.room.PrimaryKey
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
data class TrackLogEntity(
|
class TrackLogEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
@ColumnInfo(name = "id") val id: Long = 0L,
|
@ColumnInfo(name = "id") val id: Long = 0L,
|
||||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.model.TrackingLogItem
|
|||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
data class TrackLogWithManga(
|
class TrackLogWithManga(
|
||||||
@Embedded val trackLog: TrackLogEntity,
|
@Embedded val trackLog: TrackLogEntity,
|
||||||
@Relation(
|
@Relation(
|
||||||
parentColumn = "manga_id",
|
parentColumn = "manga_id",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.koitharu.kotatsu.utils.ext.map
|
||||||
|
|
||||||
|
class GraphQLException(private val errors: JSONArray) : RuntimeException() {
|
||||||
|
|
||||||
|
val messages = errors.map {
|
||||||
|
it.getString("message")
|
||||||
|
}
|
||||||
|
|
||||||
|
override val message: String
|
||||||
|
get() = messages.joinToString("\n")
|
||||||
|
}
|
||||||
@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.github
|
|||||||
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
data class VersionId(
|
class VersionId(
|
||||||
val major: Int,
|
val major: Int,
|
||||||
val minor: Int,
|
val minor: Int,
|
||||||
val build: Int,
|
val build: Int,
|
||||||
val variantType: String,
|
val variantType: String,
|
||||||
val variantNumber: Int
|
val variantNumber: Int,
|
||||||
) : Comparable<VersionId> {
|
) : Comparable<VersionId> {
|
||||||
|
|
||||||
override fun compareTo(other: VersionId): Int {
|
override fun compareTo(other: VersionId): Int {
|
||||||
@@ -30,6 +30,30 @@ data class VersionId(
|
|||||||
return variantNumber.compareTo(other.variantNumber)
|
return variantNumber.compareTo(other.variantNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as VersionId
|
||||||
|
|
||||||
|
if (major != other.major) return false
|
||||||
|
if (minor != other.minor) return false
|
||||||
|
if (build != other.build) return false
|
||||||
|
if (variantType != other.variantType) return false
|
||||||
|
if (variantNumber != other.variantNumber) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = major
|
||||||
|
result = 31 * result + minor
|
||||||
|
result = 31 * result + build
|
||||||
|
result = 31 * result + variantType.hashCode()
|
||||||
|
result = 31 * result + variantNumber
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private fun variantWeight(variantType: String) =
|
private fun variantWeight(variantType: String) =
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ data class MangaChapter(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val number: Int,
|
val number: Int,
|
||||||
val url: String,
|
val url: String,
|
||||||
val scanlator: String? = null,
|
val scanlator: String?,
|
||||||
val uploadDate: Long,
|
val uploadDate: Long,
|
||||||
val branch: String? = null,
|
val branch: String?,
|
||||||
val source: MangaSource
|
val source: MangaSource,
|
||||||
) : Parcelable
|
) : Parcelable, Comparable<MangaChapter> {
|
||||||
|
|
||||||
|
override fun compareTo(other: MangaChapter): Int {
|
||||||
|
return number.compareTo(other.number)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,5 +10,5 @@ data class MangaHistory(
|
|||||||
val updatedAt: Date,
|
val updatedAt: Date,
|
||||||
val chapterId: Long,
|
val chapterId: Long,
|
||||||
val page: Int,
|
val page: Int,
|
||||||
val scroll: Int
|
val scroll: Int,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
@@ -8,6 +8,6 @@ data class MangaPage(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
val url: String,
|
val url: String,
|
||||||
val referer: String,
|
val referer: String,
|
||||||
val preview: String? = null,
|
val preview: String?,
|
||||||
val source: MangaSource
|
val source: MangaSource,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
|||||||
enum class MangaSource(
|
enum class MangaSource(
|
||||||
val title: String,
|
val title: String,
|
||||||
val locale: String?,
|
val locale: String?,
|
||||||
val cls: Class<out MangaRepository>
|
val cls: Class<out MangaRepository>,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
LOCAL("Local", null, LocalMangaRepository::class.java),
|
LOCAL("Local", null, LocalMangaRepository::class.java),
|
||||||
READMANGA_RU("ReadManga", "ru", ReadmangaRepository::class.java),
|
READMANGA_RU("ReadManga", "ru", ReadmangaRepository::class.java),
|
||||||
@@ -40,7 +40,8 @@ enum class MangaSource(
|
|||||||
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
|
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
|
||||||
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
|
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
|
||||||
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java),
|
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java),
|
||||||
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java)
|
MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java),
|
||||||
|
MANGADEX("MangaDex", null, MangaDexRepository::class.java),
|
||||||
;
|
;
|
||||||
|
|
||||||
@get:Throws(NoBeanDefFoundException::class)
|
@get:Throws(NoBeanDefFoundException::class)
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ import kotlinx.parcelize.Parcelize
|
|||||||
data class MangaTag(
|
data class MangaTag(
|
||||||
val title: String,
|
val title: String,
|
||||||
val key: String,
|
val key: String,
|
||||||
val source: MangaSource
|
val source: MangaSource,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
@@ -6,4 +6,5 @@ object CommonHeaders {
|
|||||||
const val USER_AGENT = "User-Agent"
|
const val USER_AGENT = "User-Agent"
|
||||||
const val ACCEPT = "Accept"
|
const val ACCEPT = "Accept"
|
||||||
const val CONTENT_DISPOSITION = "Content-Disposition"
|
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||||
|
const val COOKIE = "Cookie"
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import org.koin.dsl.bind
|
|||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.utils.CacheUtils
|
import org.koitharu.kotatsu.utils.CacheUtils
|
||||||
|
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
val networkModule
|
val networkModule
|
||||||
@@ -28,4 +29,5 @@ val networkModule
|
|||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
|
factory { DownloadManagerHelper(get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import coil.map.Mapper
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
|
||||||
|
class FaviconMapper() : Mapper<Uri, HttpUrl> {
|
||||||
|
|
||||||
|
override fun map(data: Uri): HttpUrl {
|
||||||
|
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
|
||||||
|
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
|
||||||
|
return repo.getFaviconUrl().toHttpUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handles(data: Uri) = data.scheme == "favicon"
|
||||||
|
}
|
||||||
@@ -34,4 +34,5 @@ val parserModule
|
|||||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
|
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
|
||||||
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
|
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
|
||||||
factory<MangaRepository>(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) }
|
factory<MangaRepository>(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) }
|
||||||
|
factory<MangaRepository>(named(MangaSource.MANGADEX)) { MangaDexRepository(get()) }
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,8 @@ abstract class RemoteMangaRepository(
|
|||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> = emptySet()
|
override suspend fun getTags(): Set<MangaTag> = emptySet()
|
||||||
|
|
||||||
|
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico"
|
||||||
|
|
||||||
open fun onCreatePreferences(map: MutableMap<String, Any>) {
|
open fun onCreatePreferences(map: MutableMap<String, Any>) {
|
||||||
map[SourceSettings.KEY_DOMAIN] = defaultDomain
|
map[SourceSettings.KEY_DOMAIN] = defaultDomain
|
||||||
}
|
}
|
||||||
@@ -53,8 +55,10 @@ abstract class RemoteMangaRepository(
|
|||||||
if (subdomain != null) {
|
if (subdomain != null) {
|
||||||
append(subdomain)
|
append(subdomain)
|
||||||
append('.')
|
append('.')
|
||||||
|
append(conf.getDomain(defaultDomain).removePrefix("www."))
|
||||||
|
} else {
|
||||||
|
append(conf.getDomain(defaultDomain))
|
||||||
}
|
}
|
||||||
append(conf.getDomain(defaultDomain))
|
|
||||||
append(this@withDomain)
|
append(this@withDomain)
|
||||||
}
|
}
|
||||||
else -> this
|
else -> this
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.core.parser.site
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.core.model.*
|
import org.koitharu.kotatsu.core.model.*
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.map
|
||||||
|
import org.koitharu.kotatsu.utils.ext.mapIndexed
|
||||||
|
import org.koitharu.kotatsu.utils.ext.stringIterator
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||||
@@ -16,164 +21,243 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
|||||||
SortOrder.NEWEST
|
SortOrder.NEWEST
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun getFaviconUrl(): String {
|
||||||
|
return "https://cdn.${getDomain()}/favicons/favicon.png"
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getList2(
|
override suspend fun getList2(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?,
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
if (!query.isNullOrEmpty()) {
|
if (!query.isNullOrEmpty()) {
|
||||||
return if (offset == 0) search(query) else emptyList()
|
return if (offset == 0) {
|
||||||
|
search(query)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val page = (offset / 12f).toIntUp().inc()
|
val filters = tags?.takeUnless { it.isEmpty() }?.joinToString(
|
||||||
val link = when {
|
separator = ",",
|
||||||
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
|
prefix = "genres: [",
|
||||||
else -> tags.joinToString(
|
postfix = "]"
|
||||||
prefix = "/manga?",
|
) { "\"it.key\"" }.orEmpty()
|
||||||
postfix = "&page=$page",
|
val array = apiCall(
|
||||||
separator = "&",
|
"""
|
||||||
) { tag -> "genre[]=${tag.key}" }.withDomain()
|
getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) {
|
||||||
}
|
docs {
|
||||||
val doc = loaderContext.httpGet(link).parseHtml()
|
mediaId
|
||||||
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
|
title {
|
||||||
val items = root.select("div.anime-card")
|
be
|
||||||
return items.mapNotNull { card ->
|
alt
|
||||||
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
|
}
|
||||||
val status = card.select("tr")[2].text()
|
rating
|
||||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
poster
|
||||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
genres
|
||||||
val titleParts = fullTitle.splitTwoParts('/')
|
slug
|
||||||
|
mediaType
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
).getJSONObject("getMediaList").getJSONArray("docs")
|
||||||
|
return array.map { jo ->
|
||||||
|
val mediaId = jo.getString("mediaId")
|
||||||
|
val title = jo.getJSONObject("title")
|
||||||
|
val href = "${jo.getString("mediaType")}/${jo.getString("slug")}"
|
||||||
Manga(
|
Manga(
|
||||||
id = generateUid(href),
|
id = generateUid(mediaId),
|
||||||
title = titleParts?.first?.trim() ?: fullTitle,
|
title = title.getString("be"),
|
||||||
coverUrl = card.selectFirst("img")?.attr("data-src")
|
coverUrl = jo.getString("poster").removePrefix("/cdn")
|
||||||
?.withDomain().orEmpty(),
|
.withDomain("cdn") + "?width=200&height=280",
|
||||||
altTitle = titleParts?.second?.trim(),
|
altTitle = title.getString("alt").takeUnless(String::isEmpty),
|
||||||
author = null,
|
author = null,
|
||||||
rating = Manga.NO_RATING,
|
rating = jo.getDouble("rating").toFloat() / 10f,
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = href.withDomain(),
|
publicUrl = "https://${getDomain()}/${href}",
|
||||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
tags = jo.getJSONArray("genres").mapToTags(),
|
||||||
MangaTag(
|
state = when (jo.getString("status")) {
|
||||||
title = x.text(),
|
"ongoing" -> MangaState.ONGOING
|
||||||
key = x.attr("href").ifEmpty {
|
"finished" -> MangaState.FINISHED
|
||||||
return@mapNotNull null
|
|
||||||
}.substringAfterLast("="),
|
|
||||||
source = source
|
|
||||||
)
|
|
||||||
},
|
|
||||||
state = when (status) {
|
|
||||||
"выпускаецца" -> MangaState.ONGOING
|
|
||||||
"завершанае" -> MangaState.FINISHED
|
|
||||||
else -> null
|
else -> null
|
||||||
},
|
},
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
|
val (type, slug) = manga.url.split('/')
|
||||||
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
|
val details = apiCall(
|
||||||
|
"""
|
||||||
|
media(mediaType: $type, slug: "$slug") {
|
||||||
|
mediaId
|
||||||
|
title {
|
||||||
|
be
|
||||||
|
alt
|
||||||
|
}
|
||||||
|
description {
|
||||||
|
be
|
||||||
|
}
|
||||||
|
status
|
||||||
|
poster
|
||||||
|
rating
|
||||||
|
genres
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
).getJSONObject("media")
|
||||||
|
val title = details.getJSONObject("title")
|
||||||
|
val poster = details.getString("poster").removePrefix("/cdn")
|
||||||
|
.withDomain("cdn")
|
||||||
|
val chapters = apiCall(
|
||||||
|
"""
|
||||||
|
chapters(mediaId: "${details.getString("mediaId")}") {
|
||||||
|
id
|
||||||
|
chapter
|
||||||
|
released
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
).getJSONArray("chapters")
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
|
title = title.getString("be"),
|
||||||
chapters = root.select("ul.series").flatMap { table ->
|
altTitle = title.getString("alt"),
|
||||||
table.select("li")
|
coverUrl = "$poster?width=200&height=280",
|
||||||
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
|
largeCoverUrl = poster,
|
||||||
val href = a?.select("a")?.first()?.attr("href")
|
description = details.getJSONObject("description").getString("be"),
|
||||||
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null
|
rating = details.getDouble("rating").toFloat() / 10f,
|
||||||
|
tags = details.getJSONArray("genres").mapToTags(),
|
||||||
|
state = when (details.getString("status")) {
|
||||||
|
"ongoing" -> MangaState.ONGOING
|
||||||
|
"finished" -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
chapters = chapters.map { jo ->
|
||||||
|
val number = jo.getInt("chapter")
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(href),
|
id = generateUid(jo.getString("id")),
|
||||||
name = "Глава " + a.selectFirst("a")?.text().orEmpty(),
|
name = "Глава $number",
|
||||||
number = i + 1,
|
number = number,
|
||||||
url = href,
|
url = "${manga.url}/read/$number",
|
||||||
uploadDate = 0L,
|
scanlator = null,
|
||||||
source = source
|
uploadDate = jo.getLong("released"),
|
||||||
|
branch = null,
|
||||||
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val fullUrl = chapter.url.withDomain()
|
val (_, slug, _, number) = chapter.url.split('/')
|
||||||
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
val chapterJson = apiCall(
|
||||||
val scripts = doc.select("script")
|
"""
|
||||||
for (script in scripts) {
|
chapter(slug: "$slug", chapter: $number) {
|
||||||
val data = script.html()
|
id
|
||||||
val pos = data.indexOf("dataSource")
|
images {
|
||||||
if (pos == -1) {
|
large
|
||||||
continue
|
thumbnail
|
||||||
}
|
}
|
||||||
val json = data.substring(pos).substringAfter('[').substringBefore(']')
|
|
||||||
val domain = getDomain()
|
|
||||||
return json.split(",").mapNotNull {
|
|
||||||
it.trim()
|
|
||||||
.removeSurrounding('"', '\'')
|
|
||||||
.toRelativeUrl(domain)
|
|
||||||
.takeUnless(String::isBlank)
|
|
||||||
}.map { url ->
|
|
||||||
MangaPage(
|
|
||||||
id = generateUid(url),
|
|
||||||
url = url,
|
|
||||||
referer = fullUrl,
|
|
||||||
source = source
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
).getJSONObject("chapter")
|
||||||
|
val pages = chapterJson.getJSONArray("images")
|
||||||
|
val chapterUrl = "https://${getDomain()}/${chapter.url}"
|
||||||
|
return pages.mapIndexed { i, jo ->
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid("${chapter.url}/$i"),
|
||||||
|
url = jo.getString("large"),
|
||||||
|
referer = chapterUrl,
|
||||||
|
preview = jo.getString("thumbnail"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
parseFailed("Pages list not found at ${chapter.url.withDomain()}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTags(): Set<MangaTag> {
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
|
val json = apiCall(
|
||||||
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
|
"""
|
||||||
return root.select("p.menu-tags.tupe").mapToSet { p ->
|
getFilters(mediaType: manga) {
|
||||||
val a = p.selectFirst("a") ?: parseFailed("a is null")
|
genres
|
||||||
MangaTag(
|
}
|
||||||
title = a.text().toCamelCase(),
|
""".trimIndent()
|
||||||
key = a.attr("data-name"),
|
)
|
||||||
source = source
|
val array = json.getJSONObject("getFilters").getJSONArray("genres")
|
||||||
)
|
return array.mapToTags()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun search(query: String): List<Manga> {
|
private suspend fun search(query: String): List<Manga> {
|
||||||
val domain = getDomain()
|
val json = apiCall(
|
||||||
val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml()
|
"""
|
||||||
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: parseFailed("Cannot find root")
|
search(query: "$query", limit: 40) {
|
||||||
val items = root.select("div.anime-card")
|
id
|
||||||
return items.mapNotNull { card ->
|
title {
|
||||||
val href = card.select("a").attr("href")
|
be
|
||||||
val status = card.select("tr")[2].text()
|
en
|
||||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
}
|
||||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
poster
|
||||||
val titleParts = fullTitle.splitTwoParts('/')
|
url
|
||||||
|
type
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
val array = json.getJSONArray("search")
|
||||||
|
return array.map { jo ->
|
||||||
|
val mediaId = jo.getString("id")
|
||||||
|
val title = jo.getJSONObject("title")
|
||||||
|
val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}"
|
||||||
Manga(
|
Manga(
|
||||||
id = generateUid(href),
|
id = generateUid(mediaId),
|
||||||
title = titleParts?.first?.trim() ?: fullTitle,
|
title = title.getString("be"),
|
||||||
coverUrl = card.selectFirst("img")?.attr("src")
|
coverUrl = jo.getString("poster").removePrefix("/cdn")
|
||||||
?.withDomain().orEmpty(),
|
.withDomain("cdn") + "?width=200&height=280",
|
||||||
altTitle = titleParts?.second?.trim(),
|
altTitle = title.getString("en").takeUnless(String::isEmpty),
|
||||||
author = null,
|
author = null,
|
||||||
rating = Manga.NO_RATING,
|
rating = Manga.NO_RATING,
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = href.withDomain(),
|
publicUrl = "https://${getDomain()}/${href}",
|
||||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
tags = emptySet(),
|
||||||
MangaTag(
|
state = null,
|
||||||
title = x.text(),
|
source = source,
|
||||||
key = x.attr("href").ifEmpty {
|
|
||||||
return@mapNotNull null
|
|
||||||
}.substringAfterLast("="),
|
|
||||||
source = source
|
|
||||||
)
|
|
||||||
},
|
|
||||||
state = when (status) {
|
|
||||||
"выпускаецца" -> MangaState.ONGOING
|
|
||||||
"завершанае" -> MangaState.FINISHED
|
|
||||||
else -> null
|
|
||||||
},
|
|
||||||
source = source
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun apiCall(request: String): JSONObject {
|
||||||
|
return loaderContext.graphQLQuery("https://api.${getDomain()}/graphql", request)
|
||||||
|
.getJSONObject("data")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONArray.mapToTags(): Set<MangaTag> {
|
||||||
|
|
||||||
|
fun toTitle(slug: String): String {
|
||||||
|
val builder = StringBuilder(slug)
|
||||||
|
var capitalize = true
|
||||||
|
for ((i, c) in builder.withIndex()) {
|
||||||
|
when {
|
||||||
|
c == '-' -> {
|
||||||
|
builder.setCharAt(i, ' ')
|
||||||
|
capitalize = true
|
||||||
|
}
|
||||||
|
capitalize -> {
|
||||||
|
builder.setCharAt(i, c.uppercaseChar())
|
||||||
|
capitalize = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = ArraySet<MangaTag>(length())
|
||||||
|
stringIterator().forEach {
|
||||||
|
result.add(
|
||||||
|
MangaTag(
|
||||||
|
title = toTitle(it),
|
||||||
|
key = it,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,10 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
|||||||
name = tr.selectFirst("a")?.text().orEmpty(),
|
name = tr.selectFirst("a")?.text().orEmpty(),
|
||||||
number = i + 1,
|
number = i + 1,
|
||||||
url = href,
|
url = href,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
|
uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -117,8 +119,9 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
preview = null,
|
||||||
referer = fullUrl,
|
referer = fullUrl,
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,15 +93,17 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
|||||||
description = json.getString("description"),
|
description = json.getString("description"),
|
||||||
chapters = chaptersList.mapIndexed { i, it ->
|
chapters = chaptersList.mapIndexed { i, it ->
|
||||||
val chid = it.getLong("id")
|
val chid = it.getLong("id")
|
||||||
val volChap = "Том " + it.getString("vol") + ". " + "Глава " + it.getString("ch")
|
val volChap = "Том " + it.optString("vol", "0") + ". " + "Глава " + it.optString("ch", "0")
|
||||||
val title = if (it.getString("title") == "null") "" else it.getString("title")
|
val title = it.optString("title", "null").takeUnless { it == "null" }
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(chid),
|
id = generateUid(chid),
|
||||||
source = manga.source,
|
source = manga.source,
|
||||||
url = "$baseChapterUrl$chid",
|
url = "$baseChapterUrl$chid",
|
||||||
uploadDate = it.getLong("date") * 1000,
|
uploadDate = it.getLong("date") * 1000,
|
||||||
name = if (title.isEmpty()) volChap else "$volChap: $title",
|
name = if (title.isNullOrEmpty()) volChap else "$volChap: $title",
|
||||||
number = totalChapters - i
|
number = totalChapters - i,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}.reversed()
|
}.reversed()
|
||||||
)
|
)
|
||||||
@@ -116,8 +118,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(jo.getLong("id")),
|
id = generateUid(jo.getLong("id")),
|
||||||
referer = fullUrl,
|
referer = fullUrl,
|
||||||
|
preview = null,
|
||||||
source = chapter.source,
|
source = chapter.source,
|
||||||
url = jo.getString("img")
|
url = jo.getString("img"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ class ExHentaiRepository(
|
|||||||
url = url,
|
url = url,
|
||||||
uploadDate = 0L,
|
uploadDate = 0L,
|
||||||
source = source,
|
source = source,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
chapters
|
chapters
|
||||||
|
|||||||
@@ -110,11 +110,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
|||||||
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||||
?: throw ParseException("Cannot find root")
|
?: throw ParseException("Cannot find root")
|
||||||
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
|
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
|
||||||
|
val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
description = root.selectFirst("div.manga-description")?.html(),
|
description = root.selectFirst("div.manga-description")?.html(),
|
||||||
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
|
largeCoverUrl = coverImg?.attr("data-full"),
|
||||||
"data-full"
|
coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl,
|
||||||
),
|
|
||||||
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
|
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
|
||||||
.mapNotNull {
|
.mapNotNull {
|
||||||
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
|
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
|
||||||
@@ -142,7 +142,8 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
|||||||
url = href,
|
url = href,
|
||||||
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
|
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
|
||||||
scanlator = translators,
|
scanlator = translators,
|
||||||
source = source
|
source = source,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -167,8 +168,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
preview = null,
|
||||||
referer = chapter.url,
|
referer = chapter.url,
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
|
|||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
return super.getList2(offset, query, tags, sortOrder).map {
|
return super.getList2(offset, query, tags, sortOrder).map {
|
||||||
val cover = it.coverUrl
|
it.copy(
|
||||||
if (cover.contains("_blur")) {
|
coverUrl = it.coverUrl.replace("_blur", ""),
|
||||||
it.copy(coverUrl = cover.replace("_blur", ""))
|
isNsfw = true,
|
||||||
} else {
|
)
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +48,9 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
|
|||||||
source = source,
|
source = source,
|
||||||
number = 1,
|
number = 1,
|
||||||
uploadDate = 0L,
|
uploadDate = 0L,
|
||||||
name = manga.title
|
name = manga.title,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.core.model.*
|
||||||
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
private const val PAGE_SIZE = 20
|
||||||
|
private const val CONTENT_RATING =
|
||||||
|
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
||||||
|
private const val LOCALE_FALLBACK = "en"
|
||||||
|
|
||||||
|
class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||||
|
|
||||||
|
override val source = MangaSource.MANGADEX
|
||||||
|
override val defaultDomain = "mangadex.org"
|
||||||
|
|
||||||
|
override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
SortOrder.ALPHABETICAL,
|
||||||
|
SortOrder.NEWEST,
|
||||||
|
SortOrder.POPULARITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getList2(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder?,
|
||||||
|
): List<Manga> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val url = buildString {
|
||||||
|
append("https://api.")
|
||||||
|
append(domain)
|
||||||
|
append("/manga?limit=")
|
||||||
|
append(PAGE_SIZE)
|
||||||
|
append("&offset=")
|
||||||
|
append(offset)
|
||||||
|
append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
|
||||||
|
tags?.forEach { tag ->
|
||||||
|
append("includedTags[]=")
|
||||||
|
append(tag.key)
|
||||||
|
append('&')
|
||||||
|
}
|
||||||
|
if (!query.isNullOrEmpty()) {
|
||||||
|
append("title=")
|
||||||
|
append(query.urlEncoded())
|
||||||
|
append('&')
|
||||||
|
}
|
||||||
|
append(CONTENT_RATING)
|
||||||
|
append("&order")
|
||||||
|
append(when (sortOrder) {
|
||||||
|
null,
|
||||||
|
SortOrder.UPDATED,
|
||||||
|
-> "[latestUploadedChapter]=desc"
|
||||||
|
SortOrder.ALPHABETICAL -> "[title]=asc"
|
||||||
|
SortOrder.NEWEST -> "[createdAt]=desc"
|
||||||
|
SortOrder.POPULARITY -> "[followedCount]=desc"
|
||||||
|
else -> "[followedCount]=desc"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val json = loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||||
|
return json.map { jo ->
|
||||||
|
val id = jo.getString("id")
|
||||||
|
val attrs = jo.getJSONObject("attributes")
|
||||||
|
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||||
|
val cover = relations["cover_art"]
|
||||||
|
?.getJSONObject("attributes")
|
||||||
|
?.getString("fileName")
|
||||||
|
?.let {
|
||||||
|
"https://uploads.$domain/covers/$id/$it"
|
||||||
|
}
|
||||||
|
Manga(
|
||||||
|
id = generateUid(id),
|
||||||
|
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
|
||||||
|
"Title should not be null"
|
||||||
|
},
|
||||||
|
altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
|
||||||
|
url = id,
|
||||||
|
publicUrl = "https://$domain/title/$id",
|
||||||
|
rating = Manga.NO_RATING,
|
||||||
|
isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
|
||||||
|
coverUrl = cover?.plus(".256.jpg").orEmpty(),
|
||||||
|
largeCoverUrl = cover,
|
||||||
|
description = attrs.optJSONObject("description")?.selectByLocale(),
|
||||||
|
tags = attrs.getJSONArray("tags").mapToSet { tag ->
|
||||||
|
MangaTag(
|
||||||
|
title = tag.getJSONObject("attributes")
|
||||||
|
.getJSONObject("name")
|
||||||
|
.firstStringValue(),
|
||||||
|
key = tag.getString("id"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
state = when (jo.getStringOrNull("status")) {
|
||||||
|
"ongoing" -> MangaState.ONGOING
|
||||||
|
"completed" -> MangaState.FINISHED
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
author = (relations["author"] ?: relations["artist"])
|
||||||
|
?.getJSONObject("attributes")
|
||||||
|
?.getStringOrNull("name"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga = coroutineScope<Manga> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val attrsDeferred = async {
|
||||||
|
loaderContext.httpGet(
|
||||||
|
"https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art"
|
||||||
|
).parseJson().getJSONObject("data").getJSONObject("attributes")
|
||||||
|
}
|
||||||
|
val feedDeferred = async {
|
||||||
|
val url = buildString {
|
||||||
|
append("https://api.")
|
||||||
|
append(domain)
|
||||||
|
append("/manga/")
|
||||||
|
append(manga.url)
|
||||||
|
append("/feed")
|
||||||
|
append("?limit=96&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=0&")
|
||||||
|
append(CONTENT_RATING)
|
||||||
|
}
|
||||||
|
loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||||
|
}
|
||||||
|
val mangaAttrs = attrsDeferred.await()
|
||||||
|
val feed = feedDeferred.await()
|
||||||
|
//2022-01-02T00:27:11+00:00
|
||||||
|
val dateFormat = SimpleDateFormat(
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssX"
|
||||||
|
} else {
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss'+00:00'"
|
||||||
|
},
|
||||||
|
Locale.ROOT
|
||||||
|
)
|
||||||
|
manga.copy(
|
||||||
|
description = mangaAttrs.getJSONObject("description").selectByLocale()
|
||||||
|
?: manga.description,
|
||||||
|
chapters = feed.mapNotNull { jo ->
|
||||||
|
val id = jo.getString("id")
|
||||||
|
val attrs = jo.getJSONObject("attributes")
|
||||||
|
if (!attrs.isNull("externalUrl")) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage"))
|
||||||
|
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||||
|
val number = attrs.optInt("chapter", 0)
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(id),
|
||||||
|
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
|
||||||
|
?: "Chapter #$number",
|
||||||
|
number = number,
|
||||||
|
url = id,
|
||||||
|
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
|
||||||
|
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
|
||||||
|
branch = locale.displayName.toTitleCase(locale),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
val domain = getDomain()
|
||||||
|
val chapter = loaderContext.httpGet("https://api.$domain/at-home/server/${chapter.url}?forcePort443=false")
|
||||||
|
.parseJson()
|
||||||
|
.getJSONObject("chapter")
|
||||||
|
val pages = chapter.getJSONArray("data")
|
||||||
|
val prefix = "https://uploads.$domain/data/${chapter.getString("hash")}/"
|
||||||
|
val referer = "https://$domain/"
|
||||||
|
return List(pages.length()) { i ->
|
||||||
|
val url = prefix + pages.getString(i)
|
||||||
|
MangaPage(
|
||||||
|
id = generateUid(url),
|
||||||
|
url = url,
|
||||||
|
referer = referer,
|
||||||
|
preview = null, // TODO prefix + dataSaver.getString(i),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
val tags = loaderContext.httpGet("https://api.${getDomain()}/manga/tag").parseJson()
|
||||||
|
.getJSONArray("data")
|
||||||
|
return tags.mapToSet { jo ->
|
||||||
|
MangaTag(
|
||||||
|
title = jo.getJSONObject("attributes").getJSONObject("name").firstStringValue(),
|
||||||
|
key = jo.getString("id"),
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONObject.firstStringValue() = values().next() as String
|
||||||
|
|
||||||
|
private fun JSONObject.selectByLocale(): String? {
|
||||||
|
val preferredLocales = LocaleListCompat.getAdjustedDefault()
|
||||||
|
repeat(preferredLocales.size()) { i ->
|
||||||
|
val locale = preferredLocales.get(i)
|
||||||
|
getStringOrNull(locale.language)?.let { return it }
|
||||||
|
getStringOrNull(locale.toLanguageTag())?.let { return it }
|
||||||
|
}
|
||||||
|
return getStringOrNull(LOCALE_FALLBACK) ?: values().nextOrNull() as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,7 +118,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
|||||||
item.getString("chapter_created_at").substringBefore(" ")
|
item.getString("chapter_created_at").substringBefore(" ")
|
||||||
),
|
),
|
||||||
scanlator = scanlator,
|
scanlator = scanlator,
|
||||||
name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter"
|
branch = null,
|
||||||
|
name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -178,8 +179,9 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(pageUrl),
|
id = generateUid(pageUrl),
|
||||||
url = pageUrl,
|
url = pageUrl,
|
||||||
|
preview = null,
|
||||||
referer = fullUrl,
|
referer = fullUrl,
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.parser.site
|
package org.koitharu.kotatsu.core.parser.site
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||||
import org.koitharu.kotatsu.core.model.*
|
import org.koitharu.kotatsu.core.model.*
|
||||||
@@ -75,6 +76,10 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
|
|||||||
val info = doc.body().selectFirst("div.single_detail") ?: parseFailed("An error occurred while parsing")
|
val info = doc.body().selectFirst("div.single_detail") ?: parseFailed("An error occurred while parsing")
|
||||||
val table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing")
|
val table = doc.body().selectFirst("div.single-grid-right") ?: parseFailed("An error occurred while parsing")
|
||||||
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
|
val dateFormat = SimpleDateFormat("MM/dd/yyyy", Locale.US)
|
||||||
|
val trRegex = "window\\['tr'] = '([^']*)';".toRegex(RegexOption.IGNORE_CASE)
|
||||||
|
val trElement = doc.getElementsByTag("script").find { trRegex.find(it.data()) != null } ?: parseFailed("Oops, tr not found")
|
||||||
|
val tr = trRegex.find(trElement.data())!!.groups[1]!!.value
|
||||||
|
val s = Base64.encodeToString(defaultDomain.toByteArray(), Base64.NO_PADDING)
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
description = info.selectFirst(".description")?.html(),
|
description = info.selectFirst(".description")?.html(),
|
||||||
largeCoverUrl = info.select("img").first()?.let { img ->
|
largeCoverUrl = info.select("img").first()?.let { img ->
|
||||||
@@ -93,16 +98,18 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
|
|||||||
},
|
},
|
||||||
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
|
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
|
||||||
val a = li.select("a")
|
val a = li.select("a")
|
||||||
val href = a.attr("href").ifEmpty {
|
val href = a.attr("data-href").ifEmpty {
|
||||||
parseFailed("Link is missing")
|
parseFailed("Link is missing")
|
||||||
}
|
}
|
||||||
MangaChapter(
|
MangaChapter(
|
||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
name = a.select("label").text(),
|
name = a.select("label").text(),
|
||||||
number = i + 1,
|
number = i + 1,
|
||||||
url = href,
|
url = "$href?tr=$tr&s=$s",
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
|
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
|
||||||
source = MangaSource.MANGAOWL
|
source = MangaSource.MANGAOWL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -117,8 +124,9 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
referer = fullUrl,
|
preview = null,
|
||||||
source = MangaSource.MANGAOWL
|
referer = url,
|
||||||
|
source = MangaSource.MANGAOWL,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,9 +124,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
|||||||
dateFormat,
|
dateFormat,
|
||||||
li.selectFirst("span.time")?.text()
|
li.selectFirst("span.time")?.text()
|
||||||
),
|
),
|
||||||
name = name.ifEmpty { "${manga.title} - ${i + 1}" }
|
name = name.ifEmpty { "${manga.title} - ${i + 1}" },
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}
|
} ?: bypassLicensedChapters(manga)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,8 +145,9 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
|||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
url = href,
|
url = href,
|
||||||
|
preview = null,
|
||||||
referer = fullUrl,
|
referer = fullUrl,
|
||||||
source = MangaSource.MANGATOWN
|
source = MangaSource.MANGATOWN,
|
||||||
)
|
)
|
||||||
} ?: parseFailed("Pages list not found")
|
} ?: parseFailed("Pages list not found")
|
||||||
}
|
}
|
||||||
@@ -188,6 +191,32 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
|||||||
map[SourceSettings.KEY_USE_SSL] = true
|
map[SourceSettings.KEY_USE_SSL] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> {
|
||||||
|
val doc = loaderContext.httpGet(manga.url.withDomain("m")).parseHtml()
|
||||||
|
val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
|
||||||
|
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
|
||||||
|
return list.select("li").asReversed().mapIndexedNotNull { i, li ->
|
||||||
|
val a = li.selectFirst("a") ?: return@mapIndexedNotNull null
|
||||||
|
val href = a.relUrl("href")
|
||||||
|
val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty {
|
||||||
|
a.ownText()
|
||||||
|
}
|
||||||
|
MangaChapter(
|
||||||
|
id = generateUid(href),
|
||||||
|
url = href,
|
||||||
|
source = MangaSource.MANGATOWN,
|
||||||
|
number = i + 1,
|
||||||
|
uploadDate = parseChapterDate(
|
||||||
|
dateFormat,
|
||||||
|
li.selectFirst("span.time")?.text()
|
||||||
|
),
|
||||||
|
name = name.ifEmpty { "${manga.title} - ${i + 1}" },
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it }
|
private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class MangareadRepository(
|
|||||||
id = generateUid(href),
|
id = generateUid(href),
|
||||||
url = href,
|
url = href,
|
||||||
publicUrl = href.inContextOf(div),
|
publicUrl = href.inContextOf(div),
|
||||||
coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(),
|
coverUrl = div.selectFirst("img")?.absUrl("data-src").orEmpty(),
|
||||||
title = summary?.selectFirst("h3")?.text().orEmpty(),
|
title = summary?.selectFirst("h3")?.text().orEmpty(),
|
||||||
rating = div.selectFirst("span.total_votes")?.ownText()
|
rating = div.selectFirst("span.total_votes")?.ownText()
|
||||||
?.toFloatOrNull()?.div(5f) ?: -1f,
|
?.toFloatOrNull()?.div(5f) ?: -1f,
|
||||||
@@ -107,16 +107,6 @@ class MangareadRepository(
|
|||||||
val root2 = doc.body().selectFirst("div.content-area")
|
val root2 = doc.body().selectFirst("div.content-area")
|
||||||
?.selectFirst("div.c-page")
|
?.selectFirst("div.c-page")
|
||||||
?: throw ParseException("Root2 not found")
|
?: throw ParseException("Root2 not found")
|
||||||
val mangaId = doc.getElementsByAttribute("data-post").firstOrNull()
|
|
||||||
?.attr("data-post")?.toLongOrNull()
|
|
||||||
?: throw ParseException("Cannot obtain manga id")
|
|
||||||
val doc2 = loaderContext.httpPost(
|
|
||||||
"https://${getDomain()}/wp-admin/admin-ajax.php",
|
|
||||||
mapOf(
|
|
||||||
"action" to "manga_get_chapters",
|
|
||||||
"manga" to mangaId.toString()
|
|
||||||
)
|
|
||||||
).parseHtml()
|
|
||||||
val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
|
val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
|
||||||
return manga.copy(
|
return manga.copy(
|
||||||
tags = root.selectFirst("div.genres-content")?.select("a")
|
tags = root.selectFirst("div.genres-content")?.select("a")
|
||||||
@@ -132,7 +122,7 @@ class MangareadRepository(
|
|||||||
?.select("p")
|
?.select("p")
|
||||||
?.filterNot { it.ownText().startsWith("A brief description") }
|
?.filterNot { it.ownText().startsWith("A brief description") }
|
||||||
?.joinToString { it.html() },
|
?.joinToString { it.html() },
|
||||||
chapters = doc2.select("li").asReversed().mapIndexed { i, li ->
|
chapters = root2.select("li").asReversed().mapIndexed { i, li ->
|
||||||
val a = li.selectFirst("a")
|
val a = li.selectFirst("a")
|
||||||
val href = a?.relUrl("href").orEmpty().ifEmpty {
|
val href = a?.relUrl("href").orEmpty().ifEmpty {
|
||||||
parseFailed("Link is missing")
|
parseFailed("Link is missing")
|
||||||
@@ -144,9 +134,11 @@ class MangareadRepository(
|
|||||||
url = href,
|
url = href,
|
||||||
uploadDate = parseChapterDate(
|
uploadDate = parseChapterDate(
|
||||||
dateFormat,
|
dateFormat,
|
||||||
doc2.selectFirst("span.chapter-release-date i")?.text()
|
li.selectFirst("span.chapter-release-date i")?.text()
|
||||||
),
|
),
|
||||||
source = MangaSource.MANGAREAD
|
source = MangaSource.MANGAREAD,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -159,13 +151,16 @@ class MangareadRepository(
|
|||||||
?.selectFirst("div.reading-content")
|
?.selectFirst("div.reading-content")
|
||||||
?: throw ParseException("Root not found")
|
?: throw ParseException("Root not found")
|
||||||
return root.select("div.page-break").map { div ->
|
return root.select("div.page-break").map { div ->
|
||||||
val img = div.selectFirst("img")
|
val img = div.selectFirst("img") ?: parseFailed("Page image not found")
|
||||||
val url = img?.relUrl("src") ?: parseFailed("Page image not found")
|
val url = img.relUrl("data-src").ifEmpty {
|
||||||
|
img.relUrl("src")
|
||||||
|
}
|
||||||
MangaPage(
|
MangaPage(
|
||||||
id = generateUid(url),
|
id = generateUid(url),
|
||||||
url = url,
|
url = url,
|
||||||
|
preview = null,
|
||||||
referer = fullUrl,
|
referer = fullUrl,
|
||||||
source = MangaSource.MANGAREAD
|
source = MangaSource.MANGAREAD,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ abstract class NineMangaRepository(
|
|||||||
append("&page=")
|
append("&page=")
|
||||||
}
|
}
|
||||||
!tags.isNullOrEmpty() -> {
|
!tags.isNullOrEmpty() -> {
|
||||||
append("/search/&category_id=")
|
append("/search/?category_id=")
|
||||||
for (tag in tags) {
|
for (tag in tags) {
|
||||||
append(tag.key)
|
append(tag.key)
|
||||||
append(',')
|
append(',')
|
||||||
@@ -114,6 +114,8 @@ abstract class NineMangaRepository(
|
|||||||
url = href,
|
url = href,
|
||||||
uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
|
uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
|
||||||
source = source,
|
source = source,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,16 +6,20 @@ import org.json.JSONObject
|
|||||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||||
import org.koitharu.kotatsu.core.model.*
|
import org.koitharu.kotatsu.core.model.*
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext),
|
||||||
|
MangaRepositoryAuthProvider {
|
||||||
|
|
||||||
override val source = MangaSource.REMANGA
|
override val source = MangaSource.REMANGA
|
||||||
|
|
||||||
override val defaultDomain = "remanga.org"
|
override val defaultDomain = "remanga.org"
|
||||||
|
override val authUrl: String
|
||||||
|
get() = "https://${getDomain()}/user/login"
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||||
SortOrder.UPDATED,
|
SortOrder.UPDATED,
|
||||||
@@ -30,6 +34,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
|
copyCookies()
|
||||||
val domain = getDomain()
|
val domain = getDomain()
|
||||||
val urlBuilder = StringBuilder()
|
val urlBuilder = StringBuilder()
|
||||||
.append("https://api.")
|
.append("https://api.")
|
||||||
@@ -78,6 +83,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga {
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
copyCookies()
|
||||||
val domain = getDomain()
|
val domain = getDomain()
|
||||||
val slug = manga.url.find(LAST_URL_PATH_REGEX)
|
val slug = manga.url.find(LAST_URL_PATH_REGEX)
|
||||||
?: throw ParseException("Cannot obtain slug from ${manga.url}")
|
?: throw ParseException("Cannot obtain slug from ${manga.url}")
|
||||||
@@ -119,10 +125,10 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
|||||||
number = chapters.length() - i,
|
number = chapters.length() - i,
|
||||||
name = buildString {
|
name = buildString {
|
||||||
append("Том ")
|
append("Том ")
|
||||||
append(jo.getString("tome"))
|
append(jo.optString("tome", "0"))
|
||||||
append(". ")
|
append(". ")
|
||||||
append("Глава ")
|
append("Глава ")
|
||||||
append(jo.getString("chapter"))
|
append(jo.optString("chapter", "0"))
|
||||||
if (name.isNotEmpty()) {
|
if (name.isNotEmpty()) {
|
||||||
append(" - ")
|
append(" - ")
|
||||||
append(name)
|
append(name)
|
||||||
@@ -130,7 +136,8 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
|||||||
},
|
},
|
||||||
uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
|
uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
|
||||||
scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
|
scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
|
||||||
source = MangaSource.REMANGA
|
source = MangaSource.REMANGA,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}.asReversed()
|
}.asReversed()
|
||||||
)
|
)
|
||||||
@@ -164,6 +171,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun isAuthorized(): Boolean {
|
||||||
|
return loaderContext.cookieJar.getCookies(getDomain()).any {
|
||||||
|
it.name == "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyCookies() {
|
||||||
|
val domain = getDomain()
|
||||||
|
loaderContext.cookieJar.copyCookies(domain, "api.$domain")
|
||||||
|
}
|
||||||
|
|
||||||
private fun getSortKey(order: SortOrder?) = when (order) {
|
private fun getSortKey(order: SortOrder?) = when (order) {
|
||||||
SortOrder.UPDATED -> "-chapter_date"
|
SortOrder.UPDATED -> "-chapter_date"
|
||||||
SortOrder.POPULARITY -> "-rating"
|
SortOrder.POPULARITY -> "-rating"
|
||||||
@@ -175,8 +193,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
|||||||
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
|
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
|
||||||
id = generateUid(jo.getLong("id")),
|
id = generateUid(jo.getLong("id")),
|
||||||
url = jo.getString("link"),
|
url = jo.getString("link"),
|
||||||
|
preview = null,
|
||||||
referer = referer,
|
referer = referer,
|
||||||
source = source
|
source = source,
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loa
|
|||||||
number = i + 1,
|
number = i + 1,
|
||||||
url = href,
|
url = href,
|
||||||
uploadDate = 0L,
|
uploadDate = 0L,
|
||||||
source = source
|
source = source,
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import androidx.collection.arraySetOf
|
|||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.sendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
@@ -79,6 +79,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
|||||||
|
|
||||||
var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
|
var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
|
||||||
|
|
||||||
|
var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false)
|
||||||
|
|
||||||
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
|
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
|
||||||
|
|
||||||
val zoomMode by EnumPreferenceDelegate(
|
val zoomMode by EnumPreferenceDelegate(
|
||||||
@@ -107,6 +109,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
|||||||
val isSourcesSelected: Boolean
|
val isSourcesSelected: Boolean
|
||||||
get() = KEY_SOURCES_HIDDEN in prefs
|
get() = KEY_SOURCES_HIDDEN in prefs
|
||||||
|
|
||||||
|
val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
|
||||||
|
|
||||||
fun getStorageDir(context: Context): File? {
|
fun getStorageDir(context: Context): File? {
|
||||||
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||||
File(it)
|
File(it)
|
||||||
@@ -141,7 +145,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
|||||||
|
|
||||||
fun observe() = callbackFlow<String> {
|
fun observe() = callbackFlow<String> {
|
||||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
sendBlocking(key)
|
trySendBlocking(key)
|
||||||
}
|
}
|
||||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
awaitClose {
|
awaitClose {
|
||||||
@@ -192,6 +196,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
|||||||
const val KEY_RESTORE = "restore"
|
const val KEY_RESTORE = "restore"
|
||||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||||
|
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||||
|
const val KEY_PAGES_NUMBERS = "pages_numbers"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
|
|||||||
@@ -14,16 +14,35 @@ sealed class DateTimeAgo : ListModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
|
class MinutesAgo(val minutes: Int) : DateTimeAgo() {
|
||||||
|
|
||||||
override fun format(resources: Resources): String {
|
override fun format(resources: Resources): String {
|
||||||
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
|
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
other as MinutesAgo
|
||||||
|
return minutes == other.minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int = minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
data class HoursAgo(val hours: Int) : DateTimeAgo() {
|
class HoursAgo(val hours: Int) : DateTimeAgo() {
|
||||||
override fun format(resources: Resources): String {
|
override fun format(resources: Resources): String {
|
||||||
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
|
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
other as HoursAgo
|
||||||
|
return hours == other.hours
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int = hours
|
||||||
}
|
}
|
||||||
|
|
||||||
object Today : DateTimeAgo() {
|
object Today : DateTimeAgo() {
|
||||||
@@ -38,10 +57,19 @@ sealed class DateTimeAgo : ListModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class DaysAgo(val days: Int) : DateTimeAgo() {
|
class DaysAgo(val days: Int) : DateTimeAgo() {
|
||||||
override fun format(resources: Resources): String {
|
override fun format(resources: Resources): String {
|
||||||
return resources.getQuantityString(R.plurals.days_ago, days, days)
|
return resources.getQuantityString(R.plurals.days_ago, days, days)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
other as DaysAgo
|
||||||
|
return days == other.days
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int = days
|
||||||
}
|
}
|
||||||
|
|
||||||
object LongAgo : DateTimeAgo() {
|
object LongAgo : DateTimeAgo() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import coil.ImageLoader
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
import org.koitharu.kotatsu.core.parser.FaviconMapper
|
||||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||||
|
|
||||||
val uiModule
|
val uiModule
|
||||||
@@ -15,6 +16,7 @@ val uiModule
|
|||||||
.componentRegistry(
|
.componentRegistry(
|
||||||
ComponentRegistry.Builder()
|
ComponentRegistry.Builder()
|
||||||
.add(CbzFetcher())
|
.add(CbzFetcher())
|
||||||
|
.add(FaviconMapper())
|
||||||
.build()
|
.build()
|
||||||
).build()
|
).build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
|||||||
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||||
TabLayoutMediator.TabConfigurationStrategy {
|
TabLayoutMediator.TabConfigurationStrategy {
|
||||||
|
|
||||||
private val viewModel by viewModel<DetailsViewModel>(mode = LazyThreadSafetyMode.NONE) {
|
private val viewModel by viewModel<DetailsViewModel> {
|
||||||
parametersOf(MangaIntent.from(intent))
|
parametersOf(MangaIntent.from(intent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.details.ui
|
package org.koitharu.kotatsu.details.ui
|
||||||
|
|
||||||
|
import android.app.ActivityOptions
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -12,6 +13,7 @@ import androidx.core.text.parseAsHtml
|
|||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
|
import coil.request.ImageRequest
|
||||||
import coil.util.CoilUtils
|
import coil.util.CoilUtils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -27,6 +29,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.core.model.MangaState
|
import org.koitharu.kotatsu.core.model.MangaState
|
||||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
||||||
|
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||||
@@ -50,6 +53,7 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
|||||||
binding.buttonFavorite.setOnClickListener(this)
|
binding.buttonFavorite.setOnClickListener(this)
|
||||||
binding.buttonRead.setOnClickListener(this)
|
binding.buttonRead.setOnClickListener(this)
|
||||||
binding.buttonRead.setOnLongClickListener(this)
|
binding.buttonRead.setOnLongClickListener(this)
|
||||||
|
binding.coverCard.setOnClickListener(this)
|
||||||
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
|
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
|
||||||
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
|
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
|
||||||
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
|
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
|
||||||
@@ -58,14 +62,8 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
|||||||
|
|
||||||
private fun onMangaUpdated(manga: Manga) {
|
private fun onMangaUpdated(manga: Manga) {
|
||||||
with(binding) {
|
with(binding) {
|
||||||
|
|
||||||
// Main
|
// Main
|
||||||
imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl)
|
loadCover(manga)
|
||||||
.referer(manga.publicUrl)
|
|
||||||
.fallback(R.drawable.ic_placeholder)
|
|
||||||
.placeholderMemoryCacheKey(CoilUtils.metadata(imageViewCover)?.memoryCacheKey)
|
|
||||||
.lifecycle(viewLifecycleOwner)
|
|
||||||
.enqueueWith(coil)
|
|
||||||
textViewTitle.text = manga.title
|
textViewTitle.text = manga.title
|
||||||
textViewSubtitle.textAndVisible = manga.altTitle
|
textViewSubtitle.textAndVisible = manga.altTitle
|
||||||
textViewAuthor.textAndVisible = manga.author
|
textViewAuthor.textAndVisible = manga.author
|
||||||
@@ -189,6 +187,17 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
R.id.cover_card -> {
|
||||||
|
val options = ActivityOptions.makeSceneTransitionAnimation(
|
||||||
|
requireActivity(),
|
||||||
|
binding.imageViewCover,
|
||||||
|
binding.imageViewCover.transitionName,
|
||||||
|
)
|
||||||
|
startActivity(
|
||||||
|
ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl),
|
||||||
|
options.toBundle()
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,4 +248,22 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadCover(manga: Manga) {
|
||||||
|
val currentCover = binding.imageViewCover.drawable
|
||||||
|
val request = ImageRequest.Builder(context ?: return)
|
||||||
|
.target(binding.imageViewCover)
|
||||||
|
if (currentCover != null) {
|
||||||
|
request.data(manga.largeCoverUrl ?: return)
|
||||||
|
.placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey)
|
||||||
|
.fallback(currentCover)
|
||||||
|
} else {
|
||||||
|
request.crossfade(true)
|
||||||
|
.data(manga.coverUrl)
|
||||||
|
.fallback(R.drawable.ic_placeholder)
|
||||||
|
}
|
||||||
|
request.referer(manga.publicUrl)
|
||||||
|
.lifecycle(viewLifecycleOwner)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,9 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
|||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||||
|
import org.koitharu.kotatsu.utils.ext.toTitleCase
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
class DetailsViewModel(
|
class DetailsViewModel(
|
||||||
intent: MangaIntent,
|
intent: MangaIntent,
|
||||||
@@ -127,9 +129,7 @@ class DetailsViewModel(
|
|||||||
selectedBranch.value = if (hist != null) {
|
selectedBranch.value = if (hist != null) {
|
||||||
manga.chapters?.find { it.id == hist.chapterId }?.branch
|
manga.chapters?.find { it.id == hist.chapterId }?.branch
|
||||||
} else {
|
} else {
|
||||||
manga.chapters
|
predictBranch(manga.chapters)
|
||||||
?.groupBy { it.branch }
|
|
||||||
?.maxByOrNull { it.value.size }?.key
|
|
||||||
}
|
}
|
||||||
mangaData.value = manga
|
mangaData.value = manga
|
||||||
if (manga.source == MangaSource.LOCAL) {
|
if (manga.source == MangaSource.LOCAL) {
|
||||||
@@ -240,4 +240,21 @@ class DetailsViewModel(
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun predictBranch(chapters: List<MangaChapter>?): String? {
|
||||||
|
if (chapters.isNullOrEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val groups = chapters.groupBy { it.branch }
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
var language = locale.displayLanguage.toTitleCase(locale)
|
||||||
|
if (groups.containsKey(language)) {
|
||||||
|
return language
|
||||||
|
}
|
||||||
|
language = locale.displayName.toTitleCase(locale)
|
||||||
|
if (groups.containsKey(language)) {
|
||||||
|
return language
|
||||||
|
}
|
||||||
|
return groups.maxByOrNull { it.value.size }?.key
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,10 +19,6 @@ class ChaptersAdapter(
|
|||||||
return items[position].chapter.id
|
return items[position].chapter.id
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setItems(newItems: List<ChapterListItem>, callback: Runnable) {
|
|
||||||
differ.submitList(newItems, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() {
|
private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() {
|
||||||
|
|
||||||
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {
|
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class DownloadManager(
|
|||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
val response = call.clone().await()
|
val response = call.clone().await()
|
||||||
withContext(Dispatchers.IO) {
|
runInterruptible(Dispatchers.IO) {
|
||||||
file.outputStream().use { out ->
|
file.outputStream().use { out ->
|
||||||
checkNotNull(response.body).byteStream().copyTo(out)
|
checkNotNull(response.body).byteStream().copyTo(out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -79,6 +78,11 @@ class DownloadService : BaseService() {
|
|||||||
return binder ?: DownloadBinder(this).also { binder = it }
|
return binder ?: DownloadBinder(this).also { binder = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onUnbind(intent: Intent?): Boolean {
|
||||||
|
binder = null
|
||||||
|
return super.onUnbind(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
unregisterReceiver(controlReceiver)
|
unregisterReceiver(controlReceiver)
|
||||||
binder = null
|
binder = null
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.SortOrder
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@Entity(tableName = "favourite_categories")
|
@Entity(tableName = "favourite_categories")
|
||||||
data class FavouriteCategoryEntity(
|
class FavouriteCategoryEntity(
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
@ColumnInfo(name = "category_id") val categoryId: Int,
|
@ColumnInfo(name = "category_id") val categoryId: Int,
|
||||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
data class FavouriteEntity(
|
class FavouriteEntity(
|
||||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||||
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
|
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
|
||||||
@ColumnInfo(name = "created_at") val createdAt: Long
|
@ColumnInfo(name = "created_at") val createdAt: Long
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|||||||
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
data class FavouriteManga(
|
class FavouriteManga(
|
||||||
@Embedded val favourite: FavouriteEntity,
|
@Embedded val favourite: FavouriteEntity,
|
||||||
@Relation(
|
@Relation(
|
||||||
parentColumn = "manga_id",
|
parentColumn = "manga_id",
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
|||||||
|
|
||||||
override val recycledViewPool = RecyclerView.RecycledViewPool()
|
override val recycledViewPool = RecyclerView.RecycledViewPool()
|
||||||
|
|
||||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>(
|
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||||
mode = LazyThreadSafetyMode.NONE
|
|
||||||
)
|
|
||||||
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
|
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
CategoriesEditDelegate(requireContext(), this)
|
CategoriesEditDelegate(requireContext(), this)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
|||||||
OnListItemClickListener<FavouriteCategory>,
|
OnListItemClickListener<FavouriteCategory>,
|
||||||
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
|
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
|
||||||
|
|
||||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>(
|
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||||
mode = LazyThreadSafetyMode.NONE
|
|
||||||
)
|
|
||||||
|
|
||||||
private lateinit var adapter: CategoriesAdapter
|
private lateinit var adapter: CategoriesAdapter
|
||||||
private lateinit var reorderHelper: ItemTouchHelper
|
private lateinit var reorderHelper: ItemTouchHelper
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
||||||
@@ -32,7 +33,12 @@ class CategoriesEditDelegate(
|
|||||||
.setNegativeButton(android.R.string.cancel)
|
.setNegativeButton(android.R.string.cancel)
|
||||||
.setMaxLength(MAX_TITLE_LENGTH, false)
|
.setMaxLength(MAX_TITLE_LENGTH, false)
|
||||||
.setPositiveButton(R.string.rename) { _, name ->
|
.setPositiveButton(R.string.rename) { _, name ->
|
||||||
callback.onRenameCategory(category, name)
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
callback.onRenameCategory(category, name)
|
||||||
|
}
|
||||||
}.create()
|
}.create()
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
@@ -45,7 +51,12 @@ class CategoriesEditDelegate(
|
|||||||
.setNegativeButton(android.R.string.cancel)
|
.setNegativeButton(android.R.string.cancel)
|
||||||
.setMaxLength(MAX_TITLE_LENGTH, false)
|
.setMaxLength(MAX_TITLE_LENGTH, false)
|
||||||
.setPositiveButton(R.string.add) { _, name ->
|
.setPositiveButton(R.string.add) { _, name ->
|
||||||
callback.onCreateCategory(name)
|
val trimmed = name.trim()
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
callback.onCreateCategory(trimmed)
|
||||||
|
}
|
||||||
}.create()
|
}.create()
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
|
|||||||
OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback,
|
OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback,
|
||||||
View.OnClickListener {
|
View.OnClickListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<MangaCategoriesViewModel>(mode = LazyThreadSafetyMode.NONE) {
|
private val viewModel by viewModel<MangaCategoriesViewModel> {
|
||||||
parametersOf(requireNotNull(arguments?.getParcelable<Manga>(MangaIntent.KEY_MANGA)))
|
parametersOf(requireNotNull(arguments?.getParcelable<Manga>(MangaIntent.KEY_MANGA)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
|
|||||||
|
|
||||||
override fun onInflateView(
|
override fun onInflateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?
|
container: ViewGroup?,
|
||||||
) = DialogFavoriteCategoriesBinding.inflate(inflater, container, false)
|
) = DialogFavoriteCategoriesBinding.inflate(inflater, container, false)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
|
|||||||
|
|
||||||
class FavouritesListFragment : MangaListFragment() {
|
class FavouritesListFragment : MangaListFragment() {
|
||||||
|
|
||||||
override val viewModel by viewModel<FavouritesListViewModel>(mode = LazyThreadSafetyMode.NONE) {
|
override val viewModel by viewModel<FavouritesListViewModel> {
|
||||||
parametersOf(categoryId)
|
parametersOf(categoryId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
|
|||||||
val historyModule
|
val historyModule
|
||||||
get() = module {
|
get() = module {
|
||||||
|
|
||||||
single { HistoryRepository(get(), get()) }
|
single { HistoryRepository(get(), get(), get()) }
|
||||||
viewModel { HistoryListViewModel(get(), get(), get()) }
|
viewModel { HistoryListViewModel(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -18,14 +18,14 @@ import java.util.*
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
data class HistoryEntity(
|
class HistoryEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
|
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
|
||||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||||
@ColumnInfo(name = "page") val page: Int,
|
@ColumnInfo(name = "page") val page: Int,
|
||||||
@ColumnInfo(name = "scroll") val scroll: Float
|
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun toMangaHistory() = MangaHistory(
|
fun toMangaHistory() = MangaHistory(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|||||||
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
data class HistoryWithManga(
|
class HistoryWithManga(
|
||||||
@Embedded val history: HistoryEntity,
|
@Embedded val history: HistoryEntity,
|
||||||
@Relation(
|
@Relation(
|
||||||
parentColumn = "manga_id",
|
parentColumn = "manga_id",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
import org.koitharu.kotatsu.core.model.Manga
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||||
@@ -16,6 +17,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet
|
|||||||
class HistoryRepository(
|
class HistoryRepository(
|
||||||
private val db: MangaDatabase,
|
private val db: MangaDatabase,
|
||||||
private val trackingRepository: TrackingRepository,
|
private val trackingRepository: TrackingRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
||||||
@@ -45,6 +47,9 @@ class HistoryRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
|
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
|
||||||
|
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
|
||||||
|
return
|
||||||
|
}
|
||||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.tagsDao.upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
|
|||||||
|
|
||||||
class HistoryListFragment : MangaListFragment() {
|
class HistoryListFragment : MangaListFragment() {
|
||||||
|
|
||||||
override val viewModel by viewModel<HistoryListViewModel>(mode = LazyThreadSafetyMode.NONE)
|
override val viewModel by viewModel<HistoryListViewModel>()
|
||||||
override val isSwipeRefreshEnabled = false
|
override val isSwipeRefreshEnabled = false
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package org.koitharu.kotatsu.image.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.request.CachePolicy
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import coil.target.PoolableViewTarget
|
||||||
|
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||||
|
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivityImageBinding
|
||||||
|
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||||
|
import org.koitharu.kotatsu.utils.ext.indicator
|
||||||
|
|
||||||
|
class ImageActivity : BaseActivity<ActivityImageBinding>() {
|
||||||
|
|
||||||
|
private val coil: ImageLoader by inject()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivityImageBinding.inflate(layoutInflater))
|
||||||
|
supportActionBar?.run {
|
||||||
|
setDisplayHomeAsUpEnabled(true)
|
||||||
|
setDisplayShowTitleEnabled(false)
|
||||||
|
}
|
||||||
|
loadImage(intent.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
binding.toolbar.updatePadding(
|
||||||
|
left = insets.left,
|
||||||
|
right = insets.right,
|
||||||
|
top = insets.top,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadImage(url: Uri?) {
|
||||||
|
ImageRequest.Builder(this)
|
||||||
|
.data(url)
|
||||||
|
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||||
|
.lifecycle(this)
|
||||||
|
.target(SsivTarget(binding.ssiv))
|
||||||
|
.indicator(binding.progressBar)
|
||||||
|
.enqueueWith(coil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SsivTarget(
|
||||||
|
override val view: SubsamplingScaleImageView,
|
||||||
|
) : PoolableViewTarget<SubsamplingScaleImageView> {
|
||||||
|
|
||||||
|
override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
|
||||||
|
|
||||||
|
override fun onError(error: Drawable?) = setDrawable(error)
|
||||||
|
|
||||||
|
override fun onSuccess(result: Drawable) = setDrawable(result)
|
||||||
|
|
||||||
|
override fun onClear() = setDrawable(null)
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
return (this === other) || (other is SsivTarget && view == other.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode() = view.hashCode()
|
||||||
|
|
||||||
|
override fun toString() = "SsivTarget(view=$view)"
|
||||||
|
|
||||||
|
private fun setDrawable(drawable: Drawable?) {
|
||||||
|
if (drawable != null) {
|
||||||
|
view.setImage(ImageSource.bitmap(drawable.toBitmap()))
|
||||||
|
} else {
|
||||||
|
view.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newIntent(context: Context, url: String): Intent {
|
||||||
|
return Intent(context, ImageActivity::class.java)
|
||||||
|
.setData(Uri.parse(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.koitharu.kotatsu.list.domain
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.core.model.SortOrder
|
||||||
|
|
||||||
|
class AvailableFilters(
|
||||||
|
val sortOrders: Set<SortOrder>,
|
||||||
|
val tags: Set<MangaTag>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val size: Int
|
||||||
|
get() = sortOrders.size + tags.size
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
other as AvailableFilters
|
||||||
|
if (sortOrders != other.sortOrders) return false
|
||||||
|
if (tags != other.tags) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = sortOrders.hashCode()
|
||||||
|
result = 31 * result + tags.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEmpty(): Boolean = sortOrders.isEmpty() && tags.isEmpty()
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.list.ui
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.core.model.SortOrder
|
|
||||||
|
|
||||||
data class MangaFilterConfig(
|
|
||||||
val sortOrders: List<SortOrder>,
|
|
||||||
val tags: List<MangaTag>,
|
|
||||||
val currentFilter: MangaFilter?
|
|
||||||
)
|
|
||||||
@@ -22,19 +22,17 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
|
|||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
||||||
import org.koitharu.kotatsu.base.ui.list.decor.ItemTypeDividerDecoration
|
import org.koitharu.kotatsu.base.ui.list.decor.ItemTypeDividerDecoration
|
||||||
import org.koitharu.kotatsu.base.ui.list.decor.SectionItemDecoration
|
|
||||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
|
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
|
||||||
import org.koitharu.kotatsu.core.model.Manga
|
import org.koitharu.kotatsu.core.model.Manga
|
||||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
|
||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
|
||||||
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter
|
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter2
|
||||||
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
|
import org.koitharu.kotatsu.list.ui.filter.FilterItem
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||||
import org.koitharu.kotatsu.main.ui.MainActivity
|
import org.koitharu.kotatsu.main.ui.MainActivity
|
||||||
@@ -42,10 +40,11 @@ import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
|
|||||||
import org.koitharu.kotatsu.utils.ext.*
|
import org.koitharu.kotatsu.utils.ext.*
|
||||||
|
|
||||||
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
||||||
PaginationScrollListener.Callback, OnListItemClickListener<Manga>, OnFilterChangedListener,
|
PaginationScrollListener.Callback, OnListItemClickListener<Manga>,
|
||||||
SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener {
|
SwipeRefreshLayout.OnRefreshListener {
|
||||||
|
|
||||||
private var listAdapter: MangaListAdapter? = null
|
private var listAdapter: MangaListAdapter? = null
|
||||||
|
private var filterAdapter: FilterAdapter2? = null
|
||||||
private var paginationListener: PaginationScrollListener? = null
|
private var paginationListener: PaginationScrollListener? = null
|
||||||
private val spanResolver = MangaListSpanResolver()
|
private val spanResolver = MangaListSpanResolver()
|
||||||
private val spanSizeLookup = SpanSizeLookup()
|
private val spanSizeLookup = SpanSizeLookup()
|
||||||
@@ -78,6 +77,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
|||||||
onRetryClick = ::resolveException,
|
onRetryClick = ::resolveException,
|
||||||
onTagRemoveClick = viewModel::onRemoveFilterTag
|
onTagRemoveClick = viewModel::onRemoveFilterTag
|
||||||
)
|
)
|
||||||
|
filterAdapter = FilterAdapter2(viewModel)
|
||||||
paginationListener = PaginationScrollListener(4, this)
|
paginationListener = PaginationScrollListener(4, this)
|
||||||
with(binding.recyclerView) {
|
with(binding.recyclerView) {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
@@ -94,8 +94,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
|||||||
}
|
}
|
||||||
with(binding.recyclerViewFilter) {
|
with(binding.recyclerViewFilter) {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
addItemDecoration(ItemTypeDividerDecoration(view.context))
|
adapter = filterAdapter
|
||||||
addItemDecoration(SectionItemDecoration(false, this@MangaListFragment))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(parentFragment as? RecycledViewPoolHolder)?.let {
|
(parentFragment as? RecycledViewPoolHolder)?.let {
|
||||||
@@ -113,6 +112,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
|||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
drawer = null
|
drawer = null
|
||||||
listAdapter = null
|
listAdapter = null
|
||||||
|
filterAdapter = null
|
||||||
paginationListener = null
|
paginationListener = null
|
||||||
spanSizeLookup.invalidateCache()
|
spanSizeLookup.invalidateCache()
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
@@ -203,28 +203,21 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun onInitFilter(config: MangaFilterConfig) {
|
protected fun onInitFilter(filter: List<FilterItem>) {
|
||||||
binding.recyclerViewFilter.adapter = FilterAdapter(
|
filterAdapter?.items = filter
|
||||||
sortOrders = config.sortOrders,
|
|
||||||
tags = config.tags,
|
|
||||||
state = config.currentFilter,
|
|
||||||
listener = this
|
|
||||||
)
|
|
||||||
drawer?.setDrawerLockMode(
|
drawer?.setDrawerLockMode(
|
||||||
if (config.sortOrders.isEmpty() && config.tags.isEmpty()) {
|
if (filter.isEmpty()) {
|
||||||
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
|
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
|
||||||
} else {
|
} else {
|
||||||
DrawerLayout.LOCK_MODE_UNLOCKED
|
DrawerLayout.LOCK_MODE_UNLOCKED
|
||||||
}
|
}
|
||||||
) ?: binding.dividerFilter?.let {
|
) ?: binding.dividerFilter?.let {
|
||||||
it.isGone = config.sortOrders.isEmpty() && config.tags.isEmpty()
|
it.isGone = filter.isEmpty()
|
||||||
binding.recyclerViewFilter.isVisible = it.isVisible
|
binding.recyclerViewFilter.isVisible = it.isVisible
|
||||||
}
|
}
|
||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFilterChanged(filter: MangaFilter) = Unit
|
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
|
||||||
binding.recyclerViewFilter.updatePadding(
|
binding.recyclerViewFilter.updatePadding(
|
||||||
@@ -284,20 +277,6 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final override fun isSection(position: Int): Boolean {
|
|
||||||
return position == 0 || binding.recyclerViewFilter.adapter?.run {
|
|
||||||
getItemViewType(position) != getItemViewType(position - 1)
|
|
||||||
} ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
final override fun getSectionTitle(position: Int): CharSequence? {
|
|
||||||
return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) {
|
|
||||||
FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order)
|
|
||||||
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genres)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
|
protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit
|
||||||
|
|
||||||
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false
|
protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
package org.koitharu.kotatsu.list.ui
|
package org.koitharu.kotatsu.list.ui
|
||||||
|
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaFilter
|
||||||
import org.koitharu.kotatsu.core.model.MangaTag
|
import org.koitharu.kotatsu.core.model.MangaTag
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||||
|
import org.koitharu.kotatsu.list.domain.AvailableFilters
|
||||||
|
import org.koitharu.kotatsu.list.ui.filter.FilterItem
|
||||||
|
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
|
|
||||||
abstract class MangaListViewModel(
|
abstract class MangaListViewModel(
|
||||||
private val settings: AppSettings
|
private val settings: AppSettings,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel(), OnFilterChangedListener {
|
||||||
|
|
||||||
abstract val content: LiveData<List<ListModel>>
|
abstract val content: LiveData<List<ListModel>>
|
||||||
val filter = MutableLiveData<MangaFilterConfig>()
|
val filter = MutableLiveData<List<FilterItem>>()
|
||||||
val listMode = MutableLiveData<ListMode>()
|
val listMode = MutableLiveData<ListMode>()
|
||||||
val gridScale = settings.observe()
|
val gridScale = settings.observe()
|
||||||
.filter { it == AppSettings.KEY_GRID_SIZE }
|
.filter { it == AppSettings.KEY_GRID_SIZE }
|
||||||
@@ -37,7 +46,62 @@ abstract class MangaListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun onRemoveFilterTag(tag: MangaTag) = Unit
|
protected var currentFilter: MangaFilter = MangaFilter(null, emptySet())
|
||||||
|
private set(value) {
|
||||||
|
field = value
|
||||||
|
onFilterChanged()
|
||||||
|
}
|
||||||
|
protected var availableFilters: AvailableFilters? = null
|
||||||
|
private var filterJob: Job? = null
|
||||||
|
|
||||||
|
final override fun onSortItemClick(item: FilterItem.Sort) {
|
||||||
|
currentFilter = currentFilter.copy(sortOrder = item.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun onTagItemClick(item: FilterItem.Tag) {
|
||||||
|
val tags = if (item.isChecked) {
|
||||||
|
currentFilter.tags - item.tag
|
||||||
|
} else {
|
||||||
|
currentFilter.tags + item.tag
|
||||||
|
}
|
||||||
|
currentFilter = currentFilter.copy(tags = tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRemoveFilterTag(tag: MangaTag) {
|
||||||
|
val tags = currentFilter.tags
|
||||||
|
if (tag !in tags) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentFilter = currentFilter.copy(tags = tags - tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
open fun onFilterChanged() {
|
||||||
|
val previousJob = filterJob
|
||||||
|
filterJob = launchJob(Dispatchers.Default) {
|
||||||
|
previousJob?.cancelAndJoin()
|
||||||
|
filter.postValue(
|
||||||
|
availableFilters?.run {
|
||||||
|
val list = ArrayList<FilterItem>(size + 2)
|
||||||
|
if (sortOrders.isNotEmpty()) {
|
||||||
|
val selectedSort = currentFilter.sortOrder ?: sortOrders.first()
|
||||||
|
list += FilterItem.Header(R.string.sort_order)
|
||||||
|
sortOrders.sortedBy { it.ordinal }.mapTo(list) {
|
||||||
|
FilterItem.Sort(it, isSelected = it == selectedSort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tags.isNotEmpty()) {
|
||||||
|
list += FilterItem.Header(R.string.genres)
|
||||||
|
tags.sortedBy { it.title }.mapTo(list) {
|
||||||
|
FilterItem.Tag(it, isChecked = it in currentFilter.tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureActive()
|
||||||
|
list
|
||||||
|
}.orEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract fun onRefresh()
|
abstract fun onRefresh()
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ class MangaListAdapter(
|
|||||||
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
|
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setItems(list: List<ListModel>, commitCallback: Runnable) {
|
|
||||||
differ.submitList(list, commitCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
||||||
|
|
||||||
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when {
|
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when {
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.filter
|
|
||||||
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.core.model.SortOrder
|
|
||||||
|
|
||||||
class FilterAdapter(
|
|
||||||
private val sortOrders: List<SortOrder> = emptyList(),
|
|
||||||
private val tags: List<MangaTag> = emptyList(),
|
|
||||||
state: MangaFilter?,
|
|
||||||
private val listener: OnFilterChangedListener
|
|
||||||
) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() {
|
|
||||||
|
|
||||||
private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet())
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
|
|
||||||
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
setCheckedSort(requireData())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
setCheckedTag(boundData ?: return@setOnClickListener, !isChecked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> throw IllegalArgumentException("Unknown viewType $viewType")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() = sortOrders.size + tags.size
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: BaseViewHolder<*, Boolean, *>, position: Int) {
|
|
||||||
when (holder) {
|
|
||||||
is FilterSortHolder -> {
|
|
||||||
val item = sortOrders[position]
|
|
||||||
holder.bind(item, item == currentState.sortOrder)
|
|
||||||
}
|
|
||||||
is FilterTagHolder -> {
|
|
||||||
val item = tags[position - sortOrders.size]
|
|
||||||
holder.bind(item, item in currentState.tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) = when (position) {
|
|
||||||
in sortOrders.indices -> VIEW_TYPE_SORT
|
|
||||||
else -> VIEW_TYPE_TAG
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCheckedTag(tag: MangaTag, isChecked: Boolean) {
|
|
||||||
currentState = if (tag in currentState.tags) {
|
|
||||||
if (!isChecked) {
|
|
||||||
currentState.copy(tags = currentState.tags - tag)
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isChecked) {
|
|
||||||
currentState.copy(tags = currentState.tags + tag)
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val index = tags.indexOf(tag)
|
|
||||||
if (index in tags.indices) {
|
|
||||||
notifyItemChanged(sortOrders.size + index)
|
|
||||||
}
|
|
||||||
listener.onFilterChanged(currentState)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCheckedSort(sort: SortOrder) {
|
|
||||||
if (sort != currentState.sortOrder) {
|
|
||||||
val oldItemPos = sortOrders.indexOf(currentState.sortOrder)
|
|
||||||
val newItemPos = sortOrders.indexOf(sort)
|
|
||||||
currentState = currentState.copy(sortOrder = sort)
|
|
||||||
if (oldItemPos in sortOrders.indices) {
|
|
||||||
notifyItemChanged(oldItemPos)
|
|
||||||
}
|
|
||||||
if (newItemPos in sortOrders.indices) {
|
|
||||||
notifyItemChanged(newItemPos)
|
|
||||||
}
|
|
||||||
listener.onFilterChanged(currentState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val VIEW_TYPE_SORT = 0
|
|
||||||
const val VIEW_TYPE_TAG = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.filter
|
||||||
|
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||||
|
|
||||||
|
class FilterAdapter2(
|
||||||
|
listener: OnFilterChangedListener,
|
||||||
|
) : AsyncListDifferDelegationAdapter<FilterItem>(
|
||||||
|
FilterDiffCallback(),
|
||||||
|
filterSortDelegate(listener),
|
||||||
|
filterTagDelegate(listener),
|
||||||
|
filterHeaderDelegate(),
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.filter
|
||||||
|
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
|
||||||
|
|
||||||
|
fun filterSortDelegate(
|
||||||
|
listener: OnFilterChangedListener,
|
||||||
|
) = adapterDelegateViewBinding<FilterItem.Sort, FilterItem, ItemCheckableSingleBinding>(
|
||||||
|
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) }
|
||||||
|
) {
|
||||||
|
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
listener.onSortItemClick(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind {
|
||||||
|
binding.root.setText(item.order.titleRes)
|
||||||
|
binding.root.isChecked = item.isSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filterTagDelegate(
|
||||||
|
listener: OnFilterChangedListener,
|
||||||
|
) = adapterDelegateViewBinding<FilterItem.Tag, FilterItem, ItemCheckableMultipleBinding>(
|
||||||
|
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) }
|
||||||
|
) {
|
||||||
|
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
listener.onTagItemClick(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind {
|
||||||
|
binding.root.text = item.tag.title
|
||||||
|
binding.root.isChecked = item.isChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filterHeaderDelegate() = adapterDelegateViewBinding<FilterItem.Header, FilterItem, ItemFilterHeaderBinding>(
|
||||||
|
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
|
||||||
|
) {
|
||||||
|
|
||||||
|
bind {
|
||||||
|
binding.root.setText(item.titleResId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.filter
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
|
||||||
|
class FilterDiffCallback : DiffUtil.ItemCallback<FilterItem>() {
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
|
||||||
|
return when {
|
||||||
|
oldItem.javaClass != newItem.javaClass -> false
|
||||||
|
oldItem is FilterItem.Header && newItem is FilterItem.Header -> {
|
||||||
|
oldItem.titleResId == newItem.titleResId
|
||||||
|
}
|
||||||
|
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||||
|
oldItem.tag == newItem.tag
|
||||||
|
}
|
||||||
|
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||||
|
oldItem.order == newItem.order
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean {
|
||||||
|
return when {
|
||||||
|
oldItem is FilterItem.Header && newItem is FilterItem.Header -> true
|
||||||
|
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||||
|
oldItem.isChecked == newItem.isChecked
|
||||||
|
}
|
||||||
|
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||||
|
oldItem.isSelected == newItem.isSelected
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? {
|
||||||
|
val isCheckedChanged = when {
|
||||||
|
oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> {
|
||||||
|
oldItem.isChecked != newItem.isChecked
|
||||||
|
}
|
||||||
|
oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> {
|
||||||
|
oldItem.isSelected != newItem.isSelected
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
return if (isCheckedChanged) Unit else super.getChangePayload(oldItem, newItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.koitharu.kotatsu.list.ui.filter
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.core.model.SortOrder
|
||||||
|
|
||||||
|
sealed interface FilterItem {
|
||||||
|
|
||||||
|
class Header(
|
||||||
|
@StringRes val titleResId: Int,
|
||||||
|
) : FilterItem
|
||||||
|
|
||||||
|
class Sort(
|
||||||
|
val order: SortOrder,
|
||||||
|
val isSelected: Boolean,
|
||||||
|
) : FilterItem
|
||||||
|
|
||||||
|
class Tag(
|
||||||
|
val tag: MangaTag,
|
||||||
|
val isChecked: Boolean,
|
||||||
|
) : FilterItem
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.filter
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
|
||||||
import org.koitharu.kotatsu.core.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
|
|
||||||
|
|
||||||
class FilterSortHolder(parent: ViewGroup) :
|
|
||||||
BaseViewHolder<SortOrder, Boolean, ItemCheckableSingleBinding>(
|
|
||||||
ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
|
||||||
) {
|
|
||||||
|
|
||||||
override fun onBind(data: SortOrder, extra: Boolean) {
|
|
||||||
binding.root.setText(data.titleRes)
|
|
||||||
binding.root.isChecked = extra
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.filter
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
|
||||||
|
|
||||||
class FilterTagHolder(parent: ViewGroup) :
|
|
||||||
BaseViewHolder<MangaTag, Boolean, ItemCheckableMultipleBinding>(
|
|
||||||
ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
|
||||||
) {
|
|
||||||
|
|
||||||
val isChecked: Boolean
|
|
||||||
get() = binding.root.isChecked
|
|
||||||
|
|
||||||
override fun onBind(data: MangaTag, extra: Boolean) {
|
|
||||||
binding.root.text = data.title
|
|
||||||
binding.root.isChecked = extra
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.list.ui.filter
|
package org.koitharu.kotatsu.list.ui.filter
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.MangaFilter
|
interface OnFilterChangedListener {
|
||||||
|
|
||||||
fun interface OnFilterChangedListener {
|
fun onSortItemClick(item: FilterItem.Sort)
|
||||||
|
|
||||||
fun onFilterChanged(filter: MangaFilter)
|
fun onTagItemClick(item: FilterItem.Tag)
|
||||||
}
|
}
|
||||||
@@ -9,23 +9,24 @@ import coil.fetch.FetchResult
|
|||||||
import coil.fetch.Fetcher
|
import coil.fetch.Fetcher
|
||||||
import coil.fetch.SourceResult
|
import coil.fetch.SourceResult
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
class CbzFetcher : Fetcher<Uri> {
|
class CbzFetcher : Fetcher<Uri> {
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
override suspend fun fetch(
|
override suspend fun fetch(
|
||||||
pool: BitmapPool,
|
pool: BitmapPool,
|
||||||
data: Uri,
|
data: Uri,
|
||||||
size: Size,
|
size: Size,
|
||||||
options: Options,
|
options: Options,
|
||||||
): FetchResult {
|
): FetchResult = runInterruptible(Dispatchers.IO) {
|
||||||
val zip = ZipFile(data.schemeSpecificPart)
|
val zip = ZipFile(data.schemeSpecificPart)
|
||||||
val entry = zip.getEntry(data.fragment)
|
val entry = zip.getEntry(data.fragment)
|
||||||
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
|
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
|
||||||
return SourceResult(
|
SourceResult(
|
||||||
source = ExtraCloseableBufferedSource(
|
source = ExtraCloseableBufferedSource(
|
||||||
zip.getInputStream(entry).source().buffer(),
|
zip.getInputStream(entry).source().buffer(),
|
||||||
zip,
|
zip,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class MangaZip(val file: File) {
|
|||||||
return writableCbz.flush()
|
return writableCbz.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addCover(file: File, ext: String) {
|
suspend fun addCover(file: File, ext: String) {
|
||||||
val name = buildString {
|
val name = buildString {
|
||||||
append(FILENAME_PATTERN.format(0, 0))
|
append(FILENAME_PATTERN.format(0, 0))
|
||||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
@@ -39,11 +39,11 @@ class MangaZip(val file: File) {
|
|||||||
append(ext)
|
append(ext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writableCbz[name] = file
|
writableCbz.put(name, file)
|
||||||
index.setCoverEntry(name)
|
index.setCoverEntry(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
||||||
val name = buildString {
|
val name = buildString {
|
||||||
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
|
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
|
||||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
@@ -51,7 +51,7 @@ class MangaZip(val file: File) {
|
|||||||
append(ext)
|
append(ext)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writableCbz[name] = file
|
writableCbz.put(name, file)
|
||||||
index.addChapter(chapter)
|
index.addChapter(chapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.local.data
|
package org.koitharu.kotatsu.local.data
|
||||||
|
|
||||||
import androidx.annotation.CheckResult
|
import androidx.annotation.CheckResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.withContext
|
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -14,7 +14,6 @@ class WritableCbzFile(private val file: File) {
|
|||||||
|
|
||||||
private val dir = File(file.parentFile, file.nameWithoutExtension)
|
private val dir = File(file.parentFile, file.nameWithoutExtension)
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
suspend fun prepare() = withContext(Dispatchers.IO) {
|
suspend fun prepare() = withContext(Dispatchers.IO) {
|
||||||
check(dir.list().isNullOrEmpty()) {
|
check(dir.list().isNullOrEmpty()) {
|
||||||
"Dir ${dir.name} is not empty"
|
"Dir ${dir.name} is not empty"
|
||||||
@@ -27,11 +26,13 @@ class WritableCbzFile(private val file: File) {
|
|||||||
}
|
}
|
||||||
ZipInputStream(FileInputStream(file)).use { zip ->
|
ZipInputStream(FileInputStream(file)).use { zip ->
|
||||||
var entry = zip.nextEntry
|
var entry = zip.nextEntry
|
||||||
while (entry != null) {
|
while (entry != null && currentCoroutineContext().isActive) {
|
||||||
val target = File(dir.path + File.separator + entry.name)
|
val target = File(dir.path + File.separator + entry.name)
|
||||||
target.parentFile?.mkdirs()
|
runInterruptible {
|
||||||
target.outputStream().use { out ->
|
target.parentFile?.mkdirs()
|
||||||
zip.copyTo(out)
|
target.outputStream().use { out ->
|
||||||
|
zip.copyTo(out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
zip.closeEntry()
|
zip.closeEntry()
|
||||||
entry = zip.nextEntry
|
entry = zip.nextEntry
|
||||||
@@ -44,52 +45,50 @@ class WritableCbzFile(private val file: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@CheckResult
|
@CheckResult
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
suspend fun flush() = withContext(Dispatchers.IO) {
|
suspend fun flush() = withContext(Dispatchers.IO) {
|
||||||
val tempFile = File(file.path + ".tmp")
|
val tempFile = File(file.path + ".tmp")
|
||||||
if (tempFile.exists()) {
|
if (tempFile.exists()) {
|
||||||
tempFile.delete()
|
tempFile.deleteAwait()
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
|
runInterruptible {
|
||||||
dir.listFiles()?.forEach {
|
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
|
||||||
zipFile(it, it.name, zip)
|
dir.listFiles()?.forEach {
|
||||||
|
zipFile(it, it.name, zip)
|
||||||
|
}
|
||||||
|
zip.flush()
|
||||||
}
|
}
|
||||||
zip.flush()
|
|
||||||
}
|
}
|
||||||
tempFile.renameTo(file)
|
tempFile.renameTo(file)
|
||||||
} finally {
|
} finally {
|
||||||
if (tempFile.exists()) {
|
if (tempFile.exists()) {
|
||||||
tempFile.delete()
|
tempFile.deleteAwait()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
operator fun get(name: String) = File(dir, name)
|
operator fun get(name: String) = File(dir, name)
|
||||||
|
|
||||||
operator fun set(name: String, file: File) {
|
suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) {
|
||||||
file.copyTo(this[name], overwrite = true)
|
file.copyTo(this[name], overwrite = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
|
||||||
|
if (fileToZip.isDirectory) {
|
||||||
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
|
if (fileName.endsWith("/")) {
|
||||||
if (fileToZip.isDirectory) {
|
zipOut.putNextEntry(ZipEntry(fileName))
|
||||||
if (fileName.endsWith("/")) {
|
|
||||||
zipOut.putNextEntry(ZipEntry(fileName))
|
|
||||||
} else {
|
|
||||||
zipOut.putNextEntry(ZipEntry("$fileName/"))
|
|
||||||
}
|
|
||||||
zipOut.closeEntry()
|
|
||||||
fileToZip.listFiles()?.forEach { childFile ->
|
|
||||||
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
FileInputStream(fileToZip).use { fis ->
|
zipOut.putNextEntry(ZipEntry("$fileName/"))
|
||||||
val zipEntry = ZipEntry(fileName)
|
}
|
||||||
zipOut.putNextEntry(zipEntry)
|
zipOut.closeEntry()
|
||||||
fis.copyTo(zipOut)
|
fileToZip.listFiles()?.forEach { childFile ->
|
||||||
}
|
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FileInputStream(fileToZip).use { fis ->
|
||||||
|
val zipEntry = ZipEntry(fileName)
|
||||||
|
zipOut.putNextEntry(zipEntry)
|
||||||
|
fis.copyTo(zipOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.collection.ArraySet
|
|||||||
import androidx.core.net.toFile
|
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.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.core.model.*
|
import org.koitharu.kotatsu.core.model.*
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
@@ -42,37 +43,39 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
|||||||
getFromFile(Uri.parse(manga.url).toFile())
|
getFromFile(Uri.parse(manga.url).toFile())
|
||||||
} else manga
|
} else manga
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
val uri = Uri.parse(chapter.url)
|
return runInterruptible(Dispatchers.IO){
|
||||||
val file = uri.toFile()
|
val uri = Uri.parse(chapter.url)
|
||||||
val zip = ZipFile(file)
|
val file = uri.toFile()
|
||||||
val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
|
val zip = ZipFile(file)
|
||||||
var entries = zip.entries().asSequence()
|
val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
|
||||||
entries = if (index != null) {
|
var entries = zip.entries().asSequence()
|
||||||
val pattern = index.getChapterNamesPattern(chapter)
|
entries = if (index != null) {
|
||||||
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
|
val pattern = index.getChapterNamesPattern(chapter)
|
||||||
} else {
|
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
|
||||||
val parent = uri.fragment.orEmpty()
|
} else {
|
||||||
entries.filter { x ->
|
val parent = uri.fragment.orEmpty()
|
||||||
!x.isDirectory && x.name.substringBeforeLast(
|
entries.filter { x ->
|
||||||
File.separatorChar,
|
!x.isDirectory && x.name.substringBeforeLast(
|
||||||
""
|
File.separatorChar,
|
||||||
) == parent
|
""
|
||||||
|
) == parent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
entries
|
||||||
|
.toList()
|
||||||
|
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
|
||||||
|
.map { x ->
|
||||||
|
val entryUri = zipUri(file, x.name)
|
||||||
|
MangaPage(
|
||||||
|
id = entryUri.longHashCode(),
|
||||||
|
url = entryUri,
|
||||||
|
preview = null,
|
||||||
|
referer = chapter.url,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return entries
|
|
||||||
.toList()
|
|
||||||
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
|
|
||||||
.map { x ->
|
|
||||||
val entryUri = zipUri(file, x.name)
|
|
||||||
MangaPage(
|
|
||||||
id = entryUri.longHashCode(),
|
|
||||||
url = entryUri,
|
|
||||||
referer = chapter.url,
|
|
||||||
source = MangaSource.LOCAL
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun delete(manga: Manga): Boolean {
|
suspend fun delete(manga: Manga): Boolean {
|
||||||
@@ -124,7 +127,9 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
|||||||
number = i + 1,
|
number = i + 1,
|
||||||
source = MangaSource.LOCAL,
|
source = MangaSource.LOCAL,
|
||||||
uploadDate = 0L,
|
uploadDate = 0L,
|
||||||
url = uriBuilder.fragment(s).build().toString()
|
url = uriBuilder.fragment(s).build().toString(),
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -134,20 +139,18 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
|||||||
val file = runCatching {
|
val file = runCatching {
|
||||||
Uri.parse(localManga.url).toFile()
|
Uri.parse(localManga.url).toFile()
|
||||||
}.getOrNull() ?: return null
|
}.getOrNull() ?: return null
|
||||||
return withContext(Dispatchers.IO) {
|
return runInterruptible(Dispatchers.IO) {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
ZipFile(file).use { zip ->
|
ZipFile(file).use { zip ->
|
||||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||||
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null
|
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
index.getMangaInfo()
|
index?.getMangaInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) {
|
suspend fun findSavedManga(remoteManga: Manga): Manga? = runInterruptible(Dispatchers.IO) {
|
||||||
val files = getAllFiles()
|
val files = getAllFiles()
|
||||||
for (file in files) {
|
for (file in files) {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
val index = ZipFile(file).use { zip ->
|
val index = ZipFile(file).use { zip ->
|
||||||
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
|
||||||
entry?.let(zip::readText)?.let(::MangaIndex)
|
entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
@@ -155,7 +158,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
|
|||||||
val info = index.getMangaInfo() ?: continue
|
val info = index.getMangaInfo() ?: continue
|
||||||
if (info.id == remoteManga.id) {
|
if (info.id == remoteManga.id) {
|
||||||
val fileUri = file.toUri().toString()
|
val fileUri = file.toUri().toString()
|
||||||
return@withContext info.copy(
|
return@runInterruptible info.copy(
|
||||||
source = MangaSource.LOCAL,
|
source = MangaSource.LOCAL,
|
||||||
url = fileUri,
|
url = fileUri,
|
||||||
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
|
chapters = info.chapters?.map { c -> c.copy(url = fileUri) }
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import org.koitharu.kotatsu.download.ui.service.DownloadService
|
|||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.utils.ext.ellipsize
|
import org.koitharu.kotatsu.utils.ext.ellipsize
|
||||||
|
|
||||||
class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
|
class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri?> {
|
||||||
|
|
||||||
override val viewModel by viewModel<LocalListViewModel>(mode = LazyThreadSafetyMode.NONE)
|
override val viewModel by viewModel<LocalListViewModel>()
|
||||||
private val importCall = registerForActivityResult(
|
private val importCall = registerForActivityResult(
|
||||||
ActivityResultContracts.OpenDocument(),
|
ActivityResultContracts.OpenDocument(),
|
||||||
this
|
this
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||||
@@ -16,9 +17,9 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
|
|||||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.*
|
import org.koitharu.kotatsu.list.ui.model.*
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.utils.MediaStoreCompat
|
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
|
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -74,17 +75,18 @@ class LocalListViewModel(
|
|||||||
launchLoadingJob {
|
launchLoadingJob {
|
||||||
val contentResolver = context.contentResolver
|
val contentResolver = context.contentResolver
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val name = MediaStoreCompat(contentResolver).getName(uri)
|
val name = contentResolver.resolveName(uri)
|
||||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||||
if (!LocalMangaRepository.isFileSupported(name)) {
|
if (!LocalMangaRepository.isFileSupported(name)) {
|
||||||
throw UnsupportedFileException("Unsupported file on $uri")
|
throw UnsupportedFileException("Unsupported file on $uri")
|
||||||
}
|
}
|
||||||
val dest = settings.getStorageDir(context)?.let { File(it, name) }
|
val dest = settings.getStorageDir(context)?.let { File(it, name) }
|
||||||
?: throw IOException("External files dir unavailable")
|
?: throw IOException("External files dir unavailable")
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
runInterruptible {
|
||||||
contentResolver.openInputStream(uri)?.use { source ->
|
contentResolver.openInputStream(uri)?.use { source ->
|
||||||
dest.outputStream().use { output ->
|
dest.outputStream().use { output ->
|
||||||
source.copyTo(output)
|
source.copyTo(output)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} ?: throw IOException("Cannot open input stream: $uri")
|
} ?: throw IOException("Cannot open input stream: $uri")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,10 +57,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
|
|||||||
NavigationView.OnNavigationItemSelectedListener, AppBarOwner,
|
NavigationView.OnNavigationItemSelectedListener, AppBarOwner,
|
||||||
View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener {
|
View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
|
private val viewModel by viewModel<MainViewModel>()
|
||||||
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
|
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>()
|
||||||
mode = LazyThreadSafetyMode.NONE
|
|
||||||
)
|
|
||||||
|
|
||||||
private lateinit var navHeaderBinding: NavigationHeaderBinding
|
private lateinit var navHeaderBinding: NavigationHeaderBinding
|
||||||
private lateinit var drawerToggle: ActionBarDrawerToggle
|
private lateinit var drawerToggle: ActionBarDrawerToggle
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
|||||||
class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEditorActionListener,
|
class ProtectActivity : BaseActivity<ActivityProtectBinding>(), TextView.OnEditorActionListener,
|
||||||
TextWatcher, View.OnClickListener {
|
TextWatcher, View.OnClickListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<ProtectViewModel>(mode = LazyThreadSafetyMode.NONE)
|
private val viewModel by viewModel<ProtectViewModel>()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ val readerModule
|
|||||||
single { PagesCache(get()) }
|
single { PagesCache(get()) }
|
||||||
|
|
||||||
viewModel { params ->
|
viewModel { params ->
|
||||||
ReaderViewModel(params[0], params[1], get(), get(), get(), get())
|
ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user