Merge branch 'devel' into feature/suggestions
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/dictionaries
|
||||
/.idea/modules.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/workspace.xml
|
||||
|
||||
16
.idea/dictionaries/admin.xml
generated
16
.idea/dictionaries/admin.xml
generated
@@ -1,16 +0,0 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="admin">
|
||||
<words>
|
||||
<w>amoled</w>
|
||||
<w>chucker</w>
|
||||
<w>desu</w>
|
||||
<w>failsafe</w>
|
||||
<w>koin</w>
|
||||
<w>kotatsu</w>
|
||||
<w>manga</w>
|
||||
<w>snackbar</w>
|
||||
<w>upsert</w>
|
||||
<w>webtoon</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -14,7 +14,6 @@
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveModulePerSourceSet" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
102
app/build.gradle
102
app/build.gradle
@@ -6,15 +6,16 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
compileSdkVersion 31
|
||||
buildToolsVersion '30.0.3'
|
||||
namespace 'org.koitharu.kotatsu'
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 30
|
||||
versionCode 369
|
||||
versionName '2.0-b1'
|
||||
targetSdkVersion 31
|
||||
versionCode 380
|
||||
versionName '2.1.4'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -24,10 +25,6 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = '.debug'
|
||||
@@ -45,76 +42,79 @@ android {
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
lintOptions {
|
||||
disable 'MissingTranslation'
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
freeCompilerArgs += [
|
||||
'-Xjvm-default=enable',
|
||||
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-Xopt-in=kotlinx.coroutines.FlowPreview',
|
||||
'-Xopt-in=kotlin.contracts.ExperimentalContracts',
|
||||
]
|
||||
}
|
||||
lint {
|
||||
abortOnError false
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources = true
|
||||
unitTests.returnDefaultValues = false
|
||||
}
|
||||
}
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
freeCompilerArgs += [
|
||||
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-Xopt-in=kotlinx.coroutines.FlowPreview',
|
||||
'-Xopt-in=org.koin.core.component.KoinApiExtension'
|
||||
]
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.3.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.3.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.3.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.preference:preference-ktx:1.1.1'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.6.0'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||
implementation 'com.google.android.material:material:1.6.0-alpha02'
|
||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1'
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
||||
|
||||
implementation 'androidx.room:room-runtime:2.3.0'
|
||||
implementation 'androidx.room:room-ktx:2.3.0'
|
||||
kapt 'androidx.room:room-compiler:2.3.0'
|
||||
implementation 'androidx.room:room-runtime:2.4.1'
|
||||
implementation 'androidx.room:room-ktx:2.4.1'
|
||||
kapt 'androidx.room:room-compiler:2.4.1'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
|
||||
implementation 'com.squareup.okio:okio:2.10.0'
|
||||
implementation 'org.jsoup:jsoup:1.14.2'
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
|
||||
implementation 'com.squareup.okio:okio:3.0.0'
|
||||
implementation 'org.jsoup:jsoup:1.14.3'
|
||||
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1'
|
||||
|
||||
implementation 'io.insert-koin:koin-android:3.1.2'
|
||||
implementation 'io.coil-kt:coil-base:1.3.2'
|
||||
implementation 'io.insert-koin:koin-android:3.1.5'
|
||||
implementation 'io.coil-kt:coil-base:1.4.0'
|
||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.3'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'com.google.truth:truth:1.1.3'
|
||||
testImplementation 'org.json:json:20210307'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
|
||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.2'
|
||||
testImplementation 'org.json:json:20211205'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.3.0'
|
||||
androidTestImplementation 'androidx.room:room-testing:2.4.1'
|
||||
androidTestImplementation 'com.google.truth:truth:1.1.3'
|
||||
}
|
||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -1,3 +1,4 @@
|
||||
-optimizationpasses 8
|
||||
-dontobfuscate
|
||||
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
|
||||
public static void checkExpressionValueIsNotNull(...);
|
||||
@@ -5,9 +6,8 @@
|
||||
public static void checkReturnedValueIsNotNull(...);
|
||||
public static void checkFieldIsNotNull(...);
|
||||
public static void checkParameterIsNotNull(...);
|
||||
public static void checkNotNullParameter(...);
|
||||
}
|
||||
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
||||
-keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository {
|
||||
public <init>(...);
|
||||
}
|
||||
-dontwarn okhttp3.internal.platform.ConscryptPlatform
|
||||
11
app/src/debug/res/menu/opt_settings.xml
Normal file
11
app/src/debug/res/menu/opt_settings.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@id/action_leaks"
|
||||
android:title="@string/leak_canary_display_activity_label"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
4
app/src/debug/res/values/bools.xml
Normal file
4
app/src/debug/res/values/bools.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<bool name="leak_canary_add_launcher_icon">false</bool>
|
||||
</resources>
|
||||
@@ -1,8 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.koitharu.kotatsu">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
@@ -20,8 +19,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Kotatsu"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:ignore="UnusedAttribute">
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
|
||||
@@ -32,7 +31,7 @@
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.default_searchable"
|
||||
android:value=".ui.search.SearchActivity" />
|
||||
android:value="org.koitharu.kotatsu.ui.search.SearchActivity" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"
|
||||
@@ -94,11 +93,12 @@
|
||||
android:noHistory="true"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name=".settings.protect.ProtectSetupActivity"
|
||||
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||
android:label="@string/downloads" />
|
||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
|
||||
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
@@ -88,5 +89,12 @@ class KotatsuApp : Application() {
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
|
||||
.penaltyDeath()
|
||||
.detectFragmentReuse()
|
||||
.detectWrongFragmentContainer()
|
||||
.detectRetainInstanceUsage()
|
||||
.detectSetUserVisibleHint()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
|
||||
data class MangaIntent(
|
||||
class MangaIntent(
|
||||
val manga: Manga?,
|
||||
val mangaId: Long,
|
||||
val uri: Uri?
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
package org.koitharu.kotatsu.base.domain
|
||||
|
||||
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.get
|
||||
import org.koitharu.kotatsu.core.exceptions.GraphQLException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.parseJson
|
||||
|
||||
|
||||
open class MangaLoaderContext(
|
||||
private val okHttp: OkHttpClient,
|
||||
val cookieJar: CookieJar
|
||||
val cookieJar: CookieJar,
|
||||
) : KoinComponent {
|
||||
|
||||
suspend fun httpGet(url: String, headers: Headers? = null): Response {
|
||||
@@ -24,7 +30,7 @@ open class MangaLoaderContext(
|
||||
|
||||
suspend fun httpPost(
|
||||
url: String,
|
||||
form: Map<String, String>
|
||||
form: Map<String, String>,
|
||||
): Response {
|
||||
val body = FormBody.Builder()
|
||||
form.forEach { (k, v) ->
|
||||
@@ -38,7 +44,7 @@ open class MangaLoaderContext(
|
||||
|
||||
suspend fun httpPost(
|
||||
url: String,
|
||||
payload: String
|
||||
payload: String,
|
||||
): Response {
|
||||
val body = FormBody.Builder()
|
||||
payload.split('&').forEach {
|
||||
@@ -55,10 +61,24 @@ open class MangaLoaderContext(
|
||||
return okHttp.newCall(request.build()).await()
|
||||
}
|
||||
|
||||
suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
|
||||
val body = JSONObject()
|
||||
body.put("operationName", null)
|
||||
body.put("variables", JSONObject())
|
||||
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)
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val SCHEME_HTTP = "http"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ object MangaProviderFactory {
|
||||
fun getSources(settings: AppSettings, includeHidden: Boolean): List<MangaSource> {
|
||||
val list = MangaSource.values().toList() - MangaSource.LOCAL
|
||||
val order = settings.sourcesOrder
|
||||
val hidden = settings.hiddenSources
|
||||
val sorted = list.sortedBy { x ->
|
||||
val e = order.indexOf(x.ordinal)
|
||||
if (e == -1) order.size + x.ordinal else e
|
||||
@@ -16,6 +15,7 @@ object MangaProviderFactory {
|
||||
return if (includeHidden) {
|
||||
sorted
|
||||
} else {
|
||||
val hidden = settings.hiddenSources
|
||||
sorted.filterNot { x ->
|
||||
x.name in hidden
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package org.koitharu.kotatsu.base.domain
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.util.Size
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.koin.core.component.KoinComponent
|
||||
@@ -11,6 +13,7 @@ import org.koin.core.component.get
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.MangaPage
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.ext.await
|
||||
import org.koitharu.kotatsu.utils.ext.medianOrNull
|
||||
@@ -23,19 +26,19 @@ object MangaUtils : KoinComponent {
|
||||
* Automatic determine type of manga by page size
|
||||
* @return ReaderMode.WEBTOON if page is wide
|
||||
*/
|
||||
@WorkerThread
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? {
|
||||
try {
|
||||
val page = pages.medianOrNull() ?: return null
|
||||
val url = page.source.repository.getPageUrl(page)
|
||||
val url = MangaRepository(page.source).getPageUrl(page)
|
||||
val uri = Uri.parse(url)
|
||||
val size = if (uri.scheme == "cbz") {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
val zip = ZipFile(uri.schemeSpecificPart)
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
zip.getInputStream(entry).use {
|
||||
getBitmapSize(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val client = get<OkHttpClient>()
|
||||
val request = Request.Builder()
|
||||
@@ -45,9 +48,11 @@ object MangaUtils : KoinComponent {
|
||||
.cacheControl(CacheUtils.CONTROL_DISABLED)
|
||||
.build()
|
||||
client.newCall(request).await().use {
|
||||
withContext(Dispatchers.IO) {
|
||||
getBitmapSize(it.body?.byteStream())
|
||||
}
|
||||
}
|
||||
}
|
||||
return size.width * 2 < size.height
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
|
||||
@@ -5,9 +5,9 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
|
||||
@@ -20,7 +20,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
val inflater = activity?.layoutInflater ?: LayoutInflater.from(requireContext())
|
||||
val binding = onInflateView(inflater, null)
|
||||
viewBinding = binding
|
||||
return AlertDialog.Builder(requireContext(), theme)
|
||||
return MaterialAlertDialogBuilder(requireContext(), theme)
|
||||
.setView(binding.root)
|
||||
.also(::onBuildDialog)
|
||||
.create()
|
||||
@@ -38,7 +38,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
open fun onBuildDialog(builder: AlertDialog.Builder) = Unit
|
||||
open fun onBuildDialog(builder: MaterialAlertDialogBuilder) = Unit
|
||||
|
||||
protected fun bindingOrNull(): B? = viewBinding
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
|
||||
private var lastInsets: Insets = Insets.NONE
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (get<AppSettings>().isAmoledTheme) {
|
||||
setTheme(R.style.AppTheme_AMOLED)
|
||||
when {
|
||||
get<AppSettings>().isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED)
|
||||
get<AppSettings>().isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.view.LayoutInflater
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||
|
||||
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) :
|
||||
@@ -17,7 +18,7 @@ class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog)
|
||||
|
||||
private val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||
|
||||
private val delegate = AlertDialog.Builder(context)
|
||||
private val delegate = MaterialAlertDialogBuilder(context)
|
||||
.setView(binding.root)
|
||||
|
||||
fun setTitle(@StringRes titleResId: Int): Builder {
|
||||
|
||||
@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import org.koitharu.kotatsu.utils.ext.inflate
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
import java.io.File
|
||||
|
||||
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||
@@ -24,16 +23,16 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
|
||||
|
||||
private val adapter = VolumesAdapter(context)
|
||||
private val delegate = AlertDialog.Builder(context)
|
||||
private val delegate = MaterialAlertDialogBuilder(context)
|
||||
|
||||
init {
|
||||
if (adapter.isEmpty) {
|
||||
delegate.setMessage(R.string.cannot_find_available_storage)
|
||||
} else {
|
||||
val checked = adapter.volumes.indexOfFirst {
|
||||
adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
|
||||
it.first.canonicalPath == defaultValue?.canonicalPath
|
||||
}
|
||||
delegate.setSingleChoiceItems(adapter, checked) { d, i ->
|
||||
delegate.setAdapter(adapter) { d, i ->
|
||||
listener.onStorageSelected(adapter.getItem(i).first)
|
||||
d.dismiss()
|
||||
}
|
||||
@@ -60,12 +59,16 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
|
||||
private class VolumesAdapter(context: Context) : BaseAdapter() {
|
||||
|
||||
var selectedItemPosition: Int = -1
|
||||
val volumes = getAvailableVolumes(context)
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: parent.inflate(R.layout.item_storage)
|
||||
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
||||
view.tag = it
|
||||
}
|
||||
val item = volumes[position]
|
||||
val binding = ItemStorageBinding.bind(view)
|
||||
binding.imageViewIndicator.isChecked = selectedItemPosition == position
|
||||
binding.textViewTitle.text = item.second
|
||||
binding.textViewSubtitle.text = item.first.path
|
||||
return view
|
||||
@@ -73,23 +76,21 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
|
||||
override fun getItem(position: Int): Pair<File, String> = volumes[position]
|
||||
|
||||
override fun getItemId(position: Int) = volumes[position].first.absolutePath.longHashCode()
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
|
||||
override fun getCount() = volumes.size
|
||||
|
||||
override fun hasStableIds() = true
|
||||
|
||||
private fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
|
||||
return LocalMangaRepository.getAvailableStorageDirs(context).map {
|
||||
it to it.getStorageName(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnStorageSelectListener {
|
||||
|
||||
fun onStorageSelected(file: File)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
|
||||
return LocalMangaRepository.getAvailableStorageDirs(context).map {
|
||||
it to it.getStorageName(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.databinding.DialogInputBinding
|
||||
|
||||
class TextInputDialog private constructor(
|
||||
private val delegate: AlertDialog
|
||||
private val delegate: AlertDialog,
|
||||
) : DialogInterface by delegate {
|
||||
|
||||
fun show() = delegate.show()
|
||||
@@ -19,7 +19,7 @@ class TextInputDialog private constructor(
|
||||
|
||||
private val binding = DialogInputBinding.inflate(LayoutInflater.from(context))
|
||||
|
||||
private val delegate = AlertDialog.Builder(context)
|
||||
private val delegate = MaterialAlertDialogBuilder(context)
|
||||
.setView(binding.root)
|
||||
|
||||
fun setTitle(@StringRes titleResId: Int): Builder {
|
||||
@@ -33,7 +33,7 @@ class TextInputDialog private constructor(
|
||||
}
|
||||
|
||||
fun setHint(@StringRes hintResId: Int): Builder {
|
||||
binding.inputLayout.hint = binding.root.context.getString(hintResId)
|
||||
binding.inputEdit.hint = binding.root.context.getString(hintResId)
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class TextInputDialog private constructor(
|
||||
listener: (DialogInterface, String) -> Unit
|
||||
): Builder {
|
||||
delegate.setPositiveButton(textId) { dialog, _ ->
|
||||
listener(dialog, binding.inputEdit.text.toString().orEmpty())
|
||||
listener(dialog, binding.inputEdit.text?.toString().orEmpty())
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.util.*
|
||||
|
||||
@Deprecated("")
|
||||
class AdapterUpdater<T>(oldList: List<T>, newList: List<T>, getId: (T) -> Long) {
|
||||
|
||||
private val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
|
||||
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
||||
getId(oldList[oldItemPosition]) == getId(newList[newItemPosition])
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
|
||||
Objects.equals(oldList[oldItemPosition], newList[newItemPosition])
|
||||
|
||||
override fun getOldListSize() = oldList.size
|
||||
|
||||
override fun getNewListSize() = newList.size
|
||||
})
|
||||
|
||||
operator fun invoke(adapter: RecyclerView.Adapter<*>) {
|
||||
diff.dispatchUpdatesTo(adapter)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@Deprecated("")
|
||||
abstract class BaseViewHolder<T, E, B : ViewBinding> protected constructor(val binding: B) :
|
||||
RecyclerView.ViewHolder(binding.root), KoinComponent {
|
||||
|
||||
var boundData: T? = null
|
||||
private set
|
||||
|
||||
val context get() = itemView.context!!
|
||||
|
||||
fun bind(data: T, extra: E) {
|
||||
boundData = data
|
||||
onBind(data, extra)
|
||||
}
|
||||
|
||||
fun requireData(): T {
|
||||
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
|
||||
}
|
||||
|
||||
open fun onRecycled() = Unit
|
||||
|
||||
abstract fun onBind(data: T, extra: E)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.koitharu.kotatsu.base.ui.list.decor
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.core.content.res.getColorOrThrow
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val thickness: Int
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
init {
|
||||
paint.style = Paint.Style.FILL
|
||||
val ta = context.obtainStyledAttributes(
|
||||
null,
|
||||
materialR.styleable.MaterialDivider,
|
||||
materialR.attr.materialDividerStyle,
|
||||
materialR.style.Widget_Material3_MaterialDivider,
|
||||
)
|
||||
paint.color = ta.getColorOrThrow(materialR.styleable.MaterialDivider_dividerColor)
|
||||
thickness = ta.getDimensionPixelSize(
|
||||
materialR.styleable.MaterialDivider_dividerThickness,
|
||||
context.resources.getDimensionPixelSize(materialR.dimen.material_divider_thickness),
|
||||
)
|
||||
ta.recycle()
|
||||
}
|
||||
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
outRect.set(0, thickness, 0, 0)
|
||||
}
|
||||
|
||||
// TODO implement for horizontal lists on demand
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
||||
if (parent.layoutManager == null || thickness == 0) {
|
||||
return
|
||||
}
|
||||
canvas.save()
|
||||
val left: Float
|
||||
val right: Float
|
||||
if (parent.clipToPadding) {
|
||||
left = parent.paddingLeft.toFloat()
|
||||
right = (parent.width - parent.paddingRight).toFloat()
|
||||
canvas.clipRect(
|
||||
left,
|
||||
parent.paddingTop.toFloat(),
|
||||
right,
|
||||
(parent.height - parent.paddingBottom).toFloat()
|
||||
)
|
||||
} else {
|
||||
left = 0f
|
||||
right = parent.width.toFloat()
|
||||
}
|
||||
|
||||
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: Float = bounds.top + child.translationY
|
||||
val bottom: Float = top + thickness
|
||||
canvas.drawRect(left, top, right, bottom, paint)
|
||||
}
|
||||
previous = holder
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
protected abstract fun shouldDrawDivider(
|
||||
above: RecyclerView.ViewHolder,
|
||||
below: RecyclerView.ViewHolder,
|
||||
): Boolean
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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
|
||||
|
||||
class ItemTypeDividerDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val divider = context.getThemeDrawable(android.R.attr.listDivider)
|
||||
private val bounds = Rect()
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect, view: View,
|
||||
parent: RecyclerView, state: RecyclerView.State
|
||||
) {
|
||||
outRect.set(0, divider?.intrinsicHeight ?: 0, 0, 0)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) {
|
||||
if (parent.layoutManager == null || divider == null) {
|
||||
return
|
||||
}
|
||||
val adapter = parent.adapter ?: 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 lastItemType = -1
|
||||
for (child in parent.children) {
|
||||
val itemType = adapter.getItemViewType(parent.getChildAdapterPosition(child))
|
||||
if (lastItemType != -1 && itemType != lastItemType) {
|
||||
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)
|
||||
}
|
||||
lastItemType = itemType
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.core.view.children
|
||||
import com.google.android.material.button.MaterialButton
|
||||
|
||||
class CheckableButtonGroup @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : LinearLayout(context, attrs, defStyleAttr), View.OnClickListener {
|
||||
|
||||
var onCheckedChangeListener: OnCheckedChangeListener? = null
|
||||
|
||||
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
|
||||
if (child is MaterialButton) {
|
||||
child.setOnClickListener(this)
|
||||
}
|
||||
super.addView(child, index, params)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
setCheckedId(v.id)
|
||||
}
|
||||
|
||||
fun setCheckedId(@IdRes viewRes: Int) {
|
||||
children.forEach {
|
||||
(it as? MaterialButton)?.isChecked = it.id == viewRes
|
||||
}
|
||||
onCheckedChangeListener?.onCheckedChanged(this, viewRes)
|
||||
}
|
||||
|
||||
fun interface OnCheckedChangeListener {
|
||||
fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.os.Parcelable.Creator
|
||||
import android.util.AttributeSet
|
||||
import android.widget.Checkable
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.os.ParcelCompat
|
||||
|
||||
class CheckableImageView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : AppCompatImageView(context, attrs, defStyleAttr), Checkable {
|
||||
|
||||
private var isCheckedInternal = false
|
||||
@@ -14,20 +21,6 @@ class CheckableImageView @JvmOverloads constructor(
|
||||
|
||||
var onCheckedChangeListener: OnCheckedChangeListener? = null
|
||||
|
||||
init {
|
||||
setOnClickListener {
|
||||
toggle()
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnCheckedChangeListener(listener: (Boolean) -> Unit) {
|
||||
onCheckedChangeListener = object : OnCheckedChangeListener {
|
||||
override fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) {
|
||||
listener(isChecked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isChecked() = isCheckedInternal
|
||||
|
||||
override fun toggle() {
|
||||
@@ -49,18 +42,54 @@ class CheckableImageView @JvmOverloads constructor(
|
||||
override fun onCreateDrawableState(extraSpace: Int): IntArray {
|
||||
val state = super.onCreateDrawableState(extraSpace + 1)
|
||||
if (isCheckedInternal) {
|
||||
mergeDrawableStates(state, CHECKED_STATE_SET)
|
||||
mergeDrawableStates(state, intArrayOf(android.R.attr.state_checked))
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable? {
|
||||
val superState = super.onSaveInstanceState() ?: return null
|
||||
return SavedState(superState, isChecked)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
if (state is SavedState) {
|
||||
super.onRestoreInstanceState(state.superState)
|
||||
isChecked = state.isChecked
|
||||
} else {
|
||||
super.onRestoreInstanceState(state)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnCheckedChangeListener {
|
||||
|
||||
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private class SavedState : BaseSavedState {
|
||||
|
||||
private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked)
|
||||
val isChecked: Boolean
|
||||
|
||||
constructor(superState: Parcelable, checked: Boolean) : super(superState) {
|
||||
isChecked = checked
|
||||
}
|
||||
|
||||
constructor(source: Parcel) : super(source) {
|
||||
isChecked = ParcelCompat.readBoolean(source)
|
||||
}
|
||||
|
||||
override fun writeToParcel(out: Parcel, flags: Int) {
|
||||
super.writeToParcel(out, flags)
|
||||
ParcelCompat.writeBoolean(out, isChecked)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATOR: Creator<SavedState> = object : Creator<SavedState> {
|
||||
override fun createFromParcel(`in`: Parcel) = SavedState(`in`)
|
||||
|
||||
override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View.OnClickListener
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
@@ -77,7 +76,6 @@ class ChipsView @JvmOverloads constructor(
|
||||
val chip = Chip(context)
|
||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
|
||||
chip.setChipDrawable(drawable)
|
||||
chip.setTextColor(ContextCompat.getColor(context, R.color.color_primary))
|
||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||
chip.setEnsureMinTouchTargetSize(false)
|
||||
@@ -96,11 +94,32 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
data class ChipModel(
|
||||
class ChipModel(
|
||||
@DrawableRes val icon: Int,
|
||||
val title: CharSequence,
|
||||
val data: Any? = null
|
||||
)
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ChipModel
|
||||
|
||||
if (icon != other.icon) return false
|
||||
if (title != other.title) return false
|
||||
if (data != other.data) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = icon
|
||||
result = 31 * result + title.hashCode()
|
||||
result = 31 * result + data.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnChipClickListener {
|
||||
|
||||
|
||||
@@ -6,40 +6,33 @@ import android.widget.LinearLayout
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import org.koitharu.kotatsu.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
class CoverImageView @JvmOverloads constructor(
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
|
||||
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
|
||||
) : AppCompatImageView(context, attrs, defStyleAttr) {
|
||||
|
||||
private var orientation: Int = HORIZONTAL
|
||||
|
||||
init {
|
||||
context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) {
|
||||
orientation = getInt(R.styleable.CoverImageView_android_orientation, HORIZONTAL)
|
||||
orientation = getInt(R.styleable.CoverImageView_android_orientation, orientation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
val desiredWidth: Int
|
||||
val desiredHeight: Int
|
||||
if (orientation == VERTICAL) {
|
||||
val originalHeight = MeasureSpec.getSize(heightMeasureSpec)
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(
|
||||
(originalHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).toInt(),
|
||||
MeasureSpec.EXACTLY
|
||||
),
|
||||
MeasureSpec.makeMeasureSpec(originalHeight, MeasureSpec.EXACTLY)
|
||||
)
|
||||
desiredHeight = measuredHeight
|
||||
desiredWidth = (desiredHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt()
|
||||
} else {
|
||||
val originalWidth = MeasureSpec.getSize(widthMeasureSpec)
|
||||
super.onMeasure(
|
||||
MeasureSpec.makeMeasureSpec(originalWidth, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(
|
||||
(originalWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt(),
|
||||
MeasureSpec.EXACTLY
|
||||
)
|
||||
)
|
||||
desiredWidth = measuredWidth
|
||||
desiredHeight = (desiredWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt()
|
||||
}
|
||||
setMeasuredDimension(desiredWidth, desiredHeight)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2018 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.postDelayed
|
||||
import org.koitharu.kotatsu.R
|
||||
|
||||
/**
|
||||
* A custom snackbar implementation allowing more control over placement and entry/exit animations.
|
||||
*
|
||||
* Xtimms: Well, my sufferings over the Snackbar in [DetailsActivity] will go away forever... Thanks, Google.
|
||||
*
|
||||
* https://github.com/google/iosched/blob/main/mobile/src/main/java/com/google/samples/apps/iosched/widget/FadingSnackbar.kt
|
||||
*/
|
||||
class FadingSnackbar @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val message: TextView
|
||||
private val action: Button
|
||||
|
||||
init {
|
||||
val view = LayoutInflater.from(context).inflate(R.layout.fading_snackbar_layout, this, true)
|
||||
message = view.findViewById(R.id.snackbar_text)
|
||||
action = view.findViewById(R.id.snackbar_action)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
if (visibility == VISIBLE && alpha == 1f) {
|
||||
animate()
|
||||
.alpha(0f)
|
||||
.withEndAction { visibility = GONE }
|
||||
.duration = EXIT_DURATION
|
||||
}
|
||||
}
|
||||
|
||||
fun show(
|
||||
messageText: CharSequence? = null,
|
||||
@StringRes actionId: Int? = null,
|
||||
longDuration: Boolean = true,
|
||||
actionClick: () -> Unit = { dismiss() },
|
||||
dismissListener: () -> Unit = { }
|
||||
) {
|
||||
message.text = messageText
|
||||
if (actionId != null) {
|
||||
action.run {
|
||||
visibility = VISIBLE
|
||||
text = context.getString(actionId)
|
||||
setOnClickListener {
|
||||
actionClick()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
action.visibility = GONE
|
||||
}
|
||||
alpha = 0f
|
||||
visibility = VISIBLE
|
||||
animate()
|
||||
.alpha(1f)
|
||||
.duration = ENTER_DURATION
|
||||
val showDuration = ENTER_DURATION + if (longDuration) LONG_DURATION else SHORT_DURATION
|
||||
postDelayed(showDuration) {
|
||||
dismiss()
|
||||
dismissListener()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ENTER_DURATION = 300L
|
||||
private const val EXIT_DURATION = 200L
|
||||
private const val SHORT_DURATION = 1_500L
|
||||
private const val LONG_DURATION = 2_750L
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,16 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.appbar.updatePadding(top = insets.top)
|
||||
binding.webView.updatePadding(bottom = insets.bottom)
|
||||
binding.appbar.updatePadding(
|
||||
top = insets.top,
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
binding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -8,9 +8,9 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebSettings
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
|
||||
@@ -52,7 +52,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: AlertDialog.Builder) {
|
||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
|
||||
builder.setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -33,8 +34,7 @@ class BackupArchive(file: File) : MutableZipFile(file) {
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun createNew(context: Context): BackupArchive = withContext(Dispatchers.IO) {
|
||||
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import org.json.JSONArray
|
||||
|
||||
data class BackupEntry(
|
||||
class BackupEntry(
|
||||
val name: String,
|
||||
val data: JSONArray
|
||||
) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.koitharu.kotatsu.core.model.MangaState
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
|
||||
@Entity(tableName = "manga")
|
||||
data class MangaEntity(
|
||||
class MangaEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.room.PrimaryKey
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)]
|
||||
)
|
||||
data class MangaPrefsEntity(
|
||||
class MangaPrefsEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||
@ColumnInfo(name = "mode") val mode: Int
|
||||
|
||||
@@ -20,7 +20,7 @@ import androidx.room.ForeignKey
|
||||
)
|
||||
]
|
||||
)
|
||||
data class MangaTagsEntity(
|
||||
class MangaTagsEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "tag_id", index = true) val tagId: Long
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
|
||||
data class MangaWithTags(
|
||||
class MangaWithTags(
|
||||
@Embedded val manga: MangaEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||
|
||||
@Entity(tableName = "tags")
|
||||
data class TagEntity(
|
||||
class TagEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "tag_id") val id: Long,
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.room.PrimaryKey
|
||||
)
|
||||
]
|
||||
)
|
||||
data class TrackEntity(
|
||||
class TrackEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.room.PrimaryKey
|
||||
)
|
||||
]
|
||||
)
|
||||
data class TrackLogEntity(
|
||||
class TrackLogEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "id") val id: Long = 0L,
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import java.util.*
|
||||
|
||||
data class TrackLogWithManga(
|
||||
class TrackLogWithManga(
|
||||
@Embedded val trackLog: TrackLogEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.utils.ext.map
|
||||
|
||||
class GraphQLException(private val errors: JSONArray) : RuntimeException() {
|
||||
|
||||
val messages = errors.map {
|
||||
it.getString("message")
|
||||
}
|
||||
|
||||
override val message: String
|
||||
get() = messages.joinToString("\n")
|
||||
}
|
||||
@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.github
|
||||
|
||||
import java.util.*
|
||||
|
||||
data class VersionId(
|
||||
class VersionId(
|
||||
val major: Int,
|
||||
val minor: Int,
|
||||
val build: Int,
|
||||
val variantType: String,
|
||||
val variantNumber: Int
|
||||
val variantNumber: Int,
|
||||
) : Comparable<VersionId> {
|
||||
|
||||
override fun compareTo(other: VersionId): Int {
|
||||
@@ -30,10 +30,34 @@ data class VersionId(
|
||||
return variantNumber.compareTo(other.variantNumber)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as VersionId
|
||||
|
||||
if (major != other.major) return false
|
||||
if (minor != other.minor) return false
|
||||
if (build != other.build) return false
|
||||
if (variantType != other.variantType) return false
|
||||
if (variantNumber != other.variantNumber) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = major
|
||||
result = 31 * result + minor
|
||||
result = 31 * result + build
|
||||
result = 31 * result + variantType.hashCode()
|
||||
result = 31 * result + variantNumber
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private fun variantWeight(variantType: String) =
|
||||
when (variantType.toLowerCase(Locale.ROOT)) {
|
||||
when (variantType.lowercase(Locale.ROOT)) {
|
||||
"a", "alpha" -> 1
|
||||
"b", "beta" -> 2
|
||||
"rc" -> 4
|
||||
|
||||
@@ -9,6 +9,13 @@ data class MangaChapter(
|
||||
val name: String,
|
||||
val number: Int,
|
||||
val url: String,
|
||||
val branch: String? = null,
|
||||
val source: MangaSource
|
||||
) : Parcelable
|
||||
val scanlator: String?,
|
||||
val uploadDate: Long,
|
||||
val branch: String?,
|
||||
val source: MangaSource,
|
||||
) : Parcelable, Comparable<MangaChapter> {
|
||||
|
||||
override fun compareTo(other: MangaChapter): Int {
|
||||
return number.compareTo(other.number)
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,5 @@ data class MangaHistory(
|
||||
val updatedAt: Date,
|
||||
val chapterId: Long,
|
||||
val page: Int,
|
||||
val scroll: Int
|
||||
val scroll: Int,
|
||||
) : Parcelable
|
||||
@@ -8,6 +8,6 @@ data class MangaPage(
|
||||
val id: Long,
|
||||
val url: String,
|
||||
val referer: String,
|
||||
val preview: String? = null,
|
||||
val source: MangaSource
|
||||
val preview: String?,
|
||||
val source: MangaSource,
|
||||
) : Parcelable
|
||||
@@ -2,48 +2,38 @@ package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.context.GlobalContext
|
||||
import org.koin.core.error.NoBeanDefFoundException
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.site.*
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
|
||||
@Suppress("SpellCheckingInspection")
|
||||
@Parcelize
|
||||
enum class MangaSource(
|
||||
val title: String,
|
||||
val locale: String?,
|
||||
val cls: Class<out MangaRepository>
|
||||
) : Parcelable {
|
||||
LOCAL("Local", null, LocalMangaRepository::class.java),
|
||||
READMANGA_RU("ReadManga", "ru", ReadmangaRepository::class.java),
|
||||
MINTMANGA("MintManga", "ru", MintMangaRepository::class.java),
|
||||
SELFMANGA("SelfManga", "ru", SelfMangaRepository::class.java),
|
||||
MANGACHAN("Манга-тян", "ru", MangaChanRepository::class.java),
|
||||
DESUME("Desu.me", "ru", DesuMeRepository::class.java),
|
||||
HENCHAN("Хентай-тян", "ru", HenChanRepository::class.java),
|
||||
YAOICHAN("Яой-тян", "ru", YaoiChanRepository::class.java),
|
||||
MANGATOWN("MangaTown", "en", MangaTownRepository::class.java),
|
||||
MANGALIB("MangaLib", "ru", MangaLibRepository::class.java),
|
||||
LOCAL("Local", null),
|
||||
READMANGA_RU("ReadManga", "ru"),
|
||||
MINTMANGA("MintManga", "ru"),
|
||||
SELFMANGA("SelfManga", "ru"),
|
||||
MANGACHAN("Манга-тян", "ru"),
|
||||
DESUME("Desu.me", "ru"),
|
||||
HENCHAN("Хентай-тян", "ru"),
|
||||
YAOICHAN("Яой-тян", "ru"),
|
||||
MANGATOWN("MangaTown", "en"),
|
||||
MANGALIB("MangaLib", "ru"),
|
||||
|
||||
// NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java),
|
||||
MANGAREAD("MangaRead", "en", MangareadRepository::class.java),
|
||||
REMANGA("Remanga", "ru", RemangaRepository::class.java),
|
||||
HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java),
|
||||
ANIBEL("Anibel", "be", AnibelRepository::class.java),
|
||||
NINEMANGA_EN("NineManga English", "en", NineMangaRepository.English::class.java),
|
||||
NINEMANGA_ES("NineManga Español", "es", NineMangaRepository.Spanish::class.java),
|
||||
NINEMANGA_RU("NineManga Русский", "ru", NineMangaRepository.Russian::class.java),
|
||||
NINEMANGA_DE("NineManga Deutsch", "de", NineMangaRepository.Deutsch::class.java),
|
||||
NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java),
|
||||
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
|
||||
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
|
||||
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java)
|
||||
MANGAREAD("MangaRead", "en"),
|
||||
REMANGA("Remanga", "ru"),
|
||||
HENTAILIB("HentaiLib", "ru"),
|
||||
ANIBEL("Anibel", "be"),
|
||||
NINEMANGA_EN("NineManga English", "en"),
|
||||
NINEMANGA_ES("NineManga Español", "es"),
|
||||
NINEMANGA_RU("NineManga Русский", "ru"),
|
||||
NINEMANGA_DE("NineManga Deutsch", "de"),
|
||||
NINEMANGA_IT("NineManga Italiano", "it"),
|
||||
NINEMANGA_BR("NineManga Brasil", "pt"),
|
||||
NINEMANGA_FR("NineManga Français", "fr"),
|
||||
EXHENTAI("ExHentai", null),
|
||||
MANGAOWL("MangaOwl", "en"),
|
||||
MANGADEX("MangaDex", null),
|
||||
;
|
||||
|
||||
@get:Throws(NoBeanDefFoundException::class)
|
||||
@Deprecated("")
|
||||
val repository: MangaRepository
|
||||
get() = GlobalContext.get().get(named(this))
|
||||
}
|
||||
@@ -7,5 +7,5 @@ import kotlinx.parcelize.Parcelize
|
||||
data class MangaTag(
|
||||
val title: String,
|
||||
val key: String,
|
||||
val source: MangaSource
|
||||
val source: MangaSource,
|
||||
) : Parcelable
|
||||
@@ -6,4 +6,5 @@ object CommonHeaders {
|
||||
const val USER_AGENT = "User-Agent"
|
||||
const val ACCEPT = "Accept"
|
||||
const val CONTENT_DISPOSITION = "Content-Disposition"
|
||||
const val COOKIE = "Cookie"
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import org.koitharu.kotatsu.utils.DownloadManagerHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
val networkModule
|
||||
@@ -28,4 +30,6 @@ val networkModule
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
factory { DownloadManagerHelper(get(), get()) }
|
||||
single { MangaLoaderContext(get(), get()) }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.net.Uri
|
||||
import coil.map.Mapper
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
|
||||
class FaviconMapper() : Mapper<Uri, HttpUrl> {
|
||||
|
||||
override fun map(data: Uri): HttpUrl {
|
||||
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
|
||||
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
|
||||
return repo.getFaviconUrl().toHttpUrl()
|
||||
}
|
||||
|
||||
override fun handles(data: Uri) = data.scheme == "favicon"
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.*
|
||||
|
||||
interface MangaRepository {
|
||||
|
||||
val source: MangaSource
|
||||
|
||||
val sortOrders: Set<SortOrder>
|
||||
|
||||
suspend fun getList2(
|
||||
|
||||
@@ -2,15 +2,12 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.parser.site.*
|
||||
|
||||
val parserModule
|
||||
get() = module {
|
||||
|
||||
single { MangaLoaderContext(get(), get()) }
|
||||
|
||||
factory<MangaRepository>(named(MangaSource.READMANGA_RU)) { ReadmangaRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.MINTMANGA)) { MintMangaRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.SELFMANGA)) { SelfMangaRepository(get()) }
|
||||
@@ -33,4 +30,6 @@ val parserModule
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_IT)) { NineMangaRepository.Italiano(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) }
|
||||
factory<MangaRepository>(named(MangaSource.MANGADEX)) { MangaDexRepository(get()) }
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.parser
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
import org.koitharu.kotatsu.core.model.MangaPage
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
@@ -12,8 +11,6 @@ abstract class RemoteMangaRepository(
|
||||
protected val loaderContext: MangaLoaderContext
|
||||
) : MangaRepository {
|
||||
|
||||
protected abstract val source: MangaSource
|
||||
|
||||
protected abstract val defaultDomain: String
|
||||
|
||||
private val conf by lazy {
|
||||
@@ -29,6 +26,8 @@ abstract class RemoteMangaRepository(
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = emptySet()
|
||||
|
||||
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico"
|
||||
|
||||
open fun onCreatePreferences(map: MutableMap<String, Any>) {
|
||||
map[SourceSettings.KEY_DOMAIN] = defaultDomain
|
||||
}
|
||||
@@ -53,8 +52,10 @@ abstract class RemoteMangaRepository(
|
||||
if (subdomain != null) {
|
||||
append(subdomain)
|
||||
append('.')
|
||||
}
|
||||
append(conf.getDomain(defaultDomain).removePrefix("www."))
|
||||
} else {
|
||||
append(conf.getDomain(defaultDomain))
|
||||
}
|
||||
append(this@withDomain)
|
||||
}
|
||||
else -> this
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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.core.model.*
|
||||
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.*
|
||||
|
||||
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
@@ -16,163 +21,243 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
SortOrder.NEWEST
|
||||
)
|
||||
|
||||
override fun getFaviconUrl(): String {
|
||||
return "https://cdn.${getDomain()}/favicons/favicon.png"
|
||||
}
|
||||
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> {
|
||||
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 link = when {
|
||||
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
|
||||
else -> tags.joinToString(
|
||||
prefix = "/manga?",
|
||||
postfix = "&page=$page",
|
||||
separator = "&",
|
||||
) { tag -> "genre[]=${tag.key}" }.withDomain()
|
||||
}
|
||||
val doc = loaderContext.httpGet(link).parseHtml()
|
||||
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
|
||||
val items = root.select("div.anime-card")
|
||||
return items.mapNotNull { card ->
|
||||
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
|
||||
val status = card.select("tr")[2].text()
|
||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
||||
val titleParts = fullTitle.splitTwoParts('/')
|
||||
val filters = tags?.takeUnless { it.isEmpty() }?.joinToString(
|
||||
separator = ",",
|
||||
prefix = "genres: [",
|
||||
postfix = "]"
|
||||
) { "\"it.key\"" }.orEmpty()
|
||||
val array = apiCall(
|
||||
"""
|
||||
getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) {
|
||||
docs {
|
||||
mediaId
|
||||
title {
|
||||
be
|
||||
alt
|
||||
}
|
||||
rating
|
||||
poster
|
||||
genres
|
||||
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(
|
||||
id = generateUid(href),
|
||||
title = titleParts?.first?.trim() ?: fullTitle,
|
||||
coverUrl = card.selectFirst("img")?.attr("data-src")
|
||||
?.withDomain().orEmpty(),
|
||||
altTitle = titleParts?.second?.trim(),
|
||||
id = generateUid(mediaId),
|
||||
title = title.getString("be"),
|
||||
coverUrl = jo.getString("poster").removePrefix("/cdn")
|
||||
.withDomain("cdn") + "?width=200&height=280",
|
||||
altTitle = title.getString("alt").takeUnless(String::isEmpty),
|
||||
author = null,
|
||||
rating = Manga.NO_RATING,
|
||||
rating = jo.getDouble("rating").toFloat() / 10f,
|
||||
url = href,
|
||||
publicUrl = href.withDomain(),
|
||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
||||
MangaTag(
|
||||
title = x.text(),
|
||||
key = x.attr("href").ifEmpty {
|
||||
return@mapNotNull null
|
||||
}.substringAfterLast("="),
|
||||
source = source
|
||||
)
|
||||
},
|
||||
state = when (status) {
|
||||
"выпускаецца" -> MangaState.ONGOING
|
||||
"завершанае" -> MangaState.FINISHED
|
||||
publicUrl = "https://${getDomain()}/${href}",
|
||||
tags = jo.getJSONArray("genres").mapToTags(),
|
||||
state = when (jo.getString("status")) {
|
||||
"ongoing" -> MangaState.ONGOING
|
||||
"finished" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
source = source
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
|
||||
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
|
||||
val (type, slug) = manga.url.split('/')
|
||||
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(
|
||||
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
|
||||
chapters = root.select("ul.series").flatMap { table ->
|
||||
table.select("li")
|
||||
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
|
||||
val href = a?.select("a")?.first()?.attr("href")
|
||||
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null
|
||||
title = title.getString("be"),
|
||||
altTitle = title.getString("alt"),
|
||||
coverUrl = "$poster?width=200&height=280",
|
||||
largeCoverUrl = poster,
|
||||
description = details.getJSONObject("description").getString("be"),
|
||||
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(
|
||||
id = generateUid(href),
|
||||
name = a.selectFirst("a")?.text().orEmpty(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
source = source
|
||||
id = generateUid(jo.getString("id")),
|
||||
name = "Глава $number",
|
||||
number = number,
|
||||
url = "${manga.url}/read/$number",
|
||||
scanlator = null,
|
||||
uploadDate = jo.getLong("released"),
|
||||
branch = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val fullUrl = chapter.url.withDomain()
|
||||
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
||||
val scripts = doc.select("script")
|
||||
for (script in scripts) {
|
||||
val data = script.html()
|
||||
val pos = data.indexOf("dataSource")
|
||||
if (pos == -1) {
|
||||
continue
|
||||
val (_, slug, _, number) = chapter.url.split('/')
|
||||
val chapterJson = apiCall(
|
||||
"""
|
||||
chapter(slug: "$slug", chapter: $number) {
|
||||
id
|
||||
images {
|
||||
large
|
||||
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 ->
|
||||
}
|
||||
""".trimIndent()
|
||||
).getJSONObject("chapter")
|
||||
val pages = chapterJson.getJSONArray("images")
|
||||
val chapterUrl = "https://${getDomain()}/${chapter.url}"
|
||||
return pages.mapIndexed { i, jo ->
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
referer = fullUrl,
|
||||
source = source
|
||||
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> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
|
||||
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
|
||||
return root.select("p.menu-tags.tupe").mapToSet { p ->
|
||||
val a = p.selectFirst("a") ?: parseFailed("a is null")
|
||||
MangaTag(
|
||||
title = a.text().toCamelCase(),
|
||||
key = a.attr("data-name"),
|
||||
source = source
|
||||
)
|
||||
val json = apiCall(
|
||||
"""
|
||||
getFilters(mediaType: manga) {
|
||||
genres
|
||||
}
|
||||
""".trimIndent()
|
||||
)
|
||||
val array = json.getJSONObject("getFilters").getJSONArray("genres")
|
||||
return array.mapToTags()
|
||||
}
|
||||
|
||||
private suspend fun search(query: String): List<Manga> {
|
||||
val domain = getDomain()
|
||||
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")
|
||||
val items = root.select("div.anime-card")
|
||||
return items.mapNotNull { card ->
|
||||
val href = card.select("a").attr("href")
|
||||
val status = card.select("tr")[2].text()
|
||||
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
|
||||
?.substringBeforeLast('[') ?: return@mapNotNull null
|
||||
val titleParts = fullTitle.splitTwoParts('/')
|
||||
val json = apiCall(
|
||||
"""
|
||||
search(query: "$query", limit: 40) {
|
||||
id
|
||||
title {
|
||||
be
|
||||
en
|
||||
}
|
||||
poster
|
||||
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(
|
||||
id = generateUid(href),
|
||||
title = titleParts?.first?.trim() ?: fullTitle,
|
||||
coverUrl = card.selectFirst("img")?.attr("src")
|
||||
?.withDomain().orEmpty(),
|
||||
altTitle = titleParts?.second?.trim(),
|
||||
id = generateUid(mediaId),
|
||||
title = title.getString("be"),
|
||||
coverUrl = jo.getString("poster").removePrefix("/cdn")
|
||||
.withDomain("cdn") + "?width=200&height=280",
|
||||
altTitle = title.getString("en").takeUnless(String::isEmpty),
|
||||
author = null,
|
||||
rating = Manga.NO_RATING,
|
||||
url = href,
|
||||
publicUrl = href.withDomain(),
|
||||
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
|
||||
MangaTag(
|
||||
title = x.text(),
|
||||
key = x.attr("href").ifEmpty {
|
||||
return@mapNotNull null
|
||||
}.substringAfterLast("="),
|
||||
source = source
|
||||
)
|
||||
},
|
||||
state = when (status) {
|
||||
"выпускаецца" -> MangaState.ONGOING
|
||||
"завершанае" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
source = source
|
||||
publicUrl = "https://${getDomain()}/${href}",
|
||||
tags = emptySet(),
|
||||
state = 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
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
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.*
|
||||
|
||||
abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(
|
||||
@@ -76,19 +77,21 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
||||
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
|
||||
val root =
|
||||
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
return manga.copy(
|
||||
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
|
||||
largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
|
||||
chapters = root.select("table.table_cha").flatMap { table ->
|
||||
table.select("div.manga2")
|
||||
}.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
|
||||
val href = a?.relUrl("href") ?: return@mapIndexedNotNull null
|
||||
chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr ->
|
||||
val href = tr?.selectFirst("a")?.relUrl("href") ?: return@mapIndexedNotNull null
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.text().trim(),
|
||||
name = tr.selectFirst("a")?.text().orEmpty(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
source = source
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -116,8 +119,9 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
preview = null,
|
||||
referer = fullUrl,
|
||||
source = source
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -154,4 +158,5 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
|
||||
SortOrder.NEWEST -> "datedesc"
|
||||
else -> "favdesc"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -93,12 +93,17 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
description = json.getString("description"),
|
||||
chapters = chaptersList.mapIndexed { i, it ->
|
||||
val chid = it.getLong("id")
|
||||
val volChap = "Том " + it.optString("vol", "0") + ". " + "Глава " + it.optString("ch", "0")
|
||||
val title = it.optString("title", "null").takeUnless { it == "null" }
|
||||
MangaChapter(
|
||||
id = generateUid(chid),
|
||||
source = manga.source,
|
||||
url = "$baseChapterUrl$chid",
|
||||
name = it.getStringOrNull("title") ?: "${manga.title} #${it.getDouble("ch")}",
|
||||
number = totalChapters - i
|
||||
uploadDate = it.getLong("date") * 1000,
|
||||
name = if (title.isNullOrEmpty()) volChap else "$volChap: $title",
|
||||
number = totalChapters - i,
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}.reversed()
|
||||
)
|
||||
@@ -113,8 +118,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
|
||||
MangaPage(
|
||||
id = generateUid(jo.getLong("id")),
|
||||
referer = fullUrl,
|
||||
preview = null,
|
||||
source = chapter.source,
|
||||
url = jo.getString("img")
|
||||
url = jo.getString("img"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +141,10 @@ class ExHentaiRepository(
|
||||
name = "${manga.title} #$i",
|
||||
number = i,
|
||||
url = url,
|
||||
branch = null,
|
||||
uploadDate = 0L,
|
||||
source = source,
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
chapters
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
@@ -7,6 +8,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
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.*
|
||||
|
||||
abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
@@ -39,14 +41,14 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
getSortKey(
|
||||
sortOrder
|
||||
)
|
||||
}&offset=${offset upBy PAGE_SIZE}"
|
||||
}&offset=${offset upBy PAGE_SIZE}", HEADER
|
||||
)
|
||||
tags.size == 1 -> loaderContext.httpGet(
|
||||
"https://$domain/list/genre/${tags.first().key}?sortType=${
|
||||
getSortKey(
|
||||
sortOrder
|
||||
)
|
||||
}&offset=${offset upBy PAGE_SIZE}"
|
||||
}&offset=${offset upBy PAGE_SIZE}", HEADER
|
||||
)
|
||||
offset > 0 -> return emptyList()
|
||||
else -> advancedSearch(domain, tags)
|
||||
@@ -104,14 +106,15 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
|
||||
val doc = loaderContext.httpGet(manga.url.withDomain(), HEADER).parseHtml()
|
||||
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||
?: throw ParseException("Cannot find root")
|
||||
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
|
||||
val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
|
||||
return manga.copy(
|
||||
description = root.selectFirst("div.manga-description")?.html(),
|
||||
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr(
|
||||
"data-full"
|
||||
),
|
||||
largeCoverUrl = coverImg?.attr("data-full"),
|
||||
coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl,
|
||||
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
|
||||
.mapNotNull {
|
||||
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
|
||||
@@ -122,21 +125,32 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
)
|
||||
},
|
||||
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table")
|
||||
?.select("a")?.asReversed()?.mapIndexed { i, a ->
|
||||
?.select("tr:has(td > a)")?.asReversed()?.mapIndexedNotNull { i, tr ->
|
||||
val a = tr.selectFirst("a") ?: return@mapIndexedNotNull null
|
||||
val href = a.relUrl("href")
|
||||
var translators = ""
|
||||
val translatorElement = a.attr("title")
|
||||
if (!translatorElement.isNullOrBlank()) {
|
||||
translators = translatorElement
|
||||
.replace("(Переводчик),", "&")
|
||||
.removeSuffix(" (Переводчик)")
|
||||
}
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.ownText().removePrefix(manga.title).trim(),
|
||||
name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
source = source
|
||||
uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()),
|
||||
scanlator = translators,
|
||||
source = source,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1").parseHtml()
|
||||
val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1", HEADER).parseHtml()
|
||||
val scripts = doc.select("script")
|
||||
for (script in scripts) {
|
||||
val data = script.html()
|
||||
@@ -154,8 +168,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
preview = null,
|
||||
referer = chapter.url,
|
||||
source = source
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -163,7 +178,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name").parseHtml()
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name", HEADER).parseHtml()
|
||||
val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
|
||||
?.selectFirst("table.table") ?: parseFailed("Cannot find root")
|
||||
return root.select("a.element-link").mapToSet { a ->
|
||||
@@ -188,7 +203,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
|
||||
val url = "https://$domain/search/advanced"
|
||||
// Step 1: map catalog genres names to advanced-search genres ids
|
||||
val tagsIndex = loaderContext.httpGet(url).parseHtml()
|
||||
val tagsIndex = loaderContext.httpGet(url, HEADER).parseHtml()
|
||||
.body().selectFirst("form.search-form")
|
||||
?.select("div.form-group")
|
||||
?.get(1) ?: parseFailed("Genres filter element not found")
|
||||
@@ -226,5 +241,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
|
||||
|
||||
private const val PAGE_SIZE = 70
|
||||
private const val PAGE_SIZE_SEARCH = 50
|
||||
private val HEADER = Headers.Builder()
|
||||
.add("User-Agent", "readmangafun")
|
||||
.build()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,12 +18,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
return super.getList2(offset, query, tags, sortOrder).map {
|
||||
val cover = it.coverUrl
|
||||
if (cover.contains("_blur")) {
|
||||
it.copy(coverUrl = cover.replace("_blur", ""))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
it.copy(
|
||||
coverUrl = it.coverUrl.replace("_blur", ""),
|
||||
isNsfw = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +47,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
|
||||
url = readLink,
|
||||
source = source,
|
||||
number = 1,
|
||||
name = manga.title
|
||||
uploadDate = 0L,
|
||||
name = manga.title,
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import android.os.Build
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
private const val PAGE_SIZE = 20
|
||||
private const val CONTENT_RATING =
|
||||
"contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic"
|
||||
private const val LOCALE_FALLBACK = "en"
|
||||
|
||||
class MangaDexRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
|
||||
override val source = MangaSource.MANGADEX
|
||||
override val defaultDomain = "mangadex.org"
|
||||
|
||||
override val sortOrders: EnumSet<SortOrder> = EnumSet.of(
|
||||
SortOrder.UPDATED,
|
||||
SortOrder.ALPHABETICAL,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.POPULARITY,
|
||||
)
|
||||
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> {
|
||||
val domain = getDomain()
|
||||
val url = buildString {
|
||||
append("https://api.")
|
||||
append(domain)
|
||||
append("/manga?limit=")
|
||||
append(PAGE_SIZE)
|
||||
append("&offset=")
|
||||
append(offset)
|
||||
append("&includes[]=cover_art&includes[]=author&includes[]=artist&")
|
||||
tags?.forEach { tag ->
|
||||
append("includedTags[]=")
|
||||
append(tag.key)
|
||||
append('&')
|
||||
}
|
||||
if (!query.isNullOrEmpty()) {
|
||||
append("title=")
|
||||
append(query.urlEncoded())
|
||||
append('&')
|
||||
}
|
||||
append(CONTENT_RATING)
|
||||
append("&order")
|
||||
append(when (sortOrder) {
|
||||
null,
|
||||
SortOrder.UPDATED,
|
||||
-> "[latestUploadedChapter]=desc"
|
||||
SortOrder.ALPHABETICAL -> "[title]=asc"
|
||||
SortOrder.NEWEST -> "[createdAt]=desc"
|
||||
SortOrder.POPULARITY -> "[followedCount]=desc"
|
||||
else -> "[followedCount]=desc"
|
||||
})
|
||||
}
|
||||
val json = loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||
return json.map { jo ->
|
||||
val id = jo.getString("id")
|
||||
val attrs = jo.getJSONObject("attributes")
|
||||
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||
val cover = relations["cover_art"]
|
||||
?.getJSONObject("attributes")
|
||||
?.getString("fileName")
|
||||
?.let {
|
||||
"https://uploads.$domain/covers/$id/$it"
|
||||
}
|
||||
Manga(
|
||||
id = generateUid(id),
|
||||
title = requireNotNull(attrs.getJSONObject("title").selectByLocale()) {
|
||||
"Title should not be null"
|
||||
},
|
||||
altTitle = attrs.optJSONObject("altTitles")?.selectByLocale(),
|
||||
url = id,
|
||||
publicUrl = "https://$domain/title/$id",
|
||||
rating = Manga.NO_RATING,
|
||||
isNsfw = attrs.getStringOrNull("contentRating") == "erotica",
|
||||
coverUrl = cover?.plus(".256.jpg").orEmpty(),
|
||||
largeCoverUrl = cover,
|
||||
description = attrs.optJSONObject("description")?.selectByLocale(),
|
||||
tags = attrs.getJSONArray("tags").mapToSet { tag ->
|
||||
MangaTag(
|
||||
title = tag.getJSONObject("attributes")
|
||||
.getJSONObject("name")
|
||||
.firstStringValue(),
|
||||
key = tag.getString("id"),
|
||||
source = source,
|
||||
)
|
||||
},
|
||||
state = when (jo.getStringOrNull("status")) {
|
||||
"ongoing" -> MangaState.ONGOING
|
||||
"completed" -> MangaState.FINISHED
|
||||
else -> null
|
||||
},
|
||||
author = (relations["author"] ?: relations["artist"])
|
||||
?.getJSONObject("attributes")
|
||||
?.getStringOrNull("name"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = coroutineScope<Manga> {
|
||||
val domain = getDomain()
|
||||
val attrsDeferred = async {
|
||||
loaderContext.httpGet(
|
||||
"https://api.$domain/manga/${manga.url}?includes[]=artist&includes[]=author&includes[]=cover_art"
|
||||
).parseJson().getJSONObject("data").getJSONObject("attributes")
|
||||
}
|
||||
val feedDeferred = async {
|
||||
val url = buildString {
|
||||
append("https://api.")
|
||||
append(domain)
|
||||
append("/manga/")
|
||||
append(manga.url)
|
||||
append("/feed")
|
||||
append("?limit=96&includes[]=scanlation_group&order[volume]=asc&order[chapter]=asc&offset=0&")
|
||||
append(CONTENT_RATING)
|
||||
}
|
||||
loaderContext.httpGet(url).parseJson().getJSONArray("data")
|
||||
}
|
||||
val mangaAttrs = attrsDeferred.await()
|
||||
val feed = feedDeferred.await()
|
||||
//2022-01-02T00:27:11+00:00
|
||||
val dateFormat = SimpleDateFormat(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
"yyyy-MM-dd'T'HH:mm:ssX"
|
||||
} else {
|
||||
"yyyy-MM-dd'T'HH:mm:ss'+00:00'"
|
||||
},
|
||||
Locale.ROOT
|
||||
)
|
||||
manga.copy(
|
||||
description = mangaAttrs.getJSONObject("description").selectByLocale()
|
||||
?: manga.description,
|
||||
chapters = feed.mapNotNull { jo ->
|
||||
val id = jo.getString("id")
|
||||
val attrs = jo.getJSONObject("attributes")
|
||||
if (!attrs.isNull("externalUrl")) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage"))
|
||||
val relations = jo.getJSONArray("relationships").associateByKey("type")
|
||||
val number = attrs.optInt("chapter", 0)
|
||||
MangaChapter(
|
||||
id = generateUid(id),
|
||||
name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty)
|
||||
?: "Chapter #$number",
|
||||
number = number,
|
||||
url = id,
|
||||
scanlator = relations["scanlation_group"]?.getStringOrNull("name"),
|
||||
uploadDate = dateFormat.tryParse(attrs.getString("publishAt")),
|
||||
branch = locale.getDisplayName(locale).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
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
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.*
|
||||
|
||||
open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
@@ -79,6 +80,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
val info = root.selectFirst("div.media-content")
|
||||
val chaptersDoc = loaderContext.httpGet("$fullUrl?section=chapters").parseHtml()
|
||||
val scripts = chaptersDoc.select("script")
|
||||
val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US)
|
||||
var chapters: ArrayList<MangaChapter>? = null
|
||||
scripts@ for (script in scripts) {
|
||||
val raw = script.html().lines()
|
||||
@@ -91,7 +93,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
for (i in 0 until total) {
|
||||
val item = list.getJSONObject(i)
|
||||
val chapterId = item.getLong("chapter_id")
|
||||
val branchName = item.getStringOrNull("username")
|
||||
val scanlator = item.getStringOrNull("username")
|
||||
val url = buildString {
|
||||
append(manga.url)
|
||||
append("/v")
|
||||
@@ -102,19 +104,22 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
append('/')
|
||||
append(item.optString("chapter_string"))
|
||||
}
|
||||
var name = item.getStringOrNull("chapter_name")
|
||||
if (name.isNullOrBlank() || name == "null") {
|
||||
name = "Том " + item.getInt("chapter_volume") +
|
||||
" Глава " + item.getString("chapter_number")
|
||||
}
|
||||
val nameChapter = item.getStringOrNull("chapter_name")
|
||||
val volume = item.getInt("chapter_volume")
|
||||
val number = item.getString("chapter_number")
|
||||
val fullNameChapter = "Том $volume. Глава $number"
|
||||
chapters.add(
|
||||
MangaChapter(
|
||||
id = generateUid(chapterId),
|
||||
url = url,
|
||||
source = source,
|
||||
branch = branchName,
|
||||
number = total - i,
|
||||
name = name
|
||||
uploadDate = dateFormat.tryParse(
|
||||
item.getString("chapter_created_at").substringBefore(" ")
|
||||
),
|
||||
scanlator = scanlator,
|
||||
branch = null,
|
||||
name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter",
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -174,8 +179,9 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
MangaPage(
|
||||
id = generateUid(pageUrl),
|
||||
url = pageUrl,
|
||||
preview = null,
|
||||
referer = fullUrl,
|
||||
source = source
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -235,8 +241,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
|
||||
.toFloatOrNull()?.div(5f) ?: Manga.NO_RATING,
|
||||
state = null,
|
||||
source = source,
|
||||
coverUrl = "https://$domain${covers.getString("thumbnail")}",
|
||||
largeCoverUrl = "https://$domain${covers.getString("default")}"
|
||||
coverUrl = covers.getString("thumbnail"),
|
||||
largeCoverUrl = covers.getString("default")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package org.koitharu.kotatsu.core.parser.site
|
||||
|
||||
import android.util.Base64
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
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.*
|
||||
|
||||
class MangaOwlRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
|
||||
override val source = MangaSource.MANGAOWL
|
||||
|
||||
override val defaultDomain = "mangaowls.com"
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||
SortOrder.POPULARITY,
|
||||
SortOrder.NEWEST,
|
||||
SortOrder.UPDATED
|
||||
)
|
||||
|
||||
override suspend fun getList2(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> {
|
||||
val page = (offset / 36f).toIntUp().inc()
|
||||
val link = buildString {
|
||||
append("https://")
|
||||
append(getDomain())
|
||||
when {
|
||||
!query.isNullOrEmpty() -> {
|
||||
append("/search/${page}?search=")
|
||||
append(query.urlEncoded())
|
||||
}
|
||||
!tags.isNullOrEmpty() -> {
|
||||
for (tag in tags) {
|
||||
append(tag.key)
|
||||
}
|
||||
append("/${page}?type=${getAlternativeSortKey(sortOrder)}")
|
||||
}
|
||||
else -> {
|
||||
append("/${getSortKey(sortOrder)}/${page}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val doc = loaderContext.httpGet(link).parseHtml()
|
||||
val slides = doc.body().select("ul.slides") ?: parseFailed("An error occurred while parsing")
|
||||
val items = slides.select("div.col-md-2")
|
||||
return items.mapNotNull { item ->
|
||||
val href = item.selectFirst("h6 a")?.relUrl("href") ?: return@mapNotNull null
|
||||
Manga(
|
||||
id = generateUid(href),
|
||||
title = item.selectFirst("h6 a")?.text() ?: return@mapNotNull null,
|
||||
coverUrl = item.select("div.img-responsive").attr("abs:data-background-image"),
|
||||
altTitle = null,
|
||||
author = null,
|
||||
rating = runCatching {
|
||||
item.selectFirst("div.block-stars")
|
||||
?.text()
|
||||
?.toFloatOrNull()
|
||||
?.div(10f)
|
||||
}.getOrNull() ?: Manga.NO_RATING,
|
||||
url = href,
|
||||
publicUrl = href.withDomain(),
|
||||
source = source
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
|
||||
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 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(
|
||||
description = info.selectFirst(".description")?.html(),
|
||||
largeCoverUrl = info.select("img").first()?.let { img ->
|
||||
if (img.hasAttr("data-src")) img.attr("abs:data-src") else img.attr("abs:src")
|
||||
},
|
||||
author = info.selectFirst("p.fexi_header_para a.author_link")?.text(),
|
||||
state = parseStatus(info.select("p.fexi_header_para:contains(status)").first()?.ownText()),
|
||||
tags = manga.tags + info.select("div.col-xs-12.col-md-8.single-right-grid-right > p > a[href*=genres]")
|
||||
.mapNotNull {
|
||||
val a = it.selectFirst("a") ?: return@mapNotNull null
|
||||
MangaTag(
|
||||
title = a.text(),
|
||||
key = a.attr("href"),
|
||||
source = source
|
||||
)
|
||||
},
|
||||
chapters = table.select("div.table.table-chapter-list").select("li.list-group-item.chapter_list").asReversed().mapIndexed { i, li ->
|
||||
val a = li.select("a")
|
||||
val href = a.attr("data-href").ifEmpty {
|
||||
parseFailed("Link is missing")
|
||||
}
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.select("label").text(),
|
||||
number = i + 1,
|
||||
url = "$href?tr=$tr&s=$s",
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()),
|
||||
source = MangaSource.MANGAOWL,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
val fullUrl = chapter.url.withDomain()
|
||||
val doc = loaderContext.httpGet(fullUrl).parseHtml()
|
||||
val root = doc.body().select("div.item img.owl-lazy") ?: throw ParseException("Root not found")
|
||||
return root.map { div ->
|
||||
val url = div?.relUrl("data-src") ?: parseFailed("Page image not found")
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
preview = null,
|
||||
referer = url,
|
||||
source = MangaSource.MANGAOWL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String?) = when {
|
||||
status == null -> null
|
||||
status.contains("Ongoing") -> MangaState.ONGOING
|
||||
status.contains("Completed") -> MangaState.FINISHED
|
||||
else -> null
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
val doc = loaderContext.httpGet("https://${getDomain()}/").parseHtml()
|
||||
val root = doc.body().select("ul.dropdown-menu.multi-column.columns-3").select("li")
|
||||
return root.mapToSet { p ->
|
||||
val a = p.selectFirst("a") ?: parseFailed("a is null")
|
||||
MangaTag(
|
||||
title = a.text().toCamelCase(),
|
||||
key = a.attr("href"),
|
||||
source = source
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSortKey(sortOrder: SortOrder?) =
|
||||
when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
|
||||
SortOrder.POPULARITY -> "popular"
|
||||
SortOrder.NEWEST -> "new_release"
|
||||
SortOrder.UPDATED -> "lastest"
|
||||
else -> "lastest"
|
||||
}
|
||||
|
||||
private fun getAlternativeSortKey(sortOrder: SortOrder?) =
|
||||
when (sortOrder ?: sortOrders.minByOrNull { it.ordinal }) {
|
||||
SortOrder.POPULARITY -> "0"
|
||||
SortOrder.NEWEST -> "2"
|
||||
SortOrder.UPDATED -> "3"
|
||||
else -> "3"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
@@ -96,6 +98,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
|
||||
val chaptersList = root.selectFirst("div.chapter_content")
|
||||
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
|
||||
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
|
||||
return manga.copy(
|
||||
tags = manga.tags + info?.select("li")?.find { x ->
|
||||
x.selectFirst("b")?.ownText() == "Genre(s):"
|
||||
@@ -117,9 +120,15 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
url = href,
|
||||
source = MangaSource.MANGATOWN,
|
||||
number = i + 1,
|
||||
name = name.ifEmpty { "${manga.title} - ${i + 1}" }
|
||||
uploadDate = parseChapterDate(
|
||||
dateFormat,
|
||||
li.selectFirst("span.time")?.text()
|
||||
),
|
||||
name = name.ifEmpty { "${manga.title} - ${i + 1}" },
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
} ?: bypassLicensedChapters(manga)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,8 +145,9 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
MangaPage(
|
||||
id = generateUid(href),
|
||||
url = href,
|
||||
preview = null,
|
||||
referer = fullUrl,
|
||||
source = MangaSource.MANGATOWN
|
||||
source = MangaSource.MANGATOWN,
|
||||
)
|
||||
} ?: parseFailed("Pages list not found")
|
||||
}
|
||||
@@ -167,11 +177,46 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
|
||||
return when {
|
||||
date.isNullOrEmpty() -> 0L
|
||||
date.contains("Today") -> Calendar.getInstance().timeInMillis
|
||||
date.contains("Yesterday") -> Calendar.getInstance().apply { add(Calendar.DAY_OF_MONTH, -1) }.timeInMillis
|
||||
else -> dateFormat.tryParse(date)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(map: MutableMap<String, Any>) {
|
||||
super.onCreatePreferences(map)
|
||||
map[SourceSettings.KEY_USE_SSL] = true
|
||||
}
|
||||
|
||||
private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> {
|
||||
val doc = loaderContext.httpGet(manga.url.withDomain("m")).parseHtml()
|
||||
val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
|
||||
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
|
||||
return list.select("li").asReversed().mapIndexedNotNull { i, li ->
|
||||
val a = li.selectFirst("a") ?: return@mapIndexedNotNull null
|
||||
val href = a.relUrl("href")
|
||||
val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty {
|
||||
a.ownText()
|
||||
}
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
url = href,
|
||||
source = MangaSource.MANGATOWN,
|
||||
number = i + 1,
|
||||
uploadDate = parseChapterDate(
|
||||
dateFormat,
|
||||
li.selectFirst("span.time")?.text()
|
||||
),
|
||||
name = name.ifEmpty { "${manga.title} - ${i + 1}" },
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it }
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -4,7 +4,10 @@ import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.WordSet
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class MangareadRepository(
|
||||
@@ -52,7 +55,7 @@ class MangareadRepository(
|
||||
id = generateUid(href),
|
||||
url = href,
|
||||
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(),
|
||||
rating = div.selectFirst("span.total_votes")?.ownText()
|
||||
?.toFloatOrNull()?.div(5f) ?: -1f,
|
||||
@@ -104,16 +107,7 @@ class MangareadRepository(
|
||||
val root2 = doc.body().selectFirst("div.content-area")
|
||||
?.selectFirst("div.c-page")
|
||||
?: 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)
|
||||
return manga.copy(
|
||||
tags = root.selectFirst("div.genres-content")?.select("a")
|
||||
?.mapNotNullToSet { a ->
|
||||
@@ -128,7 +122,7 @@ class MangareadRepository(
|
||||
?.select("p")
|
||||
?.filterNot { it.ownText().startsWith("A brief description") }
|
||||
?.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 href = a?.relUrl("href").orEmpty().ifEmpty {
|
||||
parseFailed("Link is missing")
|
||||
@@ -138,7 +132,13 @@ class MangareadRepository(
|
||||
name = a!!.ownText(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
source = MangaSource.MANGAREAD
|
||||
uploadDate = parseChapterDate(
|
||||
dateFormat,
|
||||
li.selectFirst("span.chapter-release-date i")?.text()
|
||||
),
|
||||
source = MangaSource.MANGAREAD,
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -151,17 +151,85 @@ class MangareadRepository(
|
||||
?.selectFirst("div.reading-content")
|
||||
?: throw ParseException("Root not found")
|
||||
return root.select("div.page-break").map { div ->
|
||||
val img = div.selectFirst("img")
|
||||
val url = img?.relUrl("src") ?: parseFailed("Page image not found")
|
||||
val img = div.selectFirst("img") ?: parseFailed("Page image not found")
|
||||
val url = img.relUrl("data-src").ifEmpty {
|
||||
img.relUrl("src")
|
||||
}
|
||||
MangaPage(
|
||||
id = generateUid(url),
|
||||
url = url,
|
||||
preview = null,
|
||||
referer = fullUrl,
|
||||
source = MangaSource.MANGAREAD
|
||||
source = MangaSource.MANGAREAD,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long {
|
||||
|
||||
date ?: return 0
|
||||
return when {
|
||||
date.endsWith(" ago", ignoreCase = true) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
// Handle translated 'ago' in Portuguese.
|
||||
date.endsWith(" atrás", ignoreCase = true) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
// Handle translated 'ago' in Turkish.
|
||||
date.endsWith(" önce", ignoreCase = true) -> {
|
||||
parseRelativeDate(date)
|
||||
}
|
||||
// Handle 'yesterday' and 'today', using midnight
|
||||
date.startsWith("year", ignoreCase = true) -> {
|
||||
Calendar.getInstance().apply {
|
||||
add(Calendar.DAY_OF_MONTH, -1) // yesterday
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
date.startsWith("today", ignoreCase = true) -> {
|
||||
Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, 0)
|
||||
set(Calendar.MINUTE, 0)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
}
|
||||
date.contains(Regex("""\d(st|nd|rd|th)""")) -> {
|
||||
// Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it
|
||||
date.split(" ").map {
|
||||
if (it.contains(Regex("""\d\D\D"""))) {
|
||||
it.replace(Regex("""\D"""), "")
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.let { dateFormat.tryParse(it.joinToString(" ")) }
|
||||
}
|
||||
else -> dateFormat.tryParse(date)
|
||||
}
|
||||
}
|
||||
|
||||
// Parses dates in this form:
|
||||
// 21 hours ago
|
||||
private fun parseRelativeDate(date: String): Long {
|
||||
val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0
|
||||
val cal = Calendar.getInstance()
|
||||
|
||||
return when {
|
||||
WordSet("hari", "gün", "jour", "día", "dia", "day").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis
|
||||
WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis
|
||||
WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis
|
||||
WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis
|
||||
WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis
|
||||
WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
private const val PAGE_SIZE = 12
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
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.*
|
||||
|
||||
abstract class NineMangaRepository(
|
||||
@@ -40,7 +41,7 @@ abstract class NineMangaRepository(
|
||||
append("&page=")
|
||||
}
|
||||
!tags.isNullOrEmpty() -> {
|
||||
append("/search/&category_id=")
|
||||
append("/search/?category_id=")
|
||||
for (tag in tags) {
|
||||
append(tag.key)
|
||||
append(',')
|
||||
@@ -99,19 +100,22 @@ abstract class NineMangaRepository(
|
||||
)
|
||||
}.orEmpty(),
|
||||
author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(),
|
||||
state = parseStatus(infoRoot.select("li a.red").text()),
|
||||
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
|
||||
?.html()?.substringAfter("</b>"),
|
||||
chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul")
|
||||
?.select("li")?.asReversed()?.mapIndexed { i, li ->
|
||||
val a = li.selectFirst("a")
|
||||
val href = a?.relUrl("href") ?: parseFailed("Link not found")
|
||||
chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
|
||||
?.asReversed()?.mapIndexed { i, li ->
|
||||
val a = li.selectFirst("a.chapter_list_a")
|
||||
val href = a?.relUrl("href")?.replace("%20", " ") ?: parseFailed("Link not found")
|
||||
MangaChapter(
|
||||
id = generateUid(href),
|
||||
name = a.text(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
branch = null,
|
||||
uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
|
||||
source = source,
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -153,6 +157,50 @@ abstract class NineMangaRepository(
|
||||
} ?: parseFailed("Root not found")
|
||||
}
|
||||
|
||||
private fun parseStatus(status: String) = when {
|
||||
status.contains("Ongoing") -> MangaState.ONGOING
|
||||
status.contains("Completed") -> MangaState.FINISHED
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun parseChapterDateByLang(date: String): Long {
|
||||
val dateWords = date.split(" ")
|
||||
|
||||
if (dateWords.size == 3) {
|
||||
if (dateWords[1].contains(",")) {
|
||||
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).tryParse(date)
|
||||
} else {
|
||||
val timeAgo = Integer.parseInt(dateWords[0])
|
||||
return Calendar.getInstance().apply {
|
||||
when (dateWords[1]) {
|
||||
"minutes" -> Calendar.MINUTE // EN-FR
|
||||
"hours" -> Calendar.HOUR // EN
|
||||
|
||||
"minutos" -> Calendar.MINUTE // ES
|
||||
"horas" -> Calendar.HOUR
|
||||
|
||||
// "minutos" -> Calendar.MINUTE // BR
|
||||
"hora" -> Calendar.HOUR
|
||||
|
||||
"минут" -> Calendar.MINUTE // RU
|
||||
"часа" -> Calendar.HOUR
|
||||
|
||||
"Stunden" -> Calendar.HOUR // DE
|
||||
|
||||
"minuti" -> Calendar.MINUTE // IT
|
||||
"ore" -> Calendar.HOUR
|
||||
|
||||
"heures" -> Calendar.HOUR // FR ("minutes" also French word)
|
||||
else -> null
|
||||
}?.let {
|
||||
add(it, -timeAgo)
|
||||
}
|
||||
}.timeInMillis
|
||||
}
|
||||
}
|
||||
return 0L
|
||||
}
|
||||
|
||||
class English(loaderContext: MangaLoaderContext) : NineMangaRepository(
|
||||
loaderContext,
|
||||
MangaSource.NINEMANGA_EN,
|
||||
|
||||
@@ -5,6 +5,6 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
||||
|
||||
class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
|
||||
|
||||
override val defaultDomain = "readmanga.live"
|
||||
override val defaultDomain = "readmanga.io"
|
||||
override val source = MangaSource.READMANGA_RU
|
||||
}
|
||||
@@ -6,15 +6,20 @@ import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.core.exceptions.ParseException
|
||||
import org.koitharu.kotatsu.core.model.*
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
|
||||
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext),
|
||||
MangaRepositoryAuthProvider {
|
||||
|
||||
override val source = MangaSource.REMANGA
|
||||
|
||||
override val defaultDomain = "remanga.org"
|
||||
override val authUrl: String
|
||||
get() = "https://${getDomain()}/user/login"
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.of(
|
||||
SortOrder.UPDATED,
|
||||
@@ -29,6 +34,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
copyCookies()
|
||||
val domain = getDomain()
|
||||
val urlBuilder = StringBuilder()
|
||||
.append("https://api.")
|
||||
@@ -77,6 +83,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
}
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
copyCookies()
|
||||
val domain = getDomain()
|
||||
val slug = manga.url.find(LAST_URL_PATH_REGEX)
|
||||
?: throw ParseException("Cannot obtain slug from ${manga.url}")
|
||||
@@ -93,6 +100,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
val chapters = loaderContext.httpGet(
|
||||
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId"
|
||||
).parseJson().getJSONArray("content")
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||
return manga.copy(
|
||||
description = content.getString("description"),
|
||||
state = when (content.optJSONObject("status")?.getInt("id")) {
|
||||
@@ -109,20 +117,27 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
},
|
||||
chapters = chapters.mapIndexed { i, jo ->
|
||||
val id = jo.getLong("id")
|
||||
val name = jo.getString("name")
|
||||
val name = jo.getString("name").toTitleCase(Locale.ROOT)
|
||||
val publishers = jo.getJSONArray("publishers")
|
||||
MangaChapter(
|
||||
id = generateUid(id),
|
||||
url = "/api/titles/chapters/$id/",
|
||||
number = chapters.length() - i,
|
||||
name = buildString {
|
||||
append("Том ")
|
||||
append(jo.optString("tome", "0"))
|
||||
append(". ")
|
||||
append("Глава ")
|
||||
append(jo.getString("chapter"))
|
||||
append(jo.optString("chapter", "0"))
|
||||
if (name.isNotEmpty()) {
|
||||
append(" - ")
|
||||
append(name)
|
||||
}
|
||||
},
|
||||
source = MangaSource.REMANGA
|
||||
uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
|
||||
scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
|
||||
source = MangaSource.REMANGA,
|
||||
branch = null,
|
||||
)
|
||||
}.asReversed()
|
||||
)
|
||||
@@ -156,6 +171,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
}
|
||||
}
|
||||
|
||||
override fun isAuthorized(): Boolean {
|
||||
return loaderContext.cookieJar.getCookies(getDomain()).any {
|
||||
it.name == "user"
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyCookies() {
|
||||
val domain = getDomain()
|
||||
loaderContext.cookieJar.copyCookies(domain, "api.$domain")
|
||||
}
|
||||
|
||||
private fun getSortKey(order: SortOrder?) = when (order) {
|
||||
SortOrder.UPDATED -> "-chapter_date"
|
||||
SortOrder.POPULARITY -> "-rating"
|
||||
@@ -167,8 +193,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
|
||||
private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
|
||||
id = generateUid(jo.getLong("id")),
|
||||
url = jo.getString("link"),
|
||||
preview = null,
|
||||
referer = referer,
|
||||
source = source
|
||||
source = source,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -29,7 +29,10 @@ class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loa
|
||||
name = a.text().trim(),
|
||||
number = i + 1,
|
||||
url = href,
|
||||
source = source
|
||||
uploadDate = 0L,
|
||||
source = source,
|
||||
scanlator = null,
|
||||
branch = null,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,18 +2,23 @@ package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.collection.arraySetOf
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.sendBlocking
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.utils.delegates.prefs.*
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
SharedPreferences by prefs {
|
||||
@@ -39,6 +44,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
)
|
||||
|
||||
val isDynamicTheme by BoolPreferenceDelegate(KEY_DYNAMIC_THEME, defaultValue = false)
|
||||
|
||||
val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false)
|
||||
|
||||
val isToolbarHideWhenScrolling by BoolPreferenceDelegate(KEY_HIDE_TOOLBAR, defaultValue = true)
|
||||
@@ -76,6 +83,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
|
||||
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)
|
||||
|
||||
val zoomMode by EnumPreferenceDelegate(
|
||||
@@ -104,6 +113,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
val isSourcesSelected: Boolean
|
||||
get() = KEY_SOURCES_HIDDEN in prefs
|
||||
|
||||
val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
fun getStorageDir(context: Context): File? {
|
||||
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
|
||||
File(it)
|
||||
@@ -121,6 +132,12 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
}
|
||||
}
|
||||
|
||||
fun dateFormat(format: String? = prefs.getString(KEY_DATE_FORMAT, "")): DateFormat =
|
||||
when (format) {
|
||||
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
else -> SimpleDateFormat(format, Locale.getDefault())
|
||||
}
|
||||
|
||||
@Deprecated("Use observe()")
|
||||
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
@@ -132,7 +149,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
|
||||
fun observe() = callbackFlow<String> {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
sendBlocking(key)
|
||||
trySendBlocking(key)
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
@@ -151,7 +168,9 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_LIST_MODE = "list_mode_2"
|
||||
const val KEY_APP_SECTION = "app_section"
|
||||
const val KEY_THEME = "theme"
|
||||
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
const val KEY_DATE_FORMAT = "date_format"
|
||||
const val KEY_HIDE_TOOLBAR = "hide_toolbar"
|
||||
const val KEY_SOURCES_ORDER = "sources_order"
|
||||
const val KEY_SOURCES_HIDDEN = "sources_hidden"
|
||||
@@ -182,6 +201,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_RESTORE = "restore"
|
||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||
const val KEY_PAGES_NUMBERS = "pages_numbers"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
@@ -191,5 +212,12 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
|
||||
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
|
||||
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
|
||||
|
||||
val isDynamicColorAvailable: Boolean
|
||||
get() = DynamicColors.isDynamicColorAvailable() ||
|
||||
(isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||
|
||||
private val isSamsung
|
||||
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,35 @@ sealed class DateTimeAgo : ListModel {
|
||||
}
|
||||
}
|
||||
|
||||
data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
|
||||
class MinutesAgo(val minutes: Int) : DateTimeAgo() {
|
||||
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as MinutesAgo
|
||||
return minutes == other.minutes
|
||||
}
|
||||
|
||||
data class HoursAgo(val hours: Int) : DateTimeAgo() {
|
||||
override fun hashCode(): Int = minutes
|
||||
}
|
||||
|
||||
class HoursAgo(val hours: Int) : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as HoursAgo
|
||||
return hours == other.hours
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = hours
|
||||
}
|
||||
|
||||
object Today : DateTimeAgo() {
|
||||
@@ -38,10 +57,19 @@ sealed class DateTimeAgo : ListModel {
|
||||
}
|
||||
}
|
||||
|
||||
data class DaysAgo(val days: Int) : DateTimeAgo() {
|
||||
class DaysAgo(val days: Int) : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getQuantityString(R.plurals.days_ago, days, days)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as DaysAgo
|
||||
return days == other.days
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = days
|
||||
}
|
||||
|
||||
object LongAgo : DateTimeAgo() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import coil.ImageLoader
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.parser.FaviconMapper
|
||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||
|
||||
val uiModule
|
||||
@@ -15,6 +16,7 @@ val uiModule
|
||||
.componentRegistry(
|
||||
ComponentRegistry.Builder()
|
||||
.add(CbzFetcher())
|
||||
.add(FaviconMapper())
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
@@ -51,12 +51,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
|
||||
chaptersAdapter = ChaptersAdapter(this)
|
||||
selectionDecoration = ChaptersSelectionDecoration(view.context)
|
||||
with(binding.recyclerViewChapters) {
|
||||
addItemDecoration(
|
||||
DividerItemDecoration(
|
||||
view.context,
|
||||
RecyclerView.VERTICAL
|
||||
)
|
||||
)
|
||||
addItemDecoration(MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL))
|
||||
addItemDecoration(selectionDecoration!!)
|
||||
setHasFixedSize(true)
|
||||
adapter = chaptersAdapter
|
||||
@@ -117,7 +112,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
|
||||
}
|
||||
return
|
||||
}
|
||||
if (item.isMissing) {
|
||||
if (item.hasFlag(ChapterListItem.FLAG_MISSING)) {
|
||||
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,15 +6,17 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
@@ -44,7 +46,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
|
||||
private val viewModel by viewModel<DetailsViewModel>(mode = LazyThreadSafetyMode.NONE) {
|
||||
private val viewModel by viewModel<DetailsViewModel> {
|
||||
parametersOf(MangaIntent.from(intent))
|
||||
}
|
||||
|
||||
@@ -85,18 +87,24 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
finishAfterTransition()
|
||||
}
|
||||
else -> {
|
||||
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG)
|
||||
.show()
|
||||
binding.snackbar.show(e.getDisplayMessage(resources))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.toolbar.updatePadding(
|
||||
top = insets.top,
|
||||
binding.snackbar.updatePadding(
|
||||
bottom = insets.bottom
|
||||
)
|
||||
with(binding.toolbar) {
|
||||
updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right
|
||||
)
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top
|
||||
}
|
||||
}
|
||||
if (binding.tabs.parent !is Toolbar) {
|
||||
binding.tabs.updatePadding(
|
||||
left = insets.left,
|
||||
@@ -147,7 +155,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
viewModel.manga.value?.let { m ->
|
||||
AlertDialog.Builder(this)
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_manga)
|
||||
.setMessage(getString(R.string.text_delete_local_manga, m.title))
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
@@ -162,7 +170,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
|
||||
viewModel.manga.value?.let {
|
||||
val chaptersCount = it.chapters?.size ?: 0
|
||||
if (chaptersCount > 5) {
|
||||
AlertDialog.Builder(this)
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setMessage(
|
||||
getString(
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.os.Bundle
|
||||
import android.text.Spanned
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.util.CoilUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -23,13 +26,15 @@ import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.MangaState
|
||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
||||
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.ReaderState
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.utils.FileSizeUtils
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
import kotlin.random.Random
|
||||
|
||||
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
|
||||
View.OnLongClickListener {
|
||||
@@ -39,11 +44,16 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
|
||||
override fun onInflateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?
|
||||
container: ViewGroup?,
|
||||
) = FragmentDetailsBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.textViewAuthor.setOnClickListener(this)
|
||||
binding.buttonFavorite.setOnClickListener(this)
|
||||
binding.buttonRead.setOnClickListener(this)
|
||||
binding.buttonRead.setOnLongClickListener(this)
|
||||
binding.coverCard.setOnClickListener(this)
|
||||
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
|
||||
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
|
||||
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
|
||||
@@ -52,12 +62,8 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
|
||||
private fun onMangaUpdated(manga: Manga) {
|
||||
with(binding) {
|
||||
imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl)
|
||||
.referer(manga.publicUrl)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
.placeholderMemoryCacheKey(CoilUtils.metadata(imageViewCover)?.memoryCacheKey)
|
||||
.lifecycle(viewLifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
// Main
|
||||
loadCover(manga)
|
||||
textViewTitle.text = manga.title
|
||||
textViewSubtitle.textAndVisible = manga.altTitle
|
||||
textViewAuthor.textAndVisible = manga.author
|
||||
@@ -66,6 +72,27 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
textViewDescription.text =
|
||||
manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
|
||||
?: getString(R.string.no_description)
|
||||
when (manga.state) {
|
||||
MangaState.FINISHED -> {
|
||||
textViewState.apply {
|
||||
textAndVisible = resources.getString(R.string.state_finished)
|
||||
drawableStart = ResourcesCompat.getDrawable(resources,
|
||||
R.drawable.ic_state_finished,
|
||||
context.theme)
|
||||
}
|
||||
}
|
||||
MangaState.ONGOING -> {
|
||||
textViewState.apply {
|
||||
textAndVisible = resources.getString(R.string.state_ongoing)
|
||||
drawableStart = ResourcesCompat.getDrawable(resources,
|
||||
R.drawable.ic_state_ongoing,
|
||||
context.theme)
|
||||
}
|
||||
}
|
||||
else -> textViewState.isVisible = false
|
||||
}
|
||||
|
||||
// Info containers
|
||||
if (manga.chapters?.isNotEmpty() == true) {
|
||||
chaptersContainer.isVisible = true
|
||||
textViewChapters.text = manga.chapters.let {
|
||||
@@ -96,10 +123,11 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
} else {
|
||||
sizeContainer.isVisible = false
|
||||
}
|
||||
buttonFavorite.setOnClickListener(this@DetailsFragment)
|
||||
buttonRead.setOnClickListener(this@DetailsFragment)
|
||||
buttonRead.setOnLongClickListener(this@DetailsFragment)
|
||||
|
||||
// Buttons
|
||||
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
|
||||
|
||||
// Chips
|
||||
bindTags(manga)
|
||||
}
|
||||
}
|
||||
@@ -154,6 +182,26 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
|
||||
)
|
||||
}
|
||||
}
|
||||
R.id.textView_author -> {
|
||||
startActivity(
|
||||
SearchActivity.newIntent(
|
||||
context = v.context,
|
||||
source = manga.source,
|
||||
query = manga.author ?: return,
|
||||
)
|
||||
)
|
||||
}
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,4 +252,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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -18,12 +19,13 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.iterator
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import org.koitharu.kotatsu.utils.ext.toTitleCase
|
||||
import java.io.IOException
|
||||
|
||||
class DetailsViewModel(
|
||||
@@ -58,16 +60,6 @@ class DetailsViewModel(
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||
|
||||
private val remoteManga = MutableStateFlow<Manga?>(null)
|
||||
/*private val remoteManga = mangaData.mapLatest {
|
||||
if (it?.source == MangaSource.LOCAL) {
|
||||
runCatching {
|
||||
val m = localMangaRepository.getRemoteManga(it) ?: return@mapLatest null
|
||||
MangaRepository(m.source).getDetails(m)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)*/
|
||||
|
||||
private val chaptersReversed = settings.observe()
|
||||
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
|
||||
@@ -107,10 +99,10 @@ class DetailsViewModel(
|
||||
selectedBranch
|
||||
) { chapters, sourceManga, currentId, newCount, branch ->
|
||||
val sourceChapters = sourceManga?.chapters
|
||||
if (sourceChapters.isNullOrEmpty()) {
|
||||
mapChapters(chapters, currentId, newCount, branch)
|
||||
} else {
|
||||
if (sourceManga?.source != MangaSource.LOCAL && !sourceChapters.isNullOrEmpty()) {
|
||||
mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch)
|
||||
} else {
|
||||
mapChapters(chapters, sourceChapters, currentId, newCount, branch)
|
||||
}
|
||||
}.combine(chaptersReversed) { list, reversed ->
|
||||
if (reversed) list.asReversed() else list
|
||||
@@ -121,23 +113,23 @@ class DetailsViewModel(
|
||||
var manga = mangaDataRepository.resolveIntent(intent)
|
||||
?: throw MangaNotFoundException("Cannot find manga")
|
||||
mangaData.value = manga
|
||||
manga = manga.source.repository.getDetails(manga)
|
||||
manga = MangaRepository(manga.source).getDetails(manga)
|
||||
// find default branch
|
||||
val hist = historyRepository.getOne(manga)
|
||||
selectedBranch.value = if (hist != null) {
|
||||
manga.chapters?.find { it.id == hist.chapterId }?.branch
|
||||
} else {
|
||||
manga.chapters
|
||||
?.groupBy { it.branch }
|
||||
?.maxByOrNull { it.value.size }?.key
|
||||
predictBranch(manga.chapters)
|
||||
}
|
||||
mangaData.value = manga
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
remoteManga.value = runCatching {
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
|
||||
MangaRepository(m.source).getDetails(m)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
localMangaRepository.findSavedManga(manga)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,26 +158,28 @@ class DetailsViewModel(
|
||||
|
||||
private fun mapChapters(
|
||||
chapters: List<MangaChapter>,
|
||||
downloadedChapters: List<MangaChapter>?,
|
||||
currentId: Long?,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val result = ArrayList<ChapterListItem>(chapters.size)
|
||||
val dateFormat = settings.dateFormat()
|
||||
val currentIndex = chapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = chapters.size - newCount
|
||||
val downloadedIds = downloadedChapters?.mapToSet { it.id }
|
||||
for (i in chapters.indices) {
|
||||
val chapter = chapters[i]
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
result += chapter.toListItem(
|
||||
extra = when {
|
||||
i >= firstNewIndex -> ChapterExtra.NEW
|
||||
i == currentIndex -> ChapterExtra.CURRENT
|
||||
i < currentIndex -> ChapterExtra.READ
|
||||
else -> ChapterExtra.UNREAD
|
||||
},
|
||||
isMissing = false
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = false,
|
||||
isDownloaded = downloadedIds?.contains(chapter.id) == true,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
}
|
||||
return result
|
||||
@@ -202,6 +196,7 @@ class DetailsViewModel(
|
||||
val result = ArrayList<ChapterListItem>(sourceChapters.size)
|
||||
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = sourceChapters.size - newCount
|
||||
val dateFormat = settings.dateFormat()
|
||||
for (i in sourceChapters.indices) {
|
||||
val chapter = sourceChapters[i]
|
||||
if (chapter.branch != branch) {
|
||||
@@ -209,30 +204,53 @@ class DetailsViewModel(
|
||||
}
|
||||
val localChapter = chaptersMap.remove(chapter.id)
|
||||
result += localChapter?.toListItem(
|
||||
extra = when {
|
||||
i >= firstNewIndex -> ChapterExtra.NEW
|
||||
i == currentIndex -> ChapterExtra.CURRENT
|
||||
i < currentIndex -> ChapterExtra.READ
|
||||
else -> ChapterExtra.UNREAD
|
||||
},
|
||||
isMissing = false
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
) ?: chapter.toListItem(
|
||||
extra = when {
|
||||
i >= firstNewIndex -> ChapterExtra.NEW
|
||||
i == currentIndex -> ChapterExtra.CURRENT
|
||||
i < currentIndex -> ChapterExtra.READ
|
||||
else -> ChapterExtra.UNREAD
|
||||
},
|
||||
isMissing = true
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = true,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
}
|
||||
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
||||
result.ensureCapacity(result.size + chaptersMap.size)
|
||||
chaptersMap.values.mapTo(result) {
|
||||
it.toListItem(ChapterExtra.UNREAD, false)
|
||||
it.toListItem(
|
||||
isCurrent = false,
|
||||
isUnread = true,
|
||||
isNew = false,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
}
|
||||
result.sortBy { it.chapter.number }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun predictBranch(chapters: List<MangaChapter>?): String? {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val groups = chapters.groupBy { it.branch }
|
||||
for (locale in LocaleListCompat.getAdjustedDefault()) {
|
||||
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
language = locale.getDisplayName(locale).toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
}
|
||||
return groups.maxByOrNull { it.value.size }?.key
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
package org.koitharu.kotatsu.details.ui.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.databinding.ItemChapterBinding
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.utils.ext.textAndVisible
|
||||
|
||||
fun chapterListItemAD(
|
||||
clickListener: OnListItemClickListener<ChapterListItem>,
|
||||
@@ -14,35 +21,40 @@ fun chapterListItemAD(
|
||||
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
itemView.setOnClickListener {
|
||||
clickListener.onItemClick(item, it)
|
||||
}
|
||||
itemView.setOnLongClickListener {
|
||||
clickListener.onItemLongClick(item, it)
|
||||
val eventListener = object : View.OnClickListener, View.OnLongClickListener {
|
||||
override fun onClick(v: View) = clickListener.onItemClick(item, v)
|
||||
override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v)
|
||||
}
|
||||
|
||||
bind { payload ->
|
||||
itemView.setOnClickListener(eventListener)
|
||||
itemView.setOnLongClickListener(eventListener)
|
||||
|
||||
bind { payloads ->
|
||||
if (payloads.isEmpty()) {
|
||||
binding.textViewTitle.text = item.chapter.name
|
||||
binding.textViewNumber.text = item.chapter.number.toString()
|
||||
when (item.extra) {
|
||||
ChapterExtra.UNREAD -> {
|
||||
binding.textViewDescription.textAndVisible = item.description()
|
||||
}
|
||||
when (item.status) {
|
||||
FLAG_UNREAD -> {
|
||||
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
|
||||
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse))
|
||||
}
|
||||
ChapterExtra.READ -> {
|
||||
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline)
|
||||
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
|
||||
}
|
||||
ChapterExtra.CURRENT -> {
|
||||
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline_accent)
|
||||
binding.textViewNumber.setTextColor(context.getThemeColor(androidx.appcompat.R.attr.colorAccent))
|
||||
}
|
||||
ChapterExtra.NEW -> {
|
||||
FLAG_CURRENT -> {
|
||||
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent)
|
||||
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
|
||||
}
|
||||
}
|
||||
binding.textViewTitle.alpha = if (item.isMissing) 0.3f else 1f
|
||||
binding.textViewNumber.alpha = if (item.isMissing) 0.3f else 1f
|
||||
else -> {
|
||||
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline)
|
||||
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
|
||||
}
|
||||
}
|
||||
val isMissing = item.hasFlag(FLAG_MISSING)
|
||||
binding.textViewTitle.alpha = if (isMissing) 0.3f else 1f
|
||||
binding.textViewDescription.alpha = if (isMissing) 0.3f else 1f
|
||||
binding.textViewNumber.alpha = if (isMissing) 0.3f else 1f
|
||||
|
||||
binding.imageViewDownloaded.isVisible = item.hasFlag(FLAG_DOWNLOADED)
|
||||
binding.imageViewNew.isVisible = item.hasFlag(FLAG_NEW)
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,6 @@ class ChaptersAdapter(
|
||||
return items[position].chapter.id
|
||||
}
|
||||
|
||||
fun setItems(newItems: List<ChapterListItem>, callback: Runnable) {
|
||||
differ.submitList(newItems, callback)
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {
|
||||
@@ -37,8 +33,8 @@ class ChaptersAdapter(
|
||||
}
|
||||
|
||||
override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? {
|
||||
if (oldItem.extra != newItem.extra && oldItem.chapter == newItem.chapter) {
|
||||
return newItem.extra
|
||||
if (oldItem.flags != newItem.flags && oldItem.chapter == newItem.chapter) {
|
||||
return newItem.flags
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -4,20 +4,14 @@ import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||
|
||||
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val icon = ContextCompat.getDrawable(context, R.drawable.ic_check)
|
||||
private val padding = context.resources.resolveDp(16)
|
||||
private val bounds = Rect()
|
||||
private val selection = ArraySet<Long>()
|
||||
private val selection = HashSet<Long>()
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
init {
|
||||
@@ -54,7 +48,6 @@ class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoratio
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
icon ?: return
|
||||
canvas.save()
|
||||
if (parent.clipToPadding) {
|
||||
canvas.clipRect(
|
||||
@@ -73,36 +66,4 @@ class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoratio
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
icon ?: 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
|
||||
}
|
||||
|
||||
for (child in parent.children) {
|
||||
val itemId = parent.getChildItemId(child)
|
||||
if (itemId in selection) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
bounds.offset(child.translationX.toInt(), child.translationY.toInt())
|
||||
val hh = (bounds.height() - icon.intrinsicHeight) / 2
|
||||
val top: Int = bounds.top + hh
|
||||
val bottom: Int = bounds.bottom - hh
|
||||
icon.setBounds(right - icon.intrinsicWidth - padding, top, right - padding, bottom)
|
||||
icon.draw(canvas)
|
||||
}
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,57 @@
|
||||
package org.koitharu.kotatsu.details.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
|
||||
data class ChapterListItem(
|
||||
class ChapterListItem(
|
||||
val chapter: MangaChapter,
|
||||
val extra: ChapterExtra,
|
||||
val isMissing: Boolean,
|
||||
)
|
||||
val flags: Int,
|
||||
val uploadDate: String?,
|
||||
) {
|
||||
|
||||
val status: Int
|
||||
get() = flags and MASK_STATUS
|
||||
|
||||
fun hasFlag(flag: Int): Boolean {
|
||||
return (flags and flag) == flag
|
||||
}
|
||||
|
||||
fun description(): CharSequence? {
|
||||
val scanlator = chapter.scanlator?.takeUnless { it.isBlank() }
|
||||
return when {
|
||||
uploadDate != null && scanlator != null -> "$uploadDate • $scanlator"
|
||||
scanlator != null -> scanlator
|
||||
else -> uploadDate
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ChapterListItem
|
||||
|
||||
if (chapter != other.chapter) return false
|
||||
if (flags != other.flags) return false
|
||||
if (uploadDate != other.uploadDate) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = chapter.hashCode()
|
||||
result = 31 * result + flags
|
||||
result = 31 * result + uploadDate.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val FLAG_UNREAD = 2
|
||||
const val FLAG_CURRENT = 4
|
||||
const val FLAG_NEW = 8
|
||||
const val FLAG_MISSING = 16
|
||||
const val FLAG_DOWNLOADED = 32
|
||||
const val MASK_STATUS = FLAG_UNREAD or FLAG_CURRENT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
package org.koitharu.kotatsu.details.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.core.model.MangaChapter
|
||||
import org.koitharu.kotatsu.history.domain.ChapterExtra
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
|
||||
import java.text.DateFormat
|
||||
|
||||
fun MangaChapter.toListItem(
|
||||
extra: ChapterExtra,
|
||||
isCurrent: Boolean,
|
||||
isUnread: Boolean,
|
||||
isNew: Boolean,
|
||||
isMissing: Boolean,
|
||||
) = ChapterListItem(
|
||||
isDownloaded: Boolean,
|
||||
dateFormat: DateFormat,
|
||||
): ChapterListItem {
|
||||
var flags = 0
|
||||
if (isCurrent) flags = flags or FLAG_CURRENT
|
||||
if (isUnread) flags = flags or FLAG_UNREAD
|
||||
if (isNew) flags = flags or FLAG_NEW
|
||||
if (isMissing) flags = flags or FLAG_MISSING
|
||||
if (isDownloaded) flags = flags or FLAG_DOWNLOADED
|
||||
return ChapterListItem(
|
||||
chapter = this,
|
||||
extra = extra,
|
||||
isMissing = isMissing,
|
||||
flags = flags,
|
||||
uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null
|
||||
)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ class DownloadManager(
|
||||
while (true) {
|
||||
try {
|
||||
val response = call.clone().await()
|
||||
withContext(Dispatchers.IO) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
file.outputStream().use { out ->
|
||||
checkNotNull(response.body).byteStream().copyTo(out)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.download.ui
|
||||
|
||||
import androidx.core.view.isVisible
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -10,12 +11,11 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.utils.JobStateFlow
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
|
||||
import org.koitharu.kotatsu.utils.ext.*
|
||||
|
||||
fun downloadItemAD(
|
||||
scope: CoroutineScope,
|
||||
coil: ImageLoader,
|
||||
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
|
||||
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
@@ -24,11 +24,16 @@ fun downloadItemAD(
|
||||
|
||||
bind {
|
||||
job?.cancel()
|
||||
job = item.onEach { state ->
|
||||
job = item.onFirst { state ->
|
||||
binding.imageViewCover.newImageRequest(state.manga.coverUrl)
|
||||
.referer(state.manga.publicUrl)
|
||||
.placeholder(state.cover)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
.error(R.drawable.ic_placeholder)
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
}.onEach { state ->
|
||||
binding.textViewTitle.text = state.manga.title
|
||||
binding.imageViewCover.setImageDrawable(
|
||||
state.cover ?: getDrawable(R.drawable.ic_placeholder)
|
||||
)
|
||||
when (state) {
|
||||
is DownloadManager.State.Cancelling -> {
|
||||
binding.textViewStatus.setText(R.string.cancelling_)
|
||||
|
||||
@@ -3,14 +3,17 @@ package org.koitharu.kotatsu.download.ui
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
@@ -22,7 +25,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val adapter = DownloadsAdapter(lifecycleScope)
|
||||
val adapter = DownloadsAdapter(lifecycleScope, get())
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.adapter = adapter
|
||||
LifecycleAwareServiceConnection.bindService(
|
||||
@@ -44,11 +47,15 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
|
||||
right = insets.right,
|
||||
bottom = insets.bottom
|
||||
)
|
||||
binding.toolbar.updatePadding(
|
||||
with(binding.toolbar) {
|
||||
updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
top = insets.top
|
||||
right = insets.right
|
||||
)
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.download.ui
|
||||
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
@@ -8,10 +9,11 @@ import org.koitharu.kotatsu.utils.JobStateFlow
|
||||
|
||||
class DownloadsAdapter(
|
||||
scope: CoroutineScope,
|
||||
coil: ImageLoader,
|
||||
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(downloadItemAD(scope))
|
||||
delegatesManager.addDelegate(downloadItemAD(scope, coil))
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -79,6 +78,11 @@ class DownloadService : BaseService() {
|
||||
return binder ?: DownloadBinder(this).also { binder = it }
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
binder = null
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterReceiver(controlReceiver)
|
||||
binder = null
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = "favourite_categories")
|
||||
data class FavouriteCategoryEntity(
|
||||
class FavouriteCategoryEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "category_id") val categoryId: Int,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
|
||||
@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
)
|
||||
]
|
||||
)
|
||||
data class FavouriteEntity(
|
||||
class FavouriteEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
|
||||
data class FavouriteManga(
|
||||
class FavouriteManga(
|
||||
@Embedded val favourite: FavouriteEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
|
||||
@@ -30,9 +30,7 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
|
||||
|
||||
override val recycledViewPool = RecyclerView.RecycledViewPool()
|
||||
|
||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>(
|
||||
mode = LazyThreadSafetyMode.NONE
|
||||
)
|
||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
|
||||
CategoriesEditDelegate(requireContext(), this)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package org.koitharu.kotatsu.favourites.ui.categories
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
@@ -12,9 +10,9 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -24,15 +22,14 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.SortOrder
|
||||
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
||||
|
||||
class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
OnListItemClickListener<FavouriteCategory>,
|
||||
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
|
||||
|
||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>(
|
||||
mode = LazyThreadSafetyMode.NONE
|
||||
)
|
||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||
|
||||
private lateinit var adapter: CategoriesAdapter
|
||||
private lateinit var reorderHelper: ItemTouchHelper
|
||||
@@ -42,10 +39,9 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
binding.fabAdd.imageTintList = ColorStateList.valueOf(Color.WHITE)
|
||||
adapter = CategoriesAdapter(this)
|
||||
editDelegate = CategoriesEditDelegate(this, this)
|
||||
binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
|
||||
binding.recyclerView.addItemDecoration(MaterialDividerItemDecoration(this, RecyclerView.VERTICAL))
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.fabAdd.setOnClickListener(this)
|
||||
@@ -95,13 +91,17 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
binding.recyclerView.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom
|
||||
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight()
|
||||
)
|
||||
binding.toolbar.updatePadding(
|
||||
with(binding.toolbar) {
|
||||
updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
top = insets.top
|
||||
right = insets.right
|
||||
)
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
|
||||
|
||||
@@ -2,7 +2,8 @@ package org.koitharu.kotatsu.favourites.ui.categories
|
||||
|
||||
import android.content.Context
|
||||
import android.text.InputType
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import android.widget.Toast
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
@@ -13,7 +14,7 @@ class CategoriesEditDelegate(
|
||||
) {
|
||||
|
||||
fun deleteCategory(category: FavouriteCategory) {
|
||||
AlertDialog.Builder(context)
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setMessage(context.getString(R.string.category_delete_confirm, category.title))
|
||||
.setTitle(R.string.remove_category)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
@@ -32,7 +33,12 @@ class CategoriesEditDelegate(
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.setMaxLength(MAX_TITLE_LENGTH, false)
|
||||
.setPositiveButton(R.string.rename) { _, 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()
|
||||
.show()
|
||||
}
|
||||
@@ -45,7 +51,12 @@ class CategoriesEditDelegate(
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.setMaxLength(MAX_TITLE_LENGTH, false)
|
||||
.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()
|
||||
.show()
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
|
||||
OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback,
|
||||
View.OnClickListener {
|
||||
|
||||
private val viewModel by viewModel<MangaCategoriesViewModel>(mode = LazyThreadSafetyMode.NONE) {
|
||||
private val viewModel by viewModel<MangaCategoriesViewModel> {
|
||||
parametersOf(requireNotNull(arguments?.getParcelable<Manga>(MangaIntent.KEY_MANGA)))
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
|
||||
|
||||
override fun onInflateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?
|
||||
container: ViewGroup?,
|
||||
) = DialogFavoriteCategoriesBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
|
||||
class FavouritesListFragment : MangaListFragment() {
|
||||
|
||||
override val viewModel by viewModel<FavouritesListViewModel>(mode = LazyThreadSafetyMode.NONE) {
|
||||
override val viewModel by viewModel<FavouritesListViewModel> {
|
||||
parametersOf(categoryId)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
|
||||
val historyModule
|
||||
get() = module {
|
||||
|
||||
single { HistoryRepository(get(), get()) }
|
||||
single { HistoryRepository(get(), get(), get()) }
|
||||
viewModel { HistoryListViewModel(get(), get(), get()) }
|
||||
}
|
||||
@@ -18,14 +18,14 @@ import java.util.*
|
||||
)
|
||||
]
|
||||
)
|
||||
data class HistoryEntity(
|
||||
class HistoryEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
@ColumnInfo(name = "manga_id") val mangaId: Long,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
|
||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||
@ColumnInfo(name = "page") val page: Int,
|
||||
@ColumnInfo(name = "scroll") val scroll: Float
|
||||
@ColumnInfo(name = "scroll") val scroll: Float,
|
||||
) {
|
||||
|
||||
fun toMangaHistory() = MangaHistory(
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
|
||||
data class HistoryWithManga(
|
||||
class HistoryWithManga(
|
||||
@Embedded val history: HistoryEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.koitharu.kotatsu.history.domain
|
||||
|
||||
enum class ChapterExtra {
|
||||
|
||||
READ, CURRENT, UNREAD, NEW
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.model.Manga
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.model.MangaTag
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
@@ -17,6 +18,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
class HistoryRepository(
|
||||
private val db: MangaDatabase,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
|
||||
@@ -46,6 +48,9 @@ class HistoryRepository(
|
||||
}
|
||||
|
||||
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)
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -15,7 +15,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
|
||||
|
||||
class HistoryListFragment : MangaListFragment() {
|
||||
|
||||
override val viewModel by viewModel<HistoryListViewModel>(mode = LazyThreadSafetyMode.NONE)
|
||||
override val viewModel by viewModel<HistoryListViewModel>()
|
||||
override val isSwipeRefreshEnabled = false
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -42,7 +42,7 @@ class HistoryListFragment : MangaListFragment() {
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_clear_history -> {
|
||||
AlertDialog.Builder(context ?: return false)
|
||||
MaterialAlertDialogBuilder(context ?: return false)
|
||||
.setTitle(R.string.clear_history)
|
||||
.setMessage(R.string.text_clear_history_prompt)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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 android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.view.updateLayoutParams
|
||||
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) {
|
||||
with(binding.toolbar) {
|
||||
updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right
|
||||
)
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user