Compare commits

...

44 Commits

Author SHA1 Message Date
Koitharu
197393fbd1 Fix webtoon scroll 2022-01-15 17:21:03 +02:00
J. Lavoie
51ef6e3c78 Translated using Weblate (French)
Currently translated at 100.0% (249 of 249 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2022-01-15 08:37:11 +02:00
J. Lavoie
663277fe6f Translated using Weblate (Italian)
Currently translated at 100.0% (249 of 249 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2022-01-15 08:37:11 +02:00
J. Lavoie
332a38d674 Translated using Weblate (German)
Currently translated at 100.0% (249 of 249 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2022-01-15 08:37:11 +02:00
Zakhar Timoshenko
e9410a2f54 [MangaOwl] Fix missing pages 2022-01-15 08:20:17 +02:00
Koitharu
b5fa2bd660 Fix MangaDex pages extraction 2022-01-14 08:50:04 +02:00
Koitharu
e56c61d834 Update manga parsers 2022-01-10 18:36:52 +02:00
Koitharu
677f71dd84 Increase version 2022-01-10 08:53:54 +02:00
Allan Nordhøy
3f90f88600 Translated using Weblate (Norwegian Bokmål)
Currently translated at 87.9% (219 of 249 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
2022-01-10 08:03:16 +02:00
Koitharu
229a7c70d9 Implement new manga sources settings list screen #78 2022-01-07 19:04:59 +02:00
Koitharu
a2dbec98f9 Fix Ninemanga filter 2022-01-06 17:46:46 +02:00
Koitharu
3a02f8090e Fix pages numbers 2022-01-06 17:31:49 +02:00
Koitharu
17519db44e Refactor sources settings list 2022-01-06 17:26:16 +02:00
Koitharu
99186bf269 Show pages numbers in reader 2022-01-06 09:58:20 +02:00
Koitharu
9a65e40be1 Update page flip animation 2022-01-05 15:46:22 +02:00
Koitharu
f0add59f99 Fix smooth cover loading in details 2022-01-05 15:04:14 +02:00
Koitharu
f18c182a6a Refactor filters 2022-01-05 14:49:18 +02:00
Koitharu
68e9588f24 Fix AniBel endpoint 2022-01-05 11:49:56 +02:00
Koitharu
eea427216d Fullscreen cover view activity 2022-01-05 11:42:03 +02:00
Koitharu
8e9b89f6f0 Fix GroupLe sources covers 2022-01-05 10:51:36 +02:00
Koitharu
4f3281be99 MangaDex source 2022-01-05 10:43:32 +02:00
badlop
eb56a82702 Translated using Weblate (Spanish)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2022-01-04 12:37:53 +02:00
nzgha
089ccc9d15 Translated using Weblate (Spanish)
Currently translated at 99.1% (244 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2022-01-04 12:37:53 +02:00
J. Lavoie
12c1365513 Translated using Weblate (Finnish)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
2022-01-04 12:37:53 +02:00
J. Lavoie
7ecf9316e3 Translated using Weblate (French)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2022-01-04 12:37:53 +02:00
J. Lavoie
12e98ec36a Translated using Weblate (Italian)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2022-01-04 12:37:53 +02:00
J. Lavoie
22977fc7bc Translated using Weblate (German)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2022-01-04 12:37:53 +02:00
Aliaksiej Razumaŭ
b387a49a4e Translated using Weblate (Belarusian)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2022-01-04 12:37:53 +02:00
Allan Nordhøy
dbbb0d0f64 Translated using Weblate (Norwegian Bokmål)
Currently translated at 88.2% (217 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
2022-01-04 12:37:53 +02:00
Allan Nordhøy
0bbf2b752f Translated using Weblate (English)
Currently translated at 100.0% (246 of 246 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
2022-01-04 12:37:53 +02:00
Aliaksiej Razumaŭ
14c1eacffa Translated using Weblate (Belarusian)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
2022-01-04 12:37:53 +02:00
J. Lavoie
c2a0525bb8 Translated using Weblate (French)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
2022-01-04 12:37:53 +02:00
J. Lavoie
4f502e580c Translated using Weblate (Italian)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
2022-01-04 12:37:53 +02:00
J. Lavoie
7cb303966a Translated using Weblate (German)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
2022-01-04 12:37:53 +02:00
Allan Nordhøy
3f0431f88b Translated using Weblate (Norwegian Bokmål)
Currently translated at 87.3% (214 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
2022-01-04 12:37:53 +02:00
Allan Nordhøy
aad5601df1 Translated using Weblate (English)
Currently translated at 100.0% (245 of 245 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
2022-01-04 12:37:53 +02:00
Koitharu
8f2cf8141a Mark HenChan manga as nsfw 2021-12-10 18:10:12 +02:00
Koitharu
eefd1129f7 Update dependencies, setup FragmentStrictMode 2021-12-04 12:09:13 +02:00
Koitharu
5ed0f8b5a6 Check if category name is not empty 2021-12-03 21:11:50 +02:00
Koitharu
9b4aa4fd64 Migrate AniBel parser to graphql 2021-12-03 20:30:20 +02:00
Koitharu
bbb226791b Merge branch 'hotifx/2.0.1' into devel 2021-11-22 08:48:11 +02:00
Zakhar Timoshenko
66ed19ed5a [Source] [MangaOwl] Fix not loading chapter list 2021-11-21 17:12:04 +02:00
Koitharu
527a3cbd09 Option to exclude NSFW content from history 2021-11-20 16:49:30 +02:00
Koitharu
f22963b315 Use DownloadManager for pages saving 2021-11-18 20:06:44 +02:00
107 changed files with 1984 additions and 946 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 31 targetSdkVersion 31
versionCode 373 versionCode 378
versionName '2.0.1' versionName '2.1.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -59,35 +59,36 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [ freeCompilerArgs += [
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-Xopt-in=kotlin.contracts.ExperimentalContracts',
] ]
} }
} }
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.7.0' implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.activity:activity-ktx:1.4.0' implementation 'androidx.activity:activity-ktx:1.4.0'
implementation 'androidx.fragment:fragment-ktx:1.3.6' implementation 'androidx.fragment:fragment-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-service:2.4.0' implementation 'androidx.lifecycle:lifecycle-service:2.4.0'
implementation 'androidx.lifecycle:lifecycle-process:2.4.0' implementation 'androidx.lifecycle:lifecycle-process:2.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
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.4.0' kapt 'androidx.lifecycle:lifecycle-compiler:2.4.0'
implementation 'androidx.room:room-runtime:2.3.0' implementation 'androidx.room:room-runtime:2.4.0'
implementation 'androidx.room:room-ktx:2.3.0' implementation 'androidx.room:room-ktx:2.4.0'
kapt 'androidx.room:room-compiler:2.3.0' kapt 'androidx.room:room-compiler:2.4.0'
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'
@@ -96,7 +97,7 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1'
implementation 'io.insert-koin:koin-android:3.1.3' 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.3'
@@ -105,14 +106,14 @@ dependencies {
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.3' 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.0'
androidTestImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3'
} }

View File

@@ -99,6 +99,7 @@
<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=".image.ui.ImageActivity"/>
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"

View File

@@ -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()
} }
} }

View File

@@ -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)
} }

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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?
}
}

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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"
} }

View File

@@ -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()) }
} }

View File

@@ -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()) }
} }

View File

@@ -1,16 +1,21 @@
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) {
override val source = MangaSource.ANIBEL override val source = MangaSource.ANIBEL
override val defaultDomain = "old.anibel.net" override val defaultDomain = "anibel.net"
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST SortOrder.NEWEST
@@ -20,76 +25,119 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
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",
scanlator = null, scanlator = null,
uploadDate = jo.getLong("released"),
branch = null, branch = null,
uploadDate = 0L,
source = source, source = source,
) )
} }
@@ -97,86 +145,115 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
} }
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,
preview = null,
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
}
} }

View File

@@ -93,14 +93,14 @@ 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, scanlator = null,
branch = null, branch = null,

View File

@@ -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

View File

@@ -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
}
} }
} }

View File

@@ -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
}
}

View File

@@ -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 ->
@@ -100,7 +105,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
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, scanlator = null,
branch = null, branch = null,
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()), uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
@@ -120,7 +125,7 @@ class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposit
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null, preview = null,
referer = fullUrl, referer = url,
source = MangaSource.MANGAOWL, source = MangaSource.MANGAOWL,
) )
} }

View File

@@ -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,7 +134,7 @@ 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, scanlator = null,

View File

@@ -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(',')

View File

@@ -125,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)

View File

@@ -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"

View File

@@ -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)
}
} }

View File

@@ -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
}
} }

View File

@@ -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()
} }

View File

@@ -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()) }
} }

View File

@@ -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)

View File

@@ -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))
}
}
}

View File

@@ -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()
}

View File

@@ -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?
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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(),
)

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
} }

View File

@@ -16,9 +16,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,7 +74,7 @@ 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")

View File

@@ -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())
} }
} }

View File

@@ -196,7 +196,7 @@ class ReaderActivity : BaseFullscreenActivity<ActivityReaderBinding>(),
override fun onActivityResult(result: Boolean) { override fun onActivityResult(result: Boolean) {
if (result) { if (result) {
viewModel.saveCurrentState(reader?.getCurrentState()) viewModel.saveCurrentState(reader?.getCurrentState())
viewModel.saveCurrentPage(contentResolver) viewModel.saveCurrentPage()
} }
} }

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.reader.ui
import android.content.ContentResolver import android.content.ContentResolver
import android.net.Uri import android.net.Uri
import android.util.LongSparseArray import android.util.LongSparseArray
import android.webkit.URLUtil
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.* import kotlinx.coroutines.*
@@ -23,10 +22,9 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.utils.MediaStoreCompat import org.koitharu.kotatsu.utils.DownloadManagerHelper
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.IgnoreErrors import org.koitharu.kotatsu.utils.ext.IgnoreErrors
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -38,7 +36,8 @@ class ReaderViewModel(
private val dataRepository: MangaDataRepository, private val dataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val shortcutsRepository: ShortcutsRepository, private val shortcutsRepository: ShortcutsRepository,
private val settings: AppSettings private val settings: AppSettings,
private val downloadManagerHelper: DownloadManagerHelper,
) : BaseViewModel() { ) : BaseViewModel() {
private var loadingJob: Job? = null private var loadingJob: Job? = null
@@ -150,7 +149,7 @@ class ReaderViewModel(
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() } return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
} }
fun saveCurrentPage(resolver: ContentResolver) { fun saveCurrentPage() {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
try { try {
val state = currentState.value ?: error("Undefined state") val state = currentState.value ?: error("Undefined state")
@@ -159,13 +158,8 @@ class ReaderViewModel(
}?.toMangaPage() ?: error("Page not found") }?.toMangaPage() ?: error("Page not found")
val repo = MangaRepository(page.source) val repo = MangaRepository(page.source)
val pageUrl = repo.getPageUrl(page) val pageUrl = repo.getPageUrl(page)
val file = get<PagesCache>()[pageUrl] ?: error("Page not found in cache") val downloadId = downloadManagerHelper.downloadPage(page, pageUrl)
val uri = file.inputStream().use { input -> val uri = downloadManagerHelper.awaitDownload(downloadId)
val fileName = URLUtil.guessFileName(pageUrl, null, null)
MediaStoreCompat(resolver).insertImage(fileName) {
input.copyTo(it)
}
}
onPageSaved.postCall(uri) onPageSaved.postCall(uri)
} catch (e: CancellationException) { } catch (e: CancellationException) {
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2
class ReversedPageAnimTransformer : ViewPager2.PageTransformer { class ReversedPageAnimTransformer : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) { override fun transformPage(page: View, position: Float) = with(page) {
with(page) { translationX = -position * width
val pageWidth = width pivotX = width.toFloat()
when { pivotY = height / 2f
position > 1 -> alpha = 0f cameraDistance = 20000f
position >= 0 -> { when {
alpha = 1f position < -1f || position > 1f -> {
translationX = 0f alpha = 0f
translationZ = 0f rotationY = 0f
scaleX = 1 + FACTOR * position translationZ = -1f
scaleY = 1f }
} position <= 0f -> {
position >= -1 -> { alpha = 1f
alpha = 1f rotationY = 0f
translationX = pageWidth * -position translationZ = 0f
translationZ = -1f }
scaleX = 1f position > 0f -> {
scaleY = 1f alpha = 1f
} rotationY = 120 * position
else -> alpha = 0f translationZ = 2f
} }
} }
} }
private companion object {
const val FACTOR = 0.1f
}
} }

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.graphics.PointF import android.graphics.PointF
import android.view.Gravity
import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
@@ -16,6 +18,11 @@ class ReversedPageHolder(
exceptionResolver: ExceptionResolver exceptionResolver: ExceptionResolver
) : PageHolder(binding, loader, settings, exceptionResolver) { ) : PageHolder(binding, loader, settings, exceptionResolver) {
init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
.gravity = Gravity.START or Gravity.BOTTOM
}
override fun onImageShowing(zoom: ZoomMode) { override fun onImageShowing(zoom: ZoomMode) {
with(binding.ssiv) { with(binding.ssiv) {
maxScale = 2f * maxOf( maxScale = 2f * maxOf(

View File

@@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2
class PageAnimTransformer : ViewPager2.PageTransformer { class PageAnimTransformer : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) { override fun transformPage(page: View, position: Float) = with(page) {
page.apply { translationX = -position * width
val pageWidth = width pivotX = 0f
when { pivotY = height / 2f
position < -1 -> alpha = 0f cameraDistance = 20000f
position <= 0 -> { // [-1,0] when {
alpha = 1f position < -1f || position > 1f -> {
translationX = 0f alpha = 0f
translationZ = 0f rotationY = 0f
scaleX = 1 + FACTOR * position translationZ = -1f
scaleY = 1f }
} position > 0f -> {
position <= 1 -> { // (0,1] alpha = 1f
alpha = 1f rotationY = 0f
translationX = pageWidth * -position translationZ = 0f
translationZ = -1f }
scaleX = 1f position <= 0f -> {
scaleY = 1f alpha = 1f
} rotationY = 120 * position
else -> alpha = 0f translationZ = 2f
} }
} }
} }
private companion object {
const val FACTOR = 0.1f
}
} }

View File

@@ -20,17 +20,20 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
open class PageHolder( open class PageHolder(
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: AppSettings, exceptionResolver: ExceptionResolver settings: AppSettings,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, exceptionResolver), ) : BasePageHolder<ItemPageBinding>(binding, loader, settings, exceptionResolver),
View.OnClickListener { View.OnClickListener {
init { init {
binding.ssiv.setOnImageEventListener(delegate) binding.ssiv.setOnImageEventListener(delegate)
binding.buttonRetry.setOnClickListener(this) binding.buttonRetry.setOnClickListener(this)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
} }
override fun onBind(data: ReaderPage) { override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage()) delegate.onBind(data.toMangaPage())
binding.textViewNumber.text = (data.index + 1).toString()
} }
override fun onRecycled() { override fun onRecycled() {

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.app.Activity
import android.content.Context import android.content.Context
import android.graphics.PointF import android.graphics.PointF
import android.util.AttributeSet import android.util.AttributeSet
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.utils.ext.toIntUp import org.koitharu.kotatsu.utils.ext.toIntUp
class WebtoonImageView @JvmOverloads constructor(context: Context, attr: AttributeSet? = null) : class WebtoonImageView @JvmOverloads constructor(
SubsamplingScaleImageView(context, attr) { context: Context,
attr: AttributeSet? = null,
) : SubsamplingScaleImageView(context, attr) {
private val ct = PointF() private val ct = PointF()
private val displayHeight = resources.displayMetrics.heightPixels private val displayHeight = (context as Activity).window.decorView.height
private var scrollPos = 0 private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN private var scrollRange = SCROLL_UNKNOWN
@@ -55,6 +58,30 @@ class WebtoonImageView @JvmOverloads constructor(context: Context, attr: Attribu
return desiredHeight return desiredHeight
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
val parentWidth = MeasureSpec.getSize(widthMeasureSpec)
val parentHeight = MeasureSpec.getSize(heightMeasureSpec)
val resizeWidth = widthSpecMode != MeasureSpec.EXACTLY
val resizeHeight = heightSpecMode != MeasureSpec.EXACTLY
var width = parentWidth
var height = parentHeight
if (sWidth > 0 && sHeight > 0) {
if (resizeWidth && resizeHeight) {
width = sWidth
height = sHeight
} else if (resizeHeight) {
height = (sHeight.toDouble() / sWidth.toDouble() * width).toInt()
} else if (resizeWidth) {
width = (sWidth.toDouble() / sHeight.toDouble() * height).toInt()
}
}
width = width.coerceAtLeast(suggestedMinimumWidth)
height = height.coerceIn(suggestedMinimumHeight, displayHeight)
setMeasuredDimension(width, height)
}
private fun scrollToInternal(pos: Int) { private fun scrollToInternal(pos: Int) {
scrollPos = pos scrollPos = pos
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)

View File

@@ -34,7 +34,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
consumed[0] = 0 consumed[0] = 0
consumed[1] = consumedY consumed[1] = consumedY
} }
return consumedY != 0 return consumedY != 0 || dy == 0
} }
private fun consumeVerticalScroll(dy: Int): Int { private fun consumeVerticalScroll(dy: Int): Int {

View File

@@ -6,7 +6,6 @@ import android.view.MenuItem
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity
@@ -29,10 +28,6 @@ class RemoteListFragment : MangaListFragment() {
return source.title return source.title
} }
override fun onFilterChanged(filter: MangaFilter) {
viewModel.applyFilter(filter)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater) super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.opt_list_remote, menu) inflater.inflate(R.menu.opt_list_remote, menu)

View File

@@ -9,12 +9,10 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
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.model.MangaTag
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.list.ui.MangaFilterConfig import org.koitharu.kotatsu.list.domain.AvailableFilters
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.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -27,7 +25,6 @@ class RemoteListViewModel(
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val hasNextPage = MutableStateFlow(false) private val hasNextPage = MutableStateFlow(false)
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private var appliedFilter: MangaFilter? = null
private var loadingJob: Job? = null private var loadingJob: Job? = null
private val headerModel = ListHeader((repository as RemoteMangaRepository).title, 0) private val headerModel = ListHeader((repository as RemoteMangaRepository).title, 0)
@@ -68,16 +65,6 @@ class RemoteListViewModel(
loadList(append = !mangaList.value.isNullOrEmpty()) loadList(append = !mangaList.value.isNullOrEmpty())
} }
override fun onRemoveFilterTag(tag: MangaTag) {
val filter = appliedFilter ?: return
if (tag !in filter.tags) {
return
}
applyFilter(
filter.copy(tags = filter.tags - tag)
)
}
fun loadNextPage() { fun loadNextPage() {
if (hasNextPage.value && listError.value == null) { if (hasNextPage.value && listError.value == null) {
loadList(append = true) loadList(append = true)
@@ -93,8 +80,8 @@ class RemoteListViewModel(
listError.value = null listError.value = null
val list = repository.getList2( val list = repository.getList2(
offset = if (append) mangaList.value?.size ?: 0 else 0, offset = if (append) mangaList.value?.size ?: 0 else 0,
sortOrder = appliedFilter?.sortOrder, sortOrder = currentFilter.sortOrder,
tags = appliedFilter?.tags, tags = currentFilter.tags,
) )
if (!append) { if (!append) {
mangaList.value = list mangaList.value = list
@@ -111,26 +98,29 @@ class RemoteListViewModel(
} }
} }
fun applyFilter(newFilter: MangaFilter) { override fun onFilterChanged() {
appliedFilter = newFilter super.onFilterChanged()
mangaList.value = null mangaList.value = null
hasNextPage.value = false hasNextPage.value = false
loadList(false) loadList(false)
filter.value?.run {
filter.value = copy(currentFilter = newFilter)
}
} }
private fun createFilterModel() = appliedFilter?.run { private fun createFilterModel(): CurrentFilterModel? {
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) }) val tags = currentFilter.tags
return if (tags.isEmpty()) {
null
} else {
CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) })
}
} }
private fun loadFilter() { private fun loadFilter() {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
try { try {
val sorts = repository.sortOrders.sortedBy { it.ordinal } val sorts = repository.sortOrders
val tags = repository.getTags().sortedBy { it.title } val tags = repository.getTags()
filter.postValue(MangaFilterConfig(sorts, tags, appliedFilter)) availableFilters = AvailableFilters(sorts, tags)
onFilterChanged()
} catch (e: Exception) { } catch (e: Exception) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()

View File

@@ -1,23 +1,21 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.text.InputType
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.* import androidx.preference.*
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
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.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.names
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
import java.io.File import java.io.File
import java.util.* import java.util.*
@@ -134,53 +132,4 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings),
settings.setStorageDir(context ?: return, file) settings.setStorageDir(context ?: return, file)
} }
private fun enableAppProtection(preference: SwitchPreference) {
val ctx = preference.context ?: return
val cancelListener =
object : DialogInterface.OnCancelListener, DialogInterface.OnClickListener {
override fun onCancel(dialog: DialogInterface?) {
settings.appPassword = null
preference.isChecked = false
preference.isEnabled = true
}
override fun onClick(dialog: DialogInterface?, which: Int) = onCancel(dialog)
}
preference.isEnabled = false
TextInputDialog.Builder(ctx)
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
.setHint(R.string.enter_password)
.setNegativeButton(android.R.string.cancel, cancelListener)
.setOnCancelListener(cancelListener)
.setPositiveButton(android.R.string.ok) { d, password ->
if (password.isBlank()) {
cancelListener.onCancel(d)
return@setPositiveButton
}
TextInputDialog.Builder(ctx)
.setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD)
.setHint(R.string.repeat_password)
.setNegativeButton(android.R.string.cancel, cancelListener)
.setOnCancelListener(cancelListener)
.setPositiveButton(android.R.string.ok) { d2, password2 ->
if (password == password2) {
settings.appPassword = password.md5()
preference.isChecked = true
preference.isEnabled = true
} else {
cancelListener.onCancel(d2)
Snackbar.make(
listView,
R.string.passwords_mismatch,
Snackbar.LENGTH_SHORT
).show()
}
}.setTitle(preference.title)
.create()
.show()
}.setTitle(preference.title)
.create()
.show()
}
} }

View File

@@ -30,7 +30,6 @@ class SettingsActivity : BaseActivity<ActivitySettingsBinding>(),
} }
} }
@Suppress("DEPRECATION")
override fun onPreferenceStartFragment( override fun onPreferenceStartFragment(
caller: PreferenceFragmentCompat, caller: PreferenceFragmentCompat,
pref: Preference pref: Preference

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel import org.koitharu.kotatsu.settings.backup.RestoreViewModel
import org.koitharu.kotatsu.settings.onboard.OnboardViewModel import org.koitharu.kotatsu.settings.onboard.OnboardViewModel
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
val settingsModule val settingsModule
get() = module { get() = module {
@@ -25,4 +26,5 @@ val settingsModule
} }
viewModel { ProtectSetupViewModel(get()) } viewModel { ProtectSetupViewModel(get()) }
viewModel { OnboardViewModel(get()) } viewModel { OnboardViewModel(get()) }
viewModel { SourcesSettingsViewModel(get()) }
} }

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.toTitleCase
import java.util.* import java.util.*
class OnboardViewModel( class OnboardViewModel(
@@ -27,9 +28,9 @@ class OnboardViewModel(
init { init {
if (settings.isSourcesSelected) { if (settings.isSourcesSelected) {
selectedLocales.removeAll(settings.hiddenSources.map { x -> MangaSource.valueOf(x).locale }) selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale })
} else { } else {
val deviceLocales = LocaleListCompat.getDefault().map { x -> val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
x.language x.language
} }
selectedLocales.retainAll(deviceLocales) selectedLocales.retainAll(deviceLocales)
@@ -64,7 +65,7 @@ class OnboardViewModel(
} else null } else null
SourceLocale( SourceLocale(
key = key, key = key,
title = locale?.getDisplayLanguage(locale)?.capitalize(locale), title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
isChecked = key in selectedLocales isChecked = key in selectedLocales
) )
}.sortedWith(SourceLocaleComparator()) }.sortedWith(SourceLocaleComparator())

View File

@@ -1,18 +0,0 @@
package org.koitharu.kotatsu.settings.sources
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
class SourceViewHolder(parent: ViewGroup) :
BaseViewHolder<MangaSource, Boolean, ItemSourceConfigBinding>(
ItemSourceConfigBinding.inflate(LayoutInflater.from(parent.context), parent, false)
) {
override fun onBind(data: MangaSource, extra: Boolean) {
binding.textViewTitle.text = data.title
binding.switchToggle.isChecked = extra
}
}

View File

@@ -1,69 +0,0 @@
package org.koitharu.kotatsu.settings.sources
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.mapToSet
class SourcesAdapter(
private val settings: AppSettings,
private val onItemClickListener: OnListItemClickListener<MangaSource>,
) : RecyclerView.Adapter<SourceViewHolder>() {
private val dataSet =
MangaProviderFactory.getSources(settings, includeHidden = true).toMutableList()
private val hiddenItems = settings.hiddenSources.mapNotNull {
runCatching {
MangaSource.valueOf(it)
}.getOrNull()
}.toMutableSet()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
) = SourceViewHolder(parent).also(::onViewHolderCreated)
override fun getItemCount() = dataSet.size
override fun onBindViewHolder(holder: SourceViewHolder, position: Int) {
val item = dataSet[position]
holder.bind(item, !hiddenItems.contains(item))
}
@SuppressLint("ClickableViewAccessibility")
private fun onViewHolderCreated(holder: SourceViewHolder) {
holder.binding.switchToggle.setOnCheckedChangeListener { _, it ->
if (it) {
hiddenItems.remove(holder.requireData())
} else {
hiddenItems.add(holder.requireData())
}
settings.hiddenSources = hiddenItems.mapToSet { x -> x.name }
}
holder.binding.imageViewConfig.setOnClickListener { v ->
onItemClickListener.onItemClick(holder.requireData(), v)
}
holder.binding.imageViewHandle.setOnTouchListener { v, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onItemClickListener.onItemLongClick(
holder.requireData(),
holder.itemView
)
} else {
false
}
}
}
fun moveItem(oldPos: Int, newPos: Int) {
val item = dataSet.removeAt(oldPos)
dataSet.add(newPos, item)
notifyItemMoved(oldPos, newPos)
settings.sourcesOrder = dataSet.map { it.ordinal }
}
}

View File

@@ -1,24 +0,0 @@
package org.koitharu.kotatsu.settings.sources
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
class SourcesReorderCallback :
ItemTouchHelper.SimpleCallback(ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val adapter = recyclerView.adapter as? SourcesAdapter ?: return false
val oldPos = viewHolder.bindingAdapterPosition
val newPos = target.bindingAdapterPosition
adapter.moveItem(oldPos, newPos)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}

View File

@@ -1,25 +1,28 @@
package org.koitharu.kotatsu.settings.sources package org.koitharu.kotatsu.settings.sources
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koin.android.ext.android.get import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigItemDecoration
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(), class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
OnListItemClickListener<MangaSource> { SourceConfigListener {
private lateinit var reorderHelper: ItemTouchHelper private lateinit var reorderHelper: ItemTouchHelper
private val viewModel by viewModel<SourcesSettingsViewModel>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -39,11 +42,16 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val sourcesAdapter = SourceConfigAdapter(this)
with(binding.recyclerView) { with(binding.recyclerView) {
addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL)) setHasFixedSize(true)
adapter = SourcesAdapter(get(), this@SourcesSettingsFragment) addItemDecoration(SourceConfigItemDecoration(view.context))
adapter = sourcesAdapter
reorderHelper.attachToRecyclerView(this) reorderHelper.attachToRecyclerView(this)
} }
viewModel.items.observe(viewLifecycleOwner) {
sourcesAdapter.items = it
}
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -51,22 +59,6 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
super.onDestroyView() super.onDestroyView()
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
// TODO handle changes in dialog
// inflater.inflate(R.menu.opt_sources, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when(item.itemId) {
R.id.action_languages -> {
OnboardDialogFragment.show(parentFragmentManager)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
bottom = insets.bottom, bottom = insets.bottom,
@@ -75,14 +67,47 @@ class SourcesSettingsFragment : BaseFragment<FragmentSettingsSourcesBinding>(),
) )
} }
override fun onItemClick(item: MangaSource, view: View) { override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) {
(activity as? SettingsActivity)?.openMangaSourceSettings(item) (activity as? SettingsActivity)?.openMangaSourceSettings(item.source)
} }
override fun onItemLongClick(item: MangaSource, view: View): Boolean { override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
reorderHelper.startDrag( viewModel.setEnabled(item.source, isEnabled)
binding.recyclerView.findContainingViewHolder(view) ?: return false }
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) {
reorderHelper.startDrag(holder)
}
override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) {
viewModel.expandOrCollapse(header.localeId)
}
private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSources(
viewHolder.bindingAdapterPosition,
target.bindingAdapterPosition,
) )
return true
override fun canDropOver(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder(
current.bindingAdapterPosition,
target.bindingAdapterPosition,
)
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
} }
} }

View File

@@ -0,0 +1,134 @@
package org.koitharu.kotatsu.settings.sources
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaProviderFactory
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.move
import org.koitharu.kotatsu.utils.ext.toTitleCase
import java.util.*
private const val KEY_ENABLED = "!"
class SourcesSettingsViewModel(
private val settings: AppSettings,
) : BaseViewModel() {
val items = MutableLiveData<List<SourceConfigItem>>(emptyList())
private val expandedGroups = HashSet<String?>()
init {
buildList()
}
fun reorderSources(oldPos: Int, newPos: Int): Boolean {
val snapshot = items.value?.toMutableList() ?: return false
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
snapshot.move(oldPos, newPos)
settings.sourcesOrder = snapshot.mapNotNull {
(it as? SourceConfigItem.SourceItem)?.source?.ordinal
}
buildList()
return true
}
fun canReorder(oldPos: Int, newPos: Int): Boolean {
val snapshot = items.value?.toMutableList() ?: return false
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
return true
}
fun setEnabled(source: MangaSource, isEnabled: Boolean) {
settings.hiddenSources = if (isEnabled) {
settings.hiddenSources - source.name
} else {
settings.hiddenSources + source.name
}
buildList()
}
fun expandOrCollapse(headerId: String?) {
if (headerId in expandedGroups) {
expandedGroups.remove(headerId)
} else {
expandedGroups.add(headerId)
}
buildList()
}
private fun buildList() {
val sources = MangaProviderFactory.getSources(settings, includeHidden = true)
val hiddenSources = settings.hiddenSources
val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it.name !in hiddenSources) {
KEY_ENABLED
} else {
it.locale
}
}
val result = ArrayList<SourceConfigItem>(sources.size + map.size + 1)
val enabledSources = map.remove(KEY_ENABLED)
if (!enabledSources.isNullOrEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources)
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
isEnabled = true,
)
}
}
if (enabledSources?.size != sources.size) {
result += SourceConfigItem.Header(R.string.available_sources)
for ((key, list) in map) {
val locale = if (key != null) {
Locale(key)
} else null
list.sortBy { it.ordinal }
val isExpanded = key in expandedGroups
result += SourceConfigItem.LocaleGroup(
localeId = key,
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
isExpanded = isExpanded,
)
if (isExpanded) {
list.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
isEnabled = false,
)
}
}
}
}
items.value = result
}
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
.map { it.language }
override fun compare(a: String?, b: String?): Int {
when {
a == b -> return 0
a == null -> return 1
b == null -> return -1
}
val ai = deviceLocales.indexOf(a!!)
val bi = deviceLocales.indexOf(b!!)
return when {
ai < 0 && bi < 0 -> a.compareTo(b)
ai < 0 -> 1
bi < 0 -> -1
else -> ai.compareTo(bi)
}
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.settings.sources.adapter
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class SourceConfigAdapter(
listener: SourceConfigListener,
) : AsyncListDifferDelegationAdapter<SourceConfigItem>(
SourceConfigDiffCallback(),
sourceConfigHeaderDelegate(),
sourceConfigGroupDelegate(listener),
sourceConfigItemDelegate(listener),
)

View File

@@ -0,0 +1,80 @@
package org.koitharu.kotatsu.settings.sources.adapter
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.widget.CompoundButton
import androidx.core.view.isVisible
import androidx.core.view.updatePaddingRelative
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemExpandableBinding
import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding
import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) }
) {
bind {
binding.root.setText(item.titleResId)
}
}
fun sourceConfigGroupDelegate(
listener: SourceConfigListener,
) = adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) }
) {
binding.root.setOnClickListener {
listener.onHeaderClick(item)
}
bind {
binding.root.text = item.title ?: getString(R.string.other)
binding.root.isChecked = item.isExpanded
}
}
@SuppressLint("ClickableViewAccessibility")
fun sourceConfigItemDelegate(
listener: SourceConfigListener,
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) }
) {
val eventListener = object : View.OnClickListener, View.OnTouchListener,
CompoundButton.OnCheckedChangeListener {
override fun onClick(v: View?) = listener.onItemSettingsClick(item)
override fun onTouch(v: View?, event: MotionEvent): Boolean {
return if (event.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onDragHandleTouch(this@adapterDelegateViewBinding)
true
} else {
false
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
listener.onItemEnabledChanged(item, isChecked)
}
}
binding.imageViewConfig.setOnClickListener(eventListener)
binding.switchToggle.setOnCheckedChangeListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener)
bind {
binding.textViewTitle.text = item.source.title
binding.switchToggle.isChecked = item.isEnabled
binding.imageViewHandle.isVisible = item.isEnabled
binding.imageViewConfig.isVisible = item.isEnabled
binding.root.updatePaddingRelative(
start = if (item.isEnabled) 0 else binding.imageViewHandle.paddingStart * 2,
end = if (item.isEnabled) 0 else binding.imageViewConfig.paddingEnd,
)
}
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.settings.sources.adapter
import androidx.recyclerview.widget.DiffUtil
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
class SourceConfigDiffCallback : DiffUtil.ItemCallback<SourceConfigItem>() {
override fun areItemsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean {
return when {
oldItem.javaClass != newItem.javaClass -> false
oldItem is SourceConfigItem.LocaleGroup && newItem is SourceConfigItem.LocaleGroup -> {
oldItem.localeId == newItem.localeId
}
oldItem is SourceConfigItem.SourceItem && newItem is SourceConfigItem.SourceItem -> {
oldItem.source == newItem.source
}
oldItem is SourceConfigItem.Header && newItem is SourceConfigItem.Header -> {
oldItem.titleResId == newItem.titleResId
}
else -> false
}
}
override fun areContentsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: SourceConfigItem, newItem: SourceConfigItem) = Unit
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.settings.sources.adapter
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.base.ui.list.decor.AbstractDividerItemDecoration
class SourceConfigItemDecoration(context: Context) : AbstractDividerItemDecoration(context) {
override fun shouldDrawDivider(
above: RecyclerView.ViewHolder,
below: RecyclerView.ViewHolder,
): Boolean {
return above.itemViewType != 0 && below.itemViewType != 0
}
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.settings.sources.adapter
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
interface SourceConfigListener {
fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
fun onDragHandleTouch(holder: RecyclerView.ViewHolder)
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)
}

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.settings.sources.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.model.MangaSource
sealed interface SourceConfigItem {
class Header(
@StringRes val titleResId: Int,
) : SourceConfigItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Header
return titleResId == other.titleResId
}
override fun hashCode(): Int = titleResId
}
class LocaleGroup(
val localeId: String?,
val title: String?,
val isExpanded: Boolean,
) : SourceConfigItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LocaleGroup
if (localeId != other.localeId) return false
if (title != other.title) return false
if (isExpanded != other.isExpanded) return false
return true
}
override fun hashCode(): Int {
var result = localeId?.hashCode() ?: 0
result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + isExpanded.hashCode()
return result
}
}
class SourceItem(
val source: MangaSource,
val isEnabled: Boolean,
) : SourceConfigItem {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SourceItem
if (source != other.source) return false
if (isEnabled != other.isEnabled) return false
return true
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + isEnabled.hashCode()
return result
}
}
}

View File

@@ -0,0 +1,87 @@
package org.koitharu.kotatsu.utils
import android.app.DownloadManager
import android.app.DownloadManager.Request.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.ext.toFileNameSafe
import java.io.File
import kotlin.coroutines.resume
class DownloadManagerHelper(
private val context: Context,
private val cookieJar: CookieJar,
) {
private val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
private val subDir = context.getString(R.string.app_name).toFileNameSafe()
fun downloadPage(page: MangaPage, fullUrl: String): Long {
val uri = fullUrl.toUri()
val cookies = cookieJar.loadForRequest(fullUrl.toHttpUrl())
val dest = subDir + File.separator + uri.lastPathSegment
val request = DownloadManager.Request(uri)
.addRequestHeader(CommonHeaders.REFERER, page.referer)
.addRequestHeader(CommonHeaders.COOKIE, cookieHeader(cookies))
.setAllowedOverMetered(true)
.setAllowedNetworkTypes(NETWORK_WIFI or NETWORK_MOBILE)
.setNotificationVisibility(VISIBILITY_VISIBLE)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, dest)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
request.allowScanningByMediaScanner()
}
return manager.enqueue(request)
}
suspend fun awaitDownload(id: Long): Uri {
getUriForDownloadedFile(id)?.let { return it } // fast path
suspendCancellableCoroutine<Unit> { cont ->
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (
intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE &&
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) == id
) {
context.unregisterReceiver(this)
cont.resume(Unit)
}
}
}
context.registerReceiver(
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
)
cont.invokeOnCancellation {
context.unregisterReceiver(receiver)
}
}
return checkNotNull(getUriForDownloadedFile(id))
}
private suspend fun getUriForDownloadedFile(id: Long) = withContext(Dispatchers.IO) {
manager.getUriForDownloadedFile(id)
}
private fun cookieHeader(cookies: List<Cookie>): String = buildString {
cookies.forEachIndexed { index, cookie ->
if (index > 0) append("; ")
append(cookie.name).append('=').append(cookie.value)
}
}
}

View File

@@ -1,66 +0,0 @@
package org.koitharu.kotatsu.utils
import android.content.ContentResolver
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.database.getStringOrNull
import org.koitharu.kotatsu.BuildConfig
import java.io.OutputStream
class MediaStoreCompat(private val contentResolver: ContentResolver) {
fun insertImage(
fileName: String,
block: (OutputStream) -> Unit
): Uri? {
val name = fileName.substringBeforeLast('.')
val cv = ContentValues(7)
cv.put(MediaStore.Images.Media.DISPLAY_NAME, name)
cv.put(MediaStore.Images.Media.TITLE, name)
cv.put(
MediaStore.Images.Media.MIME_TYPE,
MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName.substringAfterLast('.'))
)
cv.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1_000)
cv.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
cv.put(MediaStore.Images.Media.IS_PENDING, 1)
}
var uri: Uri? = null
try {
uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv)
contentResolver.openOutputStream(uri!!)?.use(block)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
cv.clear()
cv.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, cv, null, null)
}
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
}
uri?.let {
contentResolver.delete(it, null, null)
}
uri = null
}
return uri
}
fun getName(uri: Uri): String? =
(if (uri.scheme == "content") {
contentResolver.query(uri, null, null, null, null)?.use {
if (it.moveToFirst()) {
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
} else {
null
}
}
} else {
null
}) ?: uri.path?.substringAfterLast('/')
}

View File

@@ -7,7 +7,7 @@ import android.database.ContentObserver
import android.os.Handler import android.os.Handler
import android.provider.Settings import android.provider.Settings
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 kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
@@ -38,7 +38,7 @@ class ScreenOrientationHelper(private val activity: Activity) {
fun observeAutoOrientation() = callbackFlow<Boolean> { fun observeAutoOrientation() = callbackFlow<Boolean> {
val observer = object : ContentObserver(Handler(activity.mainLooper)) { val observer = object : ContentObserver(Handler(activity.mainLooper)) {
override fun onChange(selfChange: Boolean) { override fun onChange(selfChange: Boolean) {
sendBlocking(isAutoRotationEnabled) trySendBlocking(isAutoRotationEnabled)
} }
} }
activity.contentResolver.registerContentObserver( activity.contentResolver.registerContentObserver(

View File

@@ -7,16 +7,16 @@ import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.request.ImageResult import coil.request.ImageResult
import coil.request.SuccessResult import coil.request.SuccessResult
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener
@Suppress("NOTHING_TO_INLINE") fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context)
inline fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context)
.data(url) .data(url)
.crossfade(true) .crossfade(true)
.target(this) .target(this)
@Suppress("NOTHING_TO_INLINE") fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
inline fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build())
fun ImageResult.requireBitmap() = when (this) { fun ImageResult.requireBitmap() = when (this) {
is SuccessResult -> drawable.toBitmap() is SuccessResult -> drawable.toBitmap()
@@ -32,7 +32,10 @@ fun ImageResult.toBitmapOrNull() = when (this) {
is ErrorResult -> null is ErrorResult -> null
} }
@Suppress("NOTHING_TO_INLINE") fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
inline fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder {
return setHeader(CommonHeaders.REFERER, referer) return setHeader(CommonHeaders.REFERER, referer)
}
fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder {
return listener(ImageRequestIndicatorListener(indicator))
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import java.util.*
fun <T> MutableCollection<T>.replaceWith(subject: Iterable<T>) { fun <T> MutableCollection<T>.replaceWith(subject: Iterable<T>) {
clear() clear()
@@ -72,4 +73,12 @@ fun <T, K> Collection<T>.isDistinctBy(selector: (T) -> K): Boolean {
} }
} }
return set.size == size return set.size == size
}
fun <T> MutableList<T>.move(sourceIndex: Int, targetIndex: Int) {
if (sourceIndex <= targetIndex) {
Collections.rotate(subList(sourceIndex, targetIndex + 1), -1)
} else {
Collections.rotate(subList(targetIndex, sourceIndex + 1), 1)
}
} }

View File

@@ -1,10 +1,13 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.OpenableColumns
import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -60,4 +63,19 @@ fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
delete() delete()
}
fun ContentResolver.resolveName(uri: Uri): String? {
val fallback = uri.lastPathSegment
if (uri.scheme != "content") {
return fallback
}
query(uri, null, null, null, null)?.use {
if (it.moveToFirst()) {
it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name ->
return name
}
}
}
return fallback
} }

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.utils.ext
fun <T> Iterator<T>.nextOrNull(): T? = if (hasNext()) next() else null
fun <T> Iterator<T>.toList(): List<T> {
if (!hasNext()) {
return emptyList()
}
val list = ArrayList<T>()
while (hasNext()) list += next()
return list
}
fun <T> Iterator<T>.toSet(): Set<T> {
if (!hasNext()) {
return emptySet()
}
val list = LinkedHashSet<T>()
while (hasNext()) list += next()
return list
}

View File

@@ -3,6 +3,10 @@ package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArraySet import androidx.collection.ArraySet
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.utils.json.JSONIterator
import org.koitharu.kotatsu.utils.json.JSONStringIterator
import org.koitharu.kotatsu.utils.json.JSONValuesIterator
import kotlin.contracts.contract
inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo( inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
destination: C, destination: C,
@@ -16,10 +20,26 @@ inline fun <R, C : MutableCollection<in R>> JSONArray.mapTo(
return destination return destination
} }
inline fun <R, C : MutableCollection<in R>> JSONArray.mapNotNullTo(
destination: C,
block: (JSONObject) -> R?
): C {
val len = length()
for (i in 0 until len) {
val jo = getJSONObject(i)
destination.add(block(jo) ?: continue)
}
return destination
}
inline fun <T> JSONArray.map(block: (JSONObject) -> T): List<T> { inline fun <T> JSONArray.map(block: (JSONObject) -> T): List<T> {
return mapTo(ArrayList(length()), block) return mapTo(ArrayList(length()), block)
} }
inline fun <T> JSONArray.mapNotNull(block: (JSONObject) -> T?): List<T> {
return mapNotNullTo(ArrayList(length()), block)
}
fun <T> JSONArray.mapIndexed(block: (Int, JSONObject) -> T): List<T> { fun <T> JSONArray.mapIndexed(block: (Int, JSONObject) -> T): List<T> {
val len = length() val len = length()
val result = ArrayList<T>(len) val result = ArrayList<T>(len)
@@ -44,16 +64,7 @@ fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long = opt(na
operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this) operator fun JSONArray.iterator(): Iterator<JSONObject> = JSONIterator(this)
private class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> { fun JSONArray.stringIterator(): Iterator<String> = JSONStringIterator(this)
private val total = array.length()
private var index = 0
override fun hasNext() = index < total - 1
override fun next(): JSONObject = array.getJSONObject(index++)
}
fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> { fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
val len = length() val len = length()
@@ -63,4 +74,24 @@ fun <T> JSONArray.mapToSet(block: (JSONObject) -> T): Set<T> {
result.add(block(jo)) result.add(block(jo))
} }
return result return result
}
fun JSONObject.values(): Iterator<Any> = JSONValuesIterator(this)
fun JSONArray.associateByKey(key: String): Map<String, JSONObject> {
val destination = LinkedHashMap<String, JSONObject>(length())
repeat(length()) { i ->
val item = getJSONObject(i)
val keyValue = item.getString(key)
destination[keyValue] = item
}
return destination
}
fun JSONArray?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}
return this == null || this.length() == 0
} }

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.utils.ext
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import java.util.* import java.util.*
import kotlin.collections.ArrayList
fun LocaleListCompat.toList(): List<Locale> { fun LocaleListCompat.toList(): List<Locale> {
val list = ArrayList<Locale>(size()) val list = ArrayList<Locale>(size())
@@ -26,4 +25,8 @@ inline fun <R, C : MutableCollection<in R>> LocaleListCompat.mapTo(
inline fun <T> LocaleListCompat.map(block: (Locale) -> T): List<T> { inline fun <T> LocaleListCompat.map(block: (Locale) -> T): List<T> {
return mapTo(ArrayList(size()), block) return mapTo(ArrayList(size()), block)
}
inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
return mapTo(LinkedHashSet(size()), block)
} }

View File

@@ -133,6 +133,8 @@ fun View.resetTransformations() {
translationZ = 0f translationZ = 0f
scaleX = 1f scaleX = 1f
scaleY = 1f scaleY = 1f
rotationX = 0f
rotationY = 0f
} }
inline fun RecyclerView.doOnCurrentItemChanged(crossinline callback: (Int) -> Unit) { inline fun RecyclerView.doOnCurrentItemChanged(crossinline callback: (Int) -> Unit) {

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONArray
import org.json.JSONObject
class JSONIterator(private val array: JSONArray) : Iterator<JSONObject> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total - 1
override fun next(): JSONObject = array.getJSONObject(index++)
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONArray
class JSONStringIterator(private val array: JSONArray) : Iterator<String> {
private val total = array.length()
private var index = 0
override fun hasNext() = index < total - 1
override fun next(): String = array.getString(index++)
}

View File

@@ -0,0 +1,17 @@
package org.koitharu.kotatsu.utils.json
import org.json.JSONObject
class JSONValuesIterator(
private val jo: JSONObject,
): Iterator<Any> {
private val keyIterator = jo.keys()
override fun hasNext(): Boolean = keyIterator.hasNext()
override fun next(): Any {
val key = keyIterator.next()
return jo.get(key)
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.utils.progress
import coil.request.ImageRequest
import coil.request.ImageResult
import com.google.android.material.progressindicator.BaseProgressIndicator
class ImageRequestIndicatorListener(
private val indicator: BaseProgressIndicator<*>,
) : ImageRequest.Listener {
override fun onCancel(request: ImageRequest) = indicator.hide()
override fun onError(request: ImageRequest, throwable: Throwable) = indicator.hide()
override fun onStart(request: ImageRequest) = indicator.show()
override fun onSuccess(request: ImageRequest, metadata: ImageResult.Metadata) = indicator.hide()
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_expand_less" android:state_checked="true" />
<item android:drawable="@drawable/ic_expand_more" android:state_checked="false" />
</selector>

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z" />
</vector>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
android:id="@+id/ssiv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:transitionName="cover" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
style="@style/Widget.Kotatsu.Toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>

View File

@@ -11,7 +11,10 @@
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/inputLayout" android:id="@+id/inputLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" app:boxBackgroundMode="filled"
app:boxBackgroundColor="@android:color/transparent"
app:hintEnabled="false"
app:expandedHintEnabled="true"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
@@ -21,7 +24,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:imeOptions="actionDone" android:imeOptions="actionDone"
android:singleLine="true" android:singleLine="true"
tools:text="@tools:sample/lorem[2]" /> tools:hint="@tools:sample/lorem[2]" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>

View File

@@ -40,6 +40,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:transitionName="cover"
tools:background="@tools:sample/backgrounds/scenic" tools:background="@tools:sample/backgrounds/scenic"
tools:ignore="ContentDescription" /> tools:ignore="ContentDescription" />

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<CheckedTextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?android:selectableItemBackground"
android:drawablePadding="12dp"
android:gravity="center_vertical|start"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
app:drawableEndCompat="@drawable/ic_expand_collapse"
app:drawableTint="?android:textColorPrimary"
tools:text="@tools:sample/full_names" />

View File

@@ -19,6 +19,17 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" /> android:layout_gravity="center" />
<TextView
android:id="@+id/textView_number"
android:layout_width="wrap_content"
android:layout_margin="8dp"
android:singleLine="true"
android:textColor="?android:textColorTertiary"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
tools:text="5" />
<LinearLayout <LinearLayout
android:id="@+id/layout_error" android:id="@+id/layout_error"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -11,8 +11,8 @@
<ImageView <ImageView
android:id="@+id/imageView_handle" android:id="@+id/imageView_handle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:padding="?listPreferredItemPaddingStart" android:paddingHorizontal="?listPreferredItemPaddingStart"
android:scaleType="center" android:scaleType="center"
android:src="@drawable/ic_reorder_handle" /> android:src="@drawable/ic_reorder_handle" />
@@ -36,10 +36,10 @@
<ImageView <ImageView
android:id="@+id/imageView_config" android:id="@+id/imageView_config"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:background="?selectableItemBackgroundBorderless" android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/settings" android:contentDescription="@string/settings"
android:padding="?listPreferredItemPaddingEnd" android:paddingHorizontal="?listPreferredItemPaddingEnd"
android:scaleType="center" android:scaleType="center"
android:src="@drawable/ic_settings" /> android:src="@drawable/ic_settings" />

View File

@@ -244,4 +244,6 @@
<string name="state_ongoing">Ангоінг</string> <string name="state_ongoing">Ангоінг</string>
<string name="date_format">Фармат даты</string> <string name="date_format">Фармат даты</string>
<string name="system_default">Па змаўчанні</string> <string name="system_default">Па змаўчанні</string>
<string name="exclude_nsfw_from_history">Не паказваць NSFW мангу з гісторыі</string>
<string name="error_empty_name">Імя не можа быць пустым</string>
</resources> </resources>

View File

@@ -244,4 +244,9 @@
<string name="state_ongoing">Fortlaufend</string> <string name="state_ongoing">Fortlaufend</string>
<string name="date_format">Datumsformat</string> <string name="date_format">Datumsformat</string>
<string name="system_default">Standard</string> <string name="system_default">Standard</string>
<string name="exclude_nsfw_from_history">NSFW-Manga aus dem Verlauf ausschließen</string>
<string name="error_empty_name">Der Name sollte nicht leer sein</string>
<string name="show_pages_numbers">Seitenzahlen anzeigen</string>
<string name="enabled_sources">Freigegebene Quellen</string>
<string name="available_sources">Verfügbare Quellen</string>
</resources> </resources>

View File

@@ -86,7 +86,7 @@
<string name="_continue">Continuar</string> <string name="_continue">Continuar</string>
<string name="warning">Advertencia</string> <string name="warning">Advertencia</string>
<string name="network_consumption_warning">Esta operación puede consumir mucho tráfico de red</string> <string name="network_consumption_warning">Esta operación puede consumir mucho tráfico de red</string>
<string name="dont_ask_again">No volver a preguntar</string> <string name="dont_ask_again">No vuelvas a preguntar</string>
<string name="cancelling_">Cancelar…</string> <string name="cancelling_">Cancelar…</string>
<string name="error">Error</string> <string name="error">Error</string>
<string name="clear_thumbs_cache">Borrar la caché de miniaturas</string> <string name="clear_thumbs_cache">Borrar la caché de miniaturas</string>
@@ -241,4 +241,9 @@
<string name="about_feedback_4pda">Tema sobre 4PDA</string> <string name="about_feedback_4pda">Tema sobre 4PDA</string>
<string name="date_format">Formato de la fecha</string> <string name="date_format">Formato de la fecha</string>
<string name="system_default">Por defecto</string> <string name="system_default">Por defecto</string>
<string name="tracker_warning">Algunos fabricantes pueden cambiar el comportamiento del sistema, lo que podría interrumpir las tareas en segundo plano.</string>
<string name="error_empty_name">Nombre no debe estar vacío</string>
<string name="auth_not_supported_by">Autorización en %s no es compatible</string>
<string name="text_clear_cookies_prompt">Se cerrará la sesión de todas las fuentes en las que esté autorizado</string>
<string name="exclude_nsfw_from_history">Excluye manga NSFW del historial</string>
</resources> </resources>

View File

@@ -244,4 +244,6 @@
<string name="state_ongoing">Jatkuva</string> <string name="state_ongoing">Jatkuva</string>
<string name="date_format">Päivämäärän muoto</string> <string name="date_format">Päivämäärän muoto</string>
<string name="system_default">Oletus</string> <string name="system_default">Oletus</string>
<string name="error_empty_name">Nimi ei saa olla tyhjä</string>
<string name="exclude_nsfw_from_history">Sulje NSFW-mangat pois historiasta</string>
</resources> </resources>

View File

@@ -244,4 +244,9 @@
<string name="state_ongoing">En cours</string> <string name="state_ongoing">En cours</string>
<string name="date_format">Format de la date</string> <string name="date_format">Format de la date</string>
<string name="system_default">Par défaut</string> <string name="system_default">Par défaut</string>
<string name="exclude_nsfw_from_history">Exclure les mangas osés de l\'historique</string>
<string name="error_empty_name">Le nom ne doit pas être vide</string>
<string name="show_pages_numbers">Afficher les numéros de pages</string>
<string name="enabled_sources">Sources activées</string>
<string name="available_sources">Sources disponibles</string>
</resources> </resources>

View File

@@ -244,4 +244,9 @@
<string name="state_ongoing">In corso</string> <string name="state_ongoing">In corso</string>
<string name="system_default">Predefinito</string> <string name="system_default">Predefinito</string>
<string name="date_format">Formato della data</string> <string name="date_format">Formato della data</string>
<string name="exclude_nsfw_from_history">Escludi i manga NSFW dalla storia</string>
<string name="error_empty_name">Il nome non dovrebbe essere vuoto</string>
<string name="show_pages_numbers">Mostra i numeri delle pagine</string>
<string name="enabled_sources">Fonti abilitate</string>
<string name="available_sources">Fonti disponibili</string>
</resources> </resources>

View File

@@ -223,4 +223,28 @@
<string name="chapter_is_missing">Kapittel mangler</string> <string name="chapter_is_missing">Kapittel mangler</string>
<string name="text_downloads_holder">Det er ingen aktive nedlastinger</string> <string name="text_downloads_holder">Det er ingen aktive nedlastinger</string>
<string name="queued">I kø</string> <string name="queued">I kø</string>
<string name="state_finished">Fullført</string>
<string name="state_ongoing">Pågående</string>
<string name="about_app_translation_summary">Oversett dette programmet</string>
<string name="about_app_translation">Oversettelse</string>
<string name="about_author">Utvikler</string>
<string name="about_feedback">Tilbakemelding</string>
<string name="about_feedback_4pda">Emne på 4PDA</string>
<string name="about_support_developer">Støtt utvikleren</string>
<string name="about_support_developer_summary">Hvis du liker programmet kan du kronerulle det på Yoomoney (tidligere Yandex.Money)</string>
<string name="about_gratitudes">Takk rettes til</string>
<string name="about_gratitudes_summary">Folk som gjorde Kotatsu enda bedre.</string>
<string name="about_copyright_and_licenses">Opphavsrett og lisenser</string>
<string name="about_license">Lisens</string>
<string name="auth_complete">Identitetsbekreftelse fullført</string>
<string name="auth_not_supported_by">Identitetsbekreftelse på %s støttes ikke</string>
<string name="text_clear_cookies_prompt">Du vil bli utlogget fra alle kilder du pålogget i</string>
<string name="genres">Sjangere</string>
<string name="exclude_nsfw_from_history">Utelat NSFW-manga fra historikk</string>
<string name="date_format">Datoformat</string>
<string name="system_default">Forvalg</string>
<string name="error_empty_name">Navn må angis</string>
<string name="available_sources">Tilgjengelige kilder</string>
<string name="show_pages_numbers">Vis sidenummerering</string>
<string name="enabled_sources">Påskrudde kilder</string>
</resources> </resources>

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