Merge branch 'devel' into feature/suggestions

pull/100/head
Koitharu 4 years ago
commit 920ea6959c
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

1
.gitignore vendored

@ -3,6 +3,7 @@
/local.properties /local.properties
/.idea/caches /.idea/caches
/.idea/libraries /.idea/libraries
/.idea/dictionaries
/.idea/modules.xml /.idea/modules.xml
/.idea/misc.xml /.idea/misc.xml
/.idea/workspace.xml /.idea/workspace.xml

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

@ -14,7 +14,6 @@
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
</set> </set>
</option> </option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

@ -6,15 +6,16 @@ plugins {
} }
android { android {
compileSdkVersion 30 compileSdkVersion 31
buildToolsVersion '30.0.3' buildToolsVersion '30.0.3'
namespace 'org.koitharu.kotatsu'
defaultConfig { defaultConfig {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 31
versionCode 369 versionCode 380
versionName '2.0-b1' versionName '2.1.4'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -24,10 +25,6 @@ android {
} }
} }
} }
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix = '.debug' applicationIdSuffix = '.debug'
@ -45,76 +42,79 @@ android {
sourceSets { sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
} }
lintOptions { compileOptions {
disable 'MissingTranslation' sourceCompatibility JavaVersion.VERSION_1_8
abortOnError false targetCompatibility JavaVersion.VERSION_1_8
}
testOptions {
unitTests.includeAndroidResources = true
unitTests.returnDefaultValues = false
}
} }
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [ freeCompilerArgs += [
'-Xjvm-default=enable',
'-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-Xopt-in=kotlinx.coroutines.FlowPreview', '-Xopt-in=kotlinx.coroutines.FlowPreview',
'-Xopt-in=org.koin.core.component.KoinApiExtension' '-Xopt-in=kotlin.contracts.ExperimentalContracts',
] ]
} }
lint {
abortOnError false
disable 'MissingTranslation'
}
testOptions {
unitTests.includeAndroidResources = true
unitTests.returnDefaultValues = false
}
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.activity:activity-ktx:1.3.1' implementation 'androidx.activity:activity-ktx:1.4.0'
implementation 'androidx.fragment:fragment-ktx:1.3.6' implementation 'androidx.fragment:fragment-ktx:1.4.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-service:2.3.1' implementation 'androidx.lifecycle:lifecycle-service:2.4.1'
implementation 'androidx.lifecycle:lifecycle-process:2.3.1' implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.1.1' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.6.0' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'com.google.android.material:material:1.4.0' implementation 'com.google.android.material:material:1.6.0-alpha02'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
implementation 'androidx.room:room-runtime:2.3.0' implementation 'androidx.room:room-runtime:2.4.1'
implementation 'androidx.room:room-ktx:2.3.0' implementation 'androidx.room:room-ktx:2.4.1'
kapt 'androidx.room:room-compiler:2.3.0' kapt 'androidx.room:room-compiler:2.4.1'
implementation 'com.squareup.okhttp3:okhttp:4.9.1' implementation 'com.squareup.okhttp3:okhttp:4.9.3'
implementation 'com.squareup.okio:okio:2.10.0' implementation 'com.squareup.okio:okio:3.0.0'
implementation 'org.jsoup:jsoup:1.14.2' implementation 'org.jsoup:jsoup:1.14.3'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1'
implementation 'io.insert-koin:koin-android:3.1.2' implementation 'io.insert-koin:koin-android:3.1.5'
implementation 'io.coil-kt:coil-base:1.3.2' implementation 'io.coil-kt:coil-base:1.4.0'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.3' implementation 'com.github.solkin:disk-lru-cache:1.4'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'com.google.truth:truth:1.1.3' testImplementation 'com.google.truth:truth:1.1.3'
testImplementation 'org.json:json:20210307' testImplementation 'org.json:json:20211205'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.2' testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0' androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0' androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'androidx.room:room-testing:2.3.0' androidTestImplementation 'androidx.room:room-testing:2.4.1'
androidTestImplementation 'com.google.truth:truth:1.1.3' androidTestImplementation 'com.google.truth:truth:1.1.3'
} }

@ -1,3 +1,4 @@
-optimizationpasses 8
-dontobfuscate -dontobfuscate
-assumenosideeffects class kotlin.jvm.internal.Intrinsics { -assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void checkExpressionValueIsNotNull(...); public static void checkExpressionValueIsNotNull(...);
@ -5,9 +6,8 @@
public static void checkReturnedValueIsNotNull(...); public static void checkReturnedValueIsNotNull(...);
public static void checkFieldIsNotNull(...); public static void checkFieldIsNotNull(...);
public static void checkParameterIsNotNull(...); public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...);
} }
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; } -keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository {
public <init>(...);
}
-dontwarn okhttp3.internal.platform.ConscryptPlatform -dontwarn okhttp3.internal.platform.ConscryptPlatform

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

@ -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"?> <?xml version="1.0" encoding="utf-8"?>
<manifest <manifest
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools">
package="org.koitharu.kotatsu">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@ -20,8 +19,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/Theme.Kotatsu"
android:usesCleartextTraffic="true" android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<activity <activity
android:name="org.koitharu.kotatsu.main.ui.MainActivity" android:name="org.koitharu.kotatsu.main.ui.MainActivity"
@ -32,7 +31,7 @@
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.app.default_searchable" android:name="android.app.default_searchable"
android:value=".ui.search.SearchActivity" /> android:value="org.koitharu.kotatsu.ui.search.SearchActivity" />
</activity> </activity>
<activity <activity
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity" android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"
@ -94,11 +93,12 @@
android:noHistory="true" android:noHistory="true"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".settings.protect.ProtectSetupActivity" android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity" android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads" /> android:label="@string/downloads" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"

@ -3,6 +3,7 @@ package org.koitharu.kotatsu
import android.app.Application import android.app.Application
import android.os.StrictMode import android.os.StrictMode
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.strictmode.FragmentStrictMode
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
@ -88,5 +89,12 @@ class KotatsuApp : Application() {
.penaltyLog() .penaltyLog()
.build() .build()
) )
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
.detectWrongFragmentContainer()
.detectRetainInstanceUsage()
.detectSetUserVisibleHint()
.build()
} }
} }

@ -5,7 +5,7 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
data class MangaIntent( class MangaIntent(
val manga: Manga?, val manga: Manga?,
val mangaId: Long, val mangaId: Long,
val uri: Uri? val uri: Uri?

@ -1,15 +1,21 @@
package org.koitharu.kotatsu.base.domain package org.koitharu.kotatsu.base.domain
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koitharu.kotatsu.core.exceptions.GraphQLException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.parseJson
open class MangaLoaderContext( open class MangaLoaderContext(
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
val cookieJar: CookieJar val cookieJar: CookieJar,
) : KoinComponent { ) : KoinComponent {
suspend fun httpGet(url: String, headers: Headers? = null): Response { suspend fun httpGet(url: String, headers: Headers? = null): Response {
@ -24,7 +30,7 @@ open class MangaLoaderContext(
suspend fun httpPost( suspend fun httpPost(
url: String, url: String,
form: Map<String, String> form: Map<String, String>,
): Response { ): Response {
val body = FormBody.Builder() val body = FormBody.Builder()
form.forEach { (k, v) -> form.forEach { (k, v) ->
@ -38,7 +44,7 @@ open class MangaLoaderContext(
suspend fun httpPost( suspend fun httpPost(
url: String, url: String,
payload: String payload: String,
): Response { ): Response {
val body = FormBody.Builder() val body = FormBody.Builder()
payload.split('&').forEach { payload.split('&').forEach {
@ -55,10 +61,24 @@ open class MangaLoaderContext(
return okHttp.newCall(request.build()).await() return okHttp.newCall(request.build()).await()
} }
open fun getSettings(source: MangaSource) = SourceSettings(get(), source) suspend fun graphQLQuery(endpoint: String, query: String): JSONObject {
val body = JSONObject()
private companion object { body.put("operationName", null)
body.put("variables", JSONObject())
private const val SCHEME_HTTP = "http" body.put("query", "{${query}}")
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(endpoint)
val json = okHttp.newCall(request.build()).await().parseJson()
json.optJSONArray("errors")?.let {
if (it.length() != 0) {
throw GraphQLException(it)
} }
} }
return json
}
open fun getSettings(source: MangaSource) = SourceSettings(get(), source)
}

@ -8,7 +8,6 @@ object MangaProviderFactory {
fun getSources(settings: AppSettings, includeHidden: Boolean): List<MangaSource> { fun getSources(settings: AppSettings, includeHidden: Boolean): List<MangaSource> {
val list = MangaSource.values().toList() - MangaSource.LOCAL val list = MangaSource.values().toList() - MangaSource.LOCAL
val order = settings.sourcesOrder val order = settings.sourcesOrder
val hidden = settings.hiddenSources
val sorted = list.sortedBy { x -> val sorted = list.sortedBy { x ->
val e = order.indexOf(x.ordinal) val e = order.indexOf(x.ordinal)
if (e == -1) order.size + x.ordinal else e if (e == -1) order.size + x.ordinal else e
@ -16,6 +15,7 @@ object MangaProviderFactory {
return if (includeHidden) { return if (includeHidden) {
sorted sorted
} else { } else {
val hidden = settings.hiddenSources
sorted.filterNot { x -> sorted.filterNot { x ->
x.name in hidden x.name in hidden
} }

@ -3,7 +3,9 @@ package org.koitharu.kotatsu.base.domain
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.util.Size import android.util.Size
import androidx.annotation.WorkerThread import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -11,6 +13,7 @@ import org.koin.core.component.get
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.ext.await import org.koitharu.kotatsu.utils.ext.await
import org.koitharu.kotatsu.utils.ext.medianOrNull import org.koitharu.kotatsu.utils.ext.medianOrNull
@ -23,19 +26,19 @@ object MangaUtils : KoinComponent {
* Automatic determine type of manga by page size * Automatic determine type of manga by page size
* @return ReaderMode.WEBTOON if page is wide * @return ReaderMode.WEBTOON if page is wide
*/ */
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? { suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? {
try { try {
val page = pages.medianOrNull() ?: return null val page = pages.medianOrNull() ?: return null
val url = page.source.repository.getPageUrl(page) val url = MangaRepository(page.source).getPageUrl(page)
val uri = Uri.parse(url) val uri = Uri.parse(url)
val size = if (uri.scheme == "cbz") { val size = if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart) val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment) val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use { zip.getInputStream(entry).use {
getBitmapSize(it) getBitmapSize(it)
} }
}
} else { } else {
val client = get<OkHttpClient>() val client = get<OkHttpClient>()
val request = Request.Builder() val request = Request.Builder()
@ -45,9 +48,11 @@ object MangaUtils : KoinComponent {
.cacheControl(CacheUtils.CONTROL_DISABLED) .cacheControl(CacheUtils.CONTROL_DISABLED)
.build() .build()
client.newCall(request).await().use { client.newCall(request).await().use {
withContext(Dispatchers.IO) {
getBitmapSize(it.body?.byteStream()) getBitmapSize(it.body?.byteStream())
} }
} }
}
return size.width * 2 < size.height return size.width * 2 < size.height
} catch (e: Exception) { } catch (e: Exception) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {

@ -5,9 +5,9 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder
abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() { abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
@ -20,7 +20,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
val inflater = activity?.layoutInflater ?: LayoutInflater.from(requireContext()) val inflater = activity?.layoutInflater ?: LayoutInflater.from(requireContext())
val binding = onInflateView(inflater, null) val binding = onInflateView(inflater, null)
viewBinding = binding viewBinding = binding
return AlertDialog.Builder(requireContext(), theme) return MaterialAlertDialogBuilder(requireContext(), theme)
.setView(binding.root) .setView(binding.root)
.also(::onBuildDialog) .also(::onBuildDialog)
.create() .create()
@ -38,7 +38,7 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
super.onDestroyView() super.onDestroyView()
} }
open fun onBuildDialog(builder: AlertDialog.Builder) = Unit open fun onBuildDialog(builder: MaterialAlertDialogBuilder) = Unit
protected fun bindingOrNull(): B? = viewBinding protected fun bindingOrNull(): B? = viewBinding

@ -35,8 +35,9 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
private var lastInsets: Insets = Insets.NONE private var lastInsets: Insets = Insets.NONE
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
if (get<AppSettings>().isAmoledTheme) { when {
setTheme(R.style.AppTheme_AMOLED) get<AppSettings>().isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED)
get<AppSettings>().isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet)
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)

@ -6,6 +6,7 @@ import android.view.LayoutInflater
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
class CheckBoxAlertDialog private constructor(private val delegate: AlertDialog) : 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 binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
private val delegate = AlertDialog.Builder(context) private val delegate = MaterialAlertDialogBuilder(context)
.setView(binding.root) .setView(binding.root)
fun setTitle(@StringRes titleResId: Int): Builder { 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.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.inflate import org.koitharu.kotatsu.utils.ext.inflate
import org.koitharu.kotatsu.utils.ext.longHashCode
import java.io.File import java.io.File
class StorageSelectDialog private constructor(private val delegate: AlertDialog) : 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) { class Builder(context: Context, defaultValue: File?, listener: OnStorageSelectListener) {
private val adapter = VolumesAdapter(context) private val adapter = VolumesAdapter(context)
private val delegate = AlertDialog.Builder(context) private val delegate = MaterialAlertDialogBuilder(context)
init { init {
if (adapter.isEmpty) { if (adapter.isEmpty) {
delegate.setMessage(R.string.cannot_find_available_storage) delegate.setMessage(R.string.cannot_find_available_storage)
} else { } else {
val checked = adapter.volumes.indexOfFirst { adapter.selectedItemPosition = adapter.volumes.indexOfFirst {
it.first.canonicalPath == defaultValue?.canonicalPath it.first.canonicalPath == defaultValue?.canonicalPath
} }
delegate.setSingleChoiceItems(adapter, checked) { d, i -> delegate.setAdapter(adapter) { d, i ->
listener.onStorageSelected(adapter.getItem(i).first) listener.onStorageSelected(adapter.getItem(i).first)
d.dismiss() d.dismiss()
} }
@ -60,12 +59,16 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
private class VolumesAdapter(context: Context) : BaseAdapter() { private class VolumesAdapter(context: Context) : BaseAdapter() {
var selectedItemPosition: Int = -1
val volumes = getAvailableVolumes(context) val volumes = getAvailableVolumes(context)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: parent.inflate(R.layout.item_storage) 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 item = volumes[position]
val binding = ItemStorageBinding.bind(view) binding.imageViewIndicator.isChecked = selectedItemPosition == position
binding.textViewTitle.text = item.second binding.textViewTitle.text = item.second
binding.textViewSubtitle.text = item.first.path binding.textViewSubtitle.text = item.first.path
return view 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 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 getCount() = volumes.size
} override fun hasStableIds() = true
fun interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
private companion object { private fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
fun getAvailableVolumes(context: Context): List<Pair<File, String>> {
return LocalMangaRepository.getAvailableStorageDirs(context).map { return LocalMangaRepository.getAvailableStorageDirs(context).map {
it to it.getStorageName(context) it to it.getStorageName(context)
} }
} }
} }
fun interface OnStorageSelectListener {
fun onStorageSelected(file: File)
}
} }

@ -10,7 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogInputBinding import org.koitharu.kotatsu.databinding.DialogInputBinding
class TextInputDialog private constructor( class TextInputDialog private constructor(
private val delegate: AlertDialog private val delegate: AlertDialog,
) : DialogInterface by delegate { ) : DialogInterface by delegate {
fun show() = delegate.show() fun show() = delegate.show()
@ -19,7 +19,7 @@ class TextInputDialog private constructor(
private val binding = DialogInputBinding.inflate(LayoutInflater.from(context)) private val binding = DialogInputBinding.inflate(LayoutInflater.from(context))
private val delegate = AlertDialog.Builder(context) private val delegate = MaterialAlertDialogBuilder(context)
.setView(binding.root) .setView(binding.root)
fun setTitle(@StringRes titleResId: Int): Builder { fun setTitle(@StringRes titleResId: Int): Builder {
@ -33,7 +33,7 @@ class TextInputDialog private constructor(
} }
fun setHint(@StringRes hintResId: Int): Builder { fun setHint(@StringRes hintResId: Int): Builder {
binding.inputLayout.hint = binding.root.context.getString(hintResId) binding.inputEdit.hint = binding.root.context.getString(hintResId)
return this return this
} }
@ -64,7 +64,7 @@ class TextInputDialog private constructor(
listener: (DialogInterface, String) -> Unit listener: (DialogInterface, String) -> Unit
): Builder { ): Builder {
delegate.setPositiveButton(textId) { dialog, _ -> delegate.setPositiveButton(textId) { dialog, _ ->
listener(dialog, binding.inputEdit.text.toString().orEmpty()) listener(dialog, binding.inputEdit.text?.toString().orEmpty())
} }
return this return this
} }

@ -1,26 +0,0 @@
package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.*
@Deprecated("")
class AdapterUpdater<T>(oldList: List<T>, newList: List<T>, getId: (T) -> Long) {
private val diff = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
getId(oldList[oldItemPosition]) == getId(newList[newItemPosition])
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
Objects.equals(oldList[oldItemPosition], newList[newItemPosition])
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
})
operator fun invoke(adapter: RecyclerView.Adapter<*>) {
diff.dispatchUpdatesTo(adapter)
}
}

@ -1,28 +0,0 @@
package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.koin.core.component.KoinComponent
@Deprecated("")
abstract class BaseViewHolder<T, E, B : ViewBinding> protected constructor(val binding: B) :
RecyclerView.ViewHolder(binding.root), KoinComponent {
var boundData: T? = null
private set
val context get() = itemView.context!!
fun bind(data: T, extra: E) {
boundData = data
onBind(data, extra)
}
fun requireData(): T {
return boundData ?: throw IllegalStateException("Calling requireData() before bind()")
}
open fun onRecycled() = Unit
abstract fun onBind(data: T, extra: E)
}

@ -0,0 +1,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 package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.os.Parcelable.Creator
import android.util.AttributeSet import android.util.AttributeSet
import android.widget.Checkable import android.widget.Checkable
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import androidx.core.os.ParcelCompat
class CheckableImageView @JvmOverloads constructor( 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 { ) : AppCompatImageView(context, attrs, defStyleAttr), Checkable {
private var isCheckedInternal = false private var isCheckedInternal = false
@ -14,20 +21,6 @@ class CheckableImageView @JvmOverloads constructor(
var onCheckedChangeListener: OnCheckedChangeListener? = null 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 isChecked() = isCheckedInternal
override fun toggle() { override fun toggle() {
@ -49,18 +42,54 @@ class CheckableImageView @JvmOverloads constructor(
override fun onCreateDrawableState(extraSpace: Int): IntArray { override fun onCreateDrawableState(extraSpace: Int): IntArray {
val state = super.onCreateDrawableState(extraSpace + 1) val state = super.onCreateDrawableState(extraSpace + 1)
if (isCheckedInternal) { if (isCheckedInternal) {
mergeDrawableStates(state, CHECKED_STATE_SET) mergeDrawableStates(state, intArrayOf(android.R.attr.state_checked))
} }
return state 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 interface OnCheckedChangeListener {
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
} }
private companion object { private class SavedState : BaseSavedState {
val isChecked: Boolean
constructor(superState: Parcelable, checked: Boolean) : super(superState) {
isChecked = checked
}
constructor(source: Parcel) : super(source) {
isChecked = ParcelCompat.readBoolean(source)
}
private val CHECKED_STATE_SET = intArrayOf(android.R.attr.state_checked) 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.util.AttributeSet
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipDrawable
@ -77,7 +76,6 @@ class ChipsView @JvmOverloads constructor(
val chip = Chip(context) val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.setChipDrawable(drawable) chip.setChipDrawable(drawable)
chip.setTextColor(ContextCompat.getColor(context, R.color.color_primary))
chip.isCloseIconVisible = onChipCloseClickListener != null chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)
@ -96,11 +94,32 @@ class ChipsView @JvmOverloads constructor(
} }
} }
data class ChipModel( class ChipModel(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
val title: CharSequence, val title: CharSequence,
val data: Any? = null val data: Any? = null
) ) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ChipModel
if (icon != other.icon) return false
if (title != other.title) return false
if (data != other.data) return false
return true
}
override fun hashCode(): Int {
var result = icon
result = 31 * result + title.hashCode()
result = 31 * result + data.hashCode()
return result
}
}
fun interface OnChipClickListener { fun interface OnChipClickListener {

@ -6,40 +6,33 @@ import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import kotlin.math.roundToInt
class CoverImageView @JvmOverloads constructor( class CoverImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0,
) : AppCompatImageView(context, attrs, defStyleAttr) { ) : AppCompatImageView(context, attrs, defStyleAttr) {
private var orientation: Int = HORIZONTAL private var orientation: Int = HORIZONTAL
init { init {
context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) { context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) {
orientation = getInt(R.styleable.CoverImageView_android_orientation, HORIZONTAL) orientation = getInt(R.styleable.CoverImageView_android_orientation, orientation)
} }
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val desiredWidth: Int
val desiredHeight: Int
if (orientation == VERTICAL) { if (orientation == VERTICAL) {
val originalHeight = MeasureSpec.getSize(heightMeasureSpec) desiredHeight = measuredHeight
super.onMeasure( desiredWidth = (desiredHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt()
MeasureSpec.makeMeasureSpec(
(originalHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).toInt(),
MeasureSpec.EXACTLY
),
MeasureSpec.makeMeasureSpec(originalHeight, MeasureSpec.EXACTLY)
)
} else { } else {
val originalWidth = MeasureSpec.getSize(widthMeasureSpec) desiredWidth = measuredWidth
super.onMeasure( desiredHeight = (desiredWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt()
MeasureSpec.makeMeasureSpec(originalWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(
(originalWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt(),
MeasureSpec.EXACTLY
)
)
} }
setMeasuredDimension(desiredWidth, desiredHeight)
} }
companion object { 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) { override fun onWindowInsetsChanged(insets: Insets) {
binding.appbar.updatePadding(top = insets.top) binding.appbar.updatePadding(
binding.webView.updatePadding(bottom = insets.bottom) top = insets.top,
left = insets.left,
right = insets.right,
)
binding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
} }
companion object { companion object {

@ -8,9 +8,9 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebSettings import android.webkit.WebSettings
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.network.UserAgentInterceptor import org.koitharu.kotatsu.core.network.UserAgentInterceptor
@ -52,7 +52,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
super.onDestroyView() super.onDestroyView()
} }
override fun onBuildDialog(builder: AlertDialog.Builder) { override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
builder.setNegativeButton(android.R.string.cancel, null) builder.setNegativeButton(android.R.string.cancel, null)
} }

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.backup
import android.content.Context import android.content.Context
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -33,8 +34,7 @@ class BackupArchive(file: File) : MutableZipFile(file) {
private const val DIR_BACKUPS = "backups" private const val DIR_BACKUPS = "backups"
@Suppress("BlockingMethodInNonBlockingContext") suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
suspend fun createNew(context: Context): BackupArchive = withContext(Dispatchers.IO) {
val dir = context.run { val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
} }

@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONArray import org.json.JSONArray
data class BackupEntry( class BackupEntry(
val name: String, val name: String,
val data: JSONArray val data: JSONArray
) { ) {

@ -9,7 +9,7 @@ import org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
@Entity(tableName = "manga") @Entity(tableName = "manga")
data class MangaEntity( class MangaEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,

@ -14,7 +14,7 @@ import androidx.room.PrimaryKey
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE
)] )]
) )
data class MangaPrefsEntity( class MangaPrefsEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "mode") val mode: Int @ColumnInfo(name = "mode") val mode: Int

@ -20,7 +20,7 @@ import androidx.room.ForeignKey
) )
] ]
) )
data class MangaTagsEntity( class MangaTagsEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "tag_id", index = true) val tagId: Long @ColumnInfo(name = "tag_id", index = true) val tagId: Long
) )

@ -5,7 +5,7 @@ import androidx.room.Junction
import androidx.room.Relation import androidx.room.Relation
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
data class MangaWithTags( class MangaWithTags(
@Embedded val manga: MangaEntity, @Embedded val manga: MangaEntity,
@Relation( @Relation(
parentColumn = "manga_id", parentColumn = "manga_id",

@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
@Entity(tableName = "tags") @Entity(tableName = "tags")
data class TagEntity( class TagEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long, @ColumnInfo(name = "tag_id") val id: Long,
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,

@ -15,7 +15,7 @@ import androidx.room.PrimaryKey
) )
] ]
) )
data class TrackEntity( class TrackEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "chapters_total") val totalChapters: Int, @ColumnInfo(name = "chapters_total") val totalChapters: Int,

@ -15,7 +15,7 @@ import androidx.room.PrimaryKey
) )
] ]
) )
data class TrackLogEntity( class TrackLogEntity(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0L, @ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,

@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.model.TrackingLogItem
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.* import java.util.*
data class TrackLogWithManga( class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity, @Embedded val trackLog: TrackLogEntity,
@Relation( @Relation(
parentColumn = "manga_id", parentColumn = "manga_id",

@ -0,0 +1,14 @@
package org.koitharu.kotatsu.core.exceptions
import org.json.JSONArray
import org.koitharu.kotatsu.utils.ext.map
class GraphQLException(private val errors: JSONArray) : RuntimeException() {
val messages = errors.map {
it.getString("message")
}
override val message: String
get() = messages.joinToString("\n")
}

@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.github
import java.util.* import java.util.*
data class VersionId( class VersionId(
val major: Int, val major: Int,
val minor: Int, val minor: Int,
val build: Int, val build: Int,
val variantType: String, val variantType: String,
val variantNumber: Int val variantNumber: Int,
) : Comparable<VersionId> { ) : Comparable<VersionId> {
override fun compareTo(other: VersionId): Int { override fun compareTo(other: VersionId): Int {
@ -30,10 +30,34 @@ data class VersionId(
return variantNumber.compareTo(other.variantNumber) return variantNumber.compareTo(other.variantNumber)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VersionId
if (major != other.major) return false
if (minor != other.minor) return false
if (build != other.build) return false
if (variantType != other.variantType) return false
if (variantNumber != other.variantNumber) return false
return true
}
override fun hashCode(): Int {
var result = major
result = 31 * result + minor
result = 31 * result + build
result = 31 * result + variantType.hashCode()
result = 31 * result + variantNumber
return result
}
companion object { companion object {
private fun variantWeight(variantType: String) = private fun variantWeight(variantType: String) =
when (variantType.toLowerCase(Locale.ROOT)) { when (variantType.lowercase(Locale.ROOT)) {
"a", "alpha" -> 1 "a", "alpha" -> 1
"b", "beta" -> 2 "b", "beta" -> 2
"rc" -> 4 "rc" -> 4

@ -9,6 +9,13 @@ data class MangaChapter(
val name: String, val name: String,
val number: Int, val number: Int,
val url: String, val url: String,
val branch: String? = null, val scanlator: String?,
val source: MangaSource val uploadDate: Long,
) : Parcelable 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 updatedAt: Date,
val chapterId: Long, val chapterId: Long,
val page: Int, val page: Int,
val scroll: Int val scroll: Int,
) : Parcelable ) : Parcelable

@ -8,6 +8,6 @@ data class MangaPage(
val id: Long, val id: Long,
val url: String, val url: String,
val referer: String, val referer: String,
val preview: String? = null, val preview: String?,
val source: MangaSource val source: MangaSource,
) : Parcelable ) : Parcelable

@ -2,48 +2,38 @@ package org.koitharu.kotatsu.core.model
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize 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") @Suppress("SpellCheckingInspection")
@Parcelize @Parcelize
enum class MangaSource( enum class MangaSource(
val title: String, val title: String,
val locale: String?, val locale: String?,
val cls: Class<out MangaRepository>
) : Parcelable { ) : Parcelable {
LOCAL("Local", null, LocalMangaRepository::class.java), LOCAL("Local", null),
READMANGA_RU("ReadManga", "ru", ReadmangaRepository::class.java), READMANGA_RU("ReadManga", "ru"),
MINTMANGA("MintManga", "ru", MintMangaRepository::class.java), MINTMANGA("MintManga", "ru"),
SELFMANGA("SelfManga", "ru", SelfMangaRepository::class.java), SELFMANGA("SelfManga", "ru"),
MANGACHAN("Манга-тян", "ru", MangaChanRepository::class.java), MANGACHAN("Манга-тян", "ru"),
DESUME("Desu.me", "ru", DesuMeRepository::class.java), DESUME("Desu.me", "ru"),
HENCHAN("Хентай-тян", "ru", HenChanRepository::class.java), HENCHAN("Хентай-тян", "ru"),
YAOICHAN("Яой-тян", "ru", YaoiChanRepository::class.java), YAOICHAN("Яой-тян", "ru"),
MANGATOWN("MangaTown", "en", MangaTownRepository::class.java), MANGATOWN("MangaTown", "en"),
MANGALIB("MangaLib", "ru", MangaLibRepository::class.java), MANGALIB("MangaLib", "ru"),
// NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java), // NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java),
MANGAREAD("MangaRead", "en", MangareadRepository::class.java), MANGAREAD("MangaRead", "en"),
REMANGA("Remanga", "ru", RemangaRepository::class.java), REMANGA("Remanga", "ru"),
HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java), HENTAILIB("HentaiLib", "ru"),
ANIBEL("Anibel", "be", AnibelRepository::class.java), ANIBEL("Anibel", "be"),
NINEMANGA_EN("NineManga English", "en", NineMangaRepository.English::class.java), NINEMANGA_EN("NineManga English", "en"),
NINEMANGA_ES("NineManga Español", "es", NineMangaRepository.Spanish::class.java), NINEMANGA_ES("NineManga Español", "es"),
NINEMANGA_RU("NineManga Русский", "ru", NineMangaRepository.Russian::class.java), NINEMANGA_RU("NineManga Русский", "ru"),
NINEMANGA_DE("NineManga Deutsch", "de", NineMangaRepository.Deutsch::class.java), NINEMANGA_DE("NineManga Deutsch", "de"),
NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java), NINEMANGA_IT("NineManga Italiano", "it"),
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java), NINEMANGA_BR("NineManga Brasil", "pt"),
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java), NINEMANGA_FR("NineManga Français", "fr"),
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java) 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( data class MangaTag(
val title: String, val title: String,
val key: String, val key: String,
val source: MangaSource val source: MangaSource,
) : Parcelable ) : Parcelable

@ -6,4 +6,5 @@ object CommonHeaders {
const val USER_AGENT = "User-Agent" const val USER_AGENT = "User-Agent"
const val ACCEPT = "Accept" const val ACCEPT = "Accept"
const val CONTENT_DISPOSITION = "Content-Disposition" const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
} }

@ -7,7 +7,9 @@ import org.koin.core.qualifier.named
import org.koin.dsl.bind import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import org.koitharu.kotatsu.utils.DownloadManagerHelper
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
val networkModule val networkModule
@ -28,4 +30,6 @@ val networkModule
} }
}.build() }.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 { interface MangaRepository {
val source: MangaSource
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
suspend fun getList2( suspend fun getList2(

@ -2,15 +2,12 @@ package org.koitharu.kotatsu.core.parser
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.site.* import org.koitharu.kotatsu.core.parser.site.*
val parserModule val parserModule
get() = module { get() = module {
single { MangaLoaderContext(get(), get()) }
factory<MangaRepository>(named(MangaSource.READMANGA_RU)) { ReadmangaRepository(get()) } factory<MangaRepository>(named(MangaSource.READMANGA_RU)) { ReadmangaRepository(get()) }
factory<MangaRepository>(named(MangaSource.MINTMANGA)) { MintMangaRepository(get()) } factory<MangaRepository>(named(MangaSource.MINTMANGA)) { MintMangaRepository(get()) }
factory<MangaRepository>(named(MangaSource.SELFMANGA)) { SelfMangaRepository(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_IT)) { NineMangaRepository.Italiano(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) } factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) } factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
factory<MangaRepository>(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) }
factory<MangaRepository>(named(MangaSource.MANGADEX)) { MangaDexRepository(get()) }
} }

@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.MangaPage 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.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
@ -12,8 +11,6 @@ abstract class RemoteMangaRepository(
protected val loaderContext: MangaLoaderContext protected val loaderContext: MangaLoaderContext
) : MangaRepository { ) : MangaRepository {
protected abstract val source: MangaSource
protected abstract val defaultDomain: String protected abstract val defaultDomain: String
private val conf by lazy { private val conf by lazy {
@ -29,6 +26,8 @@ abstract class RemoteMangaRepository(
override suspend fun getTags(): Set<MangaTag> = emptySet() override suspend fun getTags(): Set<MangaTag> = emptySet()
open fun getFaviconUrl() = "https://${getDomain()}/favicon.ico"
open fun onCreatePreferences(map: MutableMap<String, Any>) { open fun onCreatePreferences(map: MutableMap<String, Any>) {
map[SourceSettings.KEY_DOMAIN] = defaultDomain map[SourceSettings.KEY_DOMAIN] = defaultDomain
} }
@ -53,8 +52,10 @@ abstract class RemoteMangaRepository(
if (subdomain != null) { if (subdomain != null) {
append(subdomain) append(subdomain)
append('.') append('.')
} append(conf.getDomain(defaultDomain).removePrefix("www."))
} else {
append(conf.getDomain(defaultDomain)) append(conf.getDomain(defaultDomain))
}
append(this@withDomain) append(this@withDomain)
} }
else -> this else -> this

@ -1,9 +1,14 @@
package org.koitharu.kotatsu.core.parser.site package org.koitharu.kotatsu.core.parser.site
import androidx.collection.ArraySet
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapIndexed
import org.koitharu.kotatsu.utils.ext.stringIterator
import java.util.* import java.util.*
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
@ -16,163 +21,243 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
SortOrder.NEWEST SortOrder.NEWEST
) )
override fun getFaviconUrl(): String {
return "https://cdn.${getDomain()}/favicons/favicon.png"
}
override suspend fun getList2( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder? sortOrder: SortOrder?,
): List<Manga> { ): List<Manga> {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList() return if (offset == 0) {
} search(query)
val page = (offset / 12f).toIntUp().inc() } else {
val link = when { emptyList()
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain() }
else -> tags.joinToString( }
prefix = "/manga?", val filters = tags?.takeUnless { it.isEmpty() }?.joinToString(
postfix = "&page=$page", separator = ",",
separator = "&", prefix = "genres: [",
) { tag -> "genre[]=${tag.key}" }.withDomain() postfix = "]"
} ) { "\"it.key\"" }.orEmpty()
val doc = loaderContext.httpGet(link).parseHtml() val array = apiCall(
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root") """
val items = root.select("div.anime-card") getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) {
return items.mapNotNull { card -> docs {
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null mediaId
val status = card.select("tr")[2].text() title {
val fullTitle = card.selectFirst("h1.anime-card-title")?.text() be
?.substringBeforeLast('[') ?: return@mapNotNull null alt
val titleParts = fullTitle.splitTwoParts('/') }
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( Manga(
id = generateUid(href), id = generateUid(mediaId),
title = titleParts?.first?.trim() ?: fullTitle, title = title.getString("be"),
coverUrl = card.selectFirst("img")?.attr("data-src") coverUrl = jo.getString("poster").removePrefix("/cdn")
?.withDomain().orEmpty(), .withDomain("cdn") + "?width=200&height=280",
altTitle = titleParts?.second?.trim(), altTitle = title.getString("alt").takeUnless(String::isEmpty),
author = null, author = null,
rating = Manga.NO_RATING, rating = jo.getDouble("rating").toFloat() / 10f,
url = href, url = href,
publicUrl = href.withDomain(), publicUrl = "https://${getDomain()}/${href}",
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x -> tags = jo.getJSONArray("genres").mapToTags(),
MangaTag( state = when (jo.getString("status")) {
title = x.text(), "ongoing" -> MangaState.ONGOING
key = x.attr("href").ifEmpty { "finished" -> MangaState.FINISHED
return@mapNotNull null
}.substringAfterLast("="),
source = source
)
},
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
else -> null else -> null
}, },
source = source source = source,
) )
} }
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml() val (type, slug) = manga.url.split('/')
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root") val details = apiCall(
"""
media(mediaType: $type, slug: "$slug") {
mediaId
title {
be
alt
}
description {
be
}
status
poster
rating
genres
}
""".trimIndent()
).getJSONObject("media")
val title = details.getJSONObject("title")
val poster = details.getString("poster").removePrefix("/cdn")
.withDomain("cdn")
val chapters = apiCall(
"""
chapters(mediaId: "${details.getString("mediaId")}") {
id
chapter
released
}
""".trimIndent()
).getJSONArray("chapters")
return manga.copy( return manga.copy(
description = root.select("div.manga-block.grid-12")[2].select("p").text(), title = title.getString("be"),
chapters = root.select("ul.series").flatMap { table -> altTitle = title.getString("alt"),
table.select("li") coverUrl = "$poster?width=200&height=280",
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a -> largeCoverUrl = poster,
val href = a?.select("a")?.first()?.attr("href") description = details.getJSONObject("description").getString("be"),
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null rating = details.getDouble("rating").toFloat() / 10f,
tags = details.getJSONArray("genres").mapToTags(),
state = when (details.getString("status")) {
"ongoing" -> MangaState.ONGOING
"finished" -> MangaState.FINISHED
else -> null
},
chapters = chapters.map { jo ->
val number = jo.getInt("chapter")
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(jo.getString("id")),
name = a.selectFirst("a")?.text().orEmpty(), name = "Глава $number",
number = i + 1, number = number,
url = href, url = "${manga.url}/read/$number",
source = source scanlator = null,
uploadDate = jo.getLong("released"),
branch = null,
source = source,
) )
} }
) )
} }
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.withDomain() val (_, slug, _, number) = chapter.url.split('/')
val doc = loaderContext.httpGet(fullUrl).parseHtml() val chapterJson = apiCall(
val scripts = doc.select("script") """
for (script in scripts) { chapter(slug: "$slug", chapter: $number) {
val data = script.html() id
val pos = data.indexOf("dataSource") images {
if (pos == -1) { large
continue thumbnail
} }
val json = data.substring(pos).substringAfter('[').substringBefore(']') }
val domain = getDomain() """.trimIndent()
return json.split(",").mapNotNull { ).getJSONObject("chapter")
it.trim() val pages = chapterJson.getJSONArray("images")
.removeSurrounding('"', '\'') val chapterUrl = "https://${getDomain()}/${chapter.url}"
.toRelativeUrl(domain) return pages.mapIndexed { i, jo ->
.takeUnless(String::isBlank)
}.map { url ->
MangaPage( MangaPage(
id = generateUid(url), id = generateUid("${chapter.url}/$i"),
url = url, url = jo.getString("large"),
referer = fullUrl, referer = chapterUrl,
source = source preview = jo.getString("thumbnail"),
source = source,
) )
} }
} }
parseFailed("Pages list not found at ${chapter.url.withDomain()}")
}
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml() val json = apiCall(
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums") """
return root.select("p.menu-tags.tupe").mapToSet { p -> getFilters(mediaType: manga) {
val a = p.selectFirst("a") ?: parseFailed("a is null") genres
MangaTag(
title = a.text().toCamelCase(),
key = a.attr("data-name"),
source = source
)
} }
""".trimIndent()
)
val array = json.getJSONObject("getFilters").getJSONArray("genres")
return array.mapToTags()
} }
private suspend fun search(query: String): List<Manga> { private suspend fun search(query: String): List<Manga> {
val domain = getDomain() val json = apiCall(
val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml() """
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: parseFailed("Cannot find root") search(query: "$query", limit: 40) {
val items = root.select("div.anime-card") id
return items.mapNotNull { card -> title {
val href = card.select("a").attr("href") be
val status = card.select("tr")[2].text() en
val fullTitle = card.selectFirst("h1.anime-card-title")?.text() }
?.substringBeforeLast('[') ?: return@mapNotNull null poster
val titleParts = fullTitle.splitTwoParts('/') url
type
}
""".trimIndent()
)
val array = json.getJSONArray("search")
return array.map { jo ->
val mediaId = jo.getString("id")
val title = jo.getJSONObject("title")
val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}"
Manga( Manga(
id = generateUid(href), id = generateUid(mediaId),
title = titleParts?.first?.trim() ?: fullTitle, title = title.getString("be"),
coverUrl = card.selectFirst("img")?.attr("src") coverUrl = jo.getString("poster").removePrefix("/cdn")
?.withDomain().orEmpty(), .withDomain("cdn") + "?width=200&height=280",
altTitle = titleParts?.second?.trim(), altTitle = title.getString("en").takeUnless(String::isEmpty),
author = null, author = null,
rating = Manga.NO_RATING, rating = Manga.NO_RATING,
url = href, url = href,
publicUrl = href.withDomain(), publicUrl = "https://${getDomain()}/${href}",
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x -> tags = emptySet(),
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( MangaTag(
title = x.text(), title = toTitle(it),
key = x.attr("href").ifEmpty { key = it,
return@mapNotNull null source = source,
}.substringAfterLast("="),
source = source
) )
},
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
else -> null
},
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.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.* import java.util.*
abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository( 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 doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
val root = val root =
doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root") doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root")
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return manga.copy( return manga.copy(
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"), description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"), largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
chapters = root.select("table.table_cha").flatMap { table -> chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr ->
table.select("div.manga2") val href = tr?.selectFirst("a")?.relUrl("href") ?: return@mapIndexedNotNull null
}.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
val href = a?.relUrl("href") ?: return@mapIndexedNotNull null
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.text().trim(), name = tr.selectFirst("a")?.text().orEmpty(),
number = i + 1, number = i + 1,
url = href, 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( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null,
referer = fullUrl, referer = fullUrl,
source = source source = source,
) )
} }
} }
@ -154,4 +158,5 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
SortOrder.NEWEST -> "datedesc" SortOrder.NEWEST -> "datedesc"
else -> "favdesc" else -> "favdesc"
} }
} }

@ -93,12 +93,17 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
description = json.getString("description"), description = json.getString("description"),
chapters = chaptersList.mapIndexed { i, it -> chapters = chaptersList.mapIndexed { i, it ->
val chid = it.getLong("id") val chid = it.getLong("id")
val volChap = "Том " + it.optString("vol", "0") + ". " + "Глава " + it.optString("ch", "0")
val title = it.optString("title", "null").takeUnless { it == "null" }
MangaChapter( MangaChapter(
id = generateUid(chid), id = generateUid(chid),
source = manga.source, source = manga.source,
url = "$baseChapterUrl$chid", url = "$baseChapterUrl$chid",
name = it.getStringOrNull("title") ?: "${manga.title} #${it.getDouble("ch")}", uploadDate = it.getLong("date") * 1000,
number = totalChapters - i name = if (title.isNullOrEmpty()) volChap else "$volChap: $title",
number = totalChapters - i,
scanlator = null,
branch = null,
) )
}.reversed() }.reversed()
) )
@ -113,8 +118,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
MangaPage( MangaPage(
id = generateUid(jo.getLong("id")), id = generateUid(jo.getLong("id")),
referer = fullUrl, referer = fullUrl,
preview = null,
source = chapter.source, source = chapter.source,
url = jo.getString("img") url = jo.getString("img"),
) )
} }
} }

@ -141,8 +141,10 @@ class ExHentaiRepository(
name = "${manga.title} #$i", name = "${manga.title} #$i",
number = i, number = i,
url = url, url = url,
branch = null, uploadDate = 0L,
source = source, source = source,
scanlator = null,
branch = null,
) )
} }
chapters chapters

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.parser.site package org.koitharu.kotatsu.core.parser.site
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.base.domain.MangaLoaderContext 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.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.* import java.util.*
abstract class GroupleRepository(loaderContext: MangaLoaderContext) : abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
@ -39,14 +41,14 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
getSortKey( getSortKey(
sortOrder sortOrder
) )
}&offset=${offset upBy PAGE_SIZE}" }&offset=${offset upBy PAGE_SIZE}", HEADER
) )
tags.size == 1 -> loaderContext.httpGet( tags.size == 1 -> loaderContext.httpGet(
"https://$domain/list/genre/${tags.first().key}?sortType=${ "https://$domain/list/genre/${tags.first().key}?sortType=${
getSortKey( getSortKey(
sortOrder sortOrder
) )
}&offset=${offset upBy PAGE_SIZE}" }&offset=${offset upBy PAGE_SIZE}", HEADER
) )
offset > 0 -> return emptyList() offset > 0 -> return emptyList()
else -> advancedSearch(domain, tags) else -> advancedSearch(domain, tags)
@ -104,14 +106,15 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
} }
override suspend fun getDetails(manga: Manga): Manga { 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") val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?: throw ParseException("Cannot find root") ?: throw ParseException("Cannot find root")
val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US)
val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img")
return manga.copy( return manga.copy(
description = root.selectFirst("div.manga-description")?.html(), description = root.selectFirst("div.manga-description")?.html(),
largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr( largeCoverUrl = coverImg?.attr("data-full"),
"data-full" coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl,
),
tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ") tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ")
.mapNotNull { .mapNotNull {
val a = it.selectFirst("a.element-link") ?: return@mapNotNull null val a = it.selectFirst("a.element-link") ?: return@mapNotNull null
@ -122,21 +125,32 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
) )
}, },
chapters = root.selectFirst("div.chapters-link")?.selectFirst("table") 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") val href = a.relUrl("href")
var translators = ""
val translatorElement = a.attr("title")
if (!translatorElement.isNullOrBlank()) {
translators = translatorElement
.replace("(Переводчик),", "&")
.removeSuffix(" (Переводчик)")
}
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.ownText().removePrefix(manga.title).trim(), name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(),
number = i + 1, number = i + 1,
url = href, 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> { 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") val scripts = doc.select("script")
for (script in scripts) { for (script in scripts) {
val data = script.html() val data = script.html()
@ -154,8 +168,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null,
referer = chapter.url, referer = chapter.url,
source = source source = source,
) )
} }
} }
@ -163,7 +178,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
} }
override suspend fun getTags(): Set<MangaTag> { 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") val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
?.selectFirst("table.table") ?: parseFailed("Cannot find root") ?.selectFirst("table.table") ?: parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a -> 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 { private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
val url = "https://$domain/search/advanced" val url = "https://$domain/search/advanced"
// Step 1: map catalog genres names to advanced-search genres ids // 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") .body().selectFirst("form.search-form")
?.select("div.form-group") ?.select("div.form-group")
?.get(1) ?: parseFailed("Genres filter element not found") ?.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 = 70
private const val PAGE_SIZE_SEARCH = 50 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? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
return super.getList2(offset, query, tags, sortOrder).map { return super.getList2(offset, query, tags, sortOrder).map {
val cover = it.coverUrl it.copy(
if (cover.contains("_blur")) { coverUrl = it.coverUrl.replace("_blur", ""),
it.copy(coverUrl = cover.replace("_blur", "")) isNsfw = true,
} else { )
it
}
} }
} }
@ -49,7 +47,10 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
url = readLink, url = readLink,
source = source, source = source,
number = 1, 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.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.* import java.util.*
open class MangaLibRepository(loaderContext: MangaLoaderContext) : open class MangaLibRepository(loaderContext: MangaLoaderContext) :
@ -79,6 +80,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
val info = root.selectFirst("div.media-content") val info = root.selectFirst("div.media-content")
val chaptersDoc = loaderContext.httpGet("$fullUrl?section=chapters").parseHtml() val chaptersDoc = loaderContext.httpGet("$fullUrl?section=chapters").parseHtml()
val scripts = chaptersDoc.select("script") val scripts = chaptersDoc.select("script")
val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US)
var chapters: ArrayList<MangaChapter>? = null var chapters: ArrayList<MangaChapter>? = null
scripts@ for (script in scripts) { scripts@ for (script in scripts) {
val raw = script.html().lines() val raw = script.html().lines()
@ -91,7 +93,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
for (i in 0 until total) { for (i in 0 until total) {
val item = list.getJSONObject(i) val item = list.getJSONObject(i)
val chapterId = item.getLong("chapter_id") val chapterId = item.getLong("chapter_id")
val branchName = item.getStringOrNull("username") val scanlator = item.getStringOrNull("username")
val url = buildString { val url = buildString {
append(manga.url) append(manga.url)
append("/v") append("/v")
@ -102,19 +104,22 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
append('/') append('/')
append(item.optString("chapter_string")) append(item.optString("chapter_string"))
} }
var name = item.getStringOrNull("chapter_name") val nameChapter = item.getStringOrNull("chapter_name")
if (name.isNullOrBlank() || name == "null") { val volume = item.getInt("chapter_volume")
name = "Том " + item.getInt("chapter_volume") + val number = item.getString("chapter_number")
" Глава " + item.getString("chapter_number") val fullNameChapter = "Том $volume. Глава $number"
}
chapters.add( chapters.add(
MangaChapter( MangaChapter(
id = generateUid(chapterId), id = generateUid(chapterId),
url = url, url = url,
source = source, source = source,
branch = branchName,
number = total - i, 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( MangaPage(
id = generateUid(pageUrl), id = generateUid(pageUrl),
url = pageUrl, url = pageUrl,
preview = null,
referer = fullUrl, referer = fullUrl,
source = source source = source,
) )
} }
} }
@ -235,8 +241,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
.toFloatOrNull()?.div(5f) ?: Manga.NO_RATING, .toFloatOrNull()?.div(5f) ?: Manga.NO_RATING,
state = null, state = null,
source = source, source = source,
coverUrl = "https://$domain${covers.getString("thumbnail")}", coverUrl = covers.getString("thumbnail"),
largeCoverUrl = "https://$domain${covers.getString("default")}" 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.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.* import java.util.*
class MangaTownRepository(loaderContext: MangaLoaderContext) : class MangaTownRepository(loaderContext: MangaLoaderContext) :
@ -96,6 +98,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
val info = root.selectFirst("div.detail_info")?.selectFirst("ul") val info = root.selectFirst("div.detail_info")?.selectFirst("ul")
val chaptersList = root.selectFirst("div.chapter_content") val chaptersList = root.selectFirst("div.chapter_content")
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed() ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
return manga.copy( return manga.copy(
tags = manga.tags + info?.select("li")?.find { x -> tags = manga.tags + info?.select("li")?.find { x ->
x.selectFirst("b")?.ownText() == "Genre(s):" x.selectFirst("b")?.ownText() == "Genre(s):"
@ -117,9 +120,15 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
url = href, url = href,
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
number = i + 1, 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( MangaPage(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
preview = null,
referer = fullUrl, referer = fullUrl,
source = MangaSource.MANGATOWN source = MangaSource.MANGATOWN,
) )
} ?: parseFailed("Pages list not found") } ?: parseFailed("Pages list not found")
} }
@ -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>) { override fun onCreatePreferences(map: MutableMap<String, Any>) {
super.onCreatePreferences(map) super.onCreatePreferences(map)
map[SourceSettings.KEY_USE_SSL] = true map[SourceSettings.KEY_USE_SSL] = true
} }
private suspend fun bypassLicensedChapters(manga: Manga): List<MangaChapter> {
val doc = loaderContext.httpGet(manga.url.withDomain("m")).parseHtml()
val list = doc.body().selectFirst("ul.detail-ch-list") ?: return emptyList()
val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US)
return list.select("li").asReversed().mapIndexedNotNull { i, li ->
val a = li.selectFirst("a") ?: return@mapIndexedNotNull null
val href = a.relUrl("href")
val name = a.selectFirst("span.vol")?.text().orEmpty().ifEmpty {
a.ownText()
}
MangaChapter(
id = generateUid(href),
url = href,
source = MangaSource.MANGATOWN,
number = i + 1,
uploadDate = parseChapterDate(
dateFormat,
li.selectFirst("span.time")?.text()
),
name = name.ifEmpty { "${manga.title} - ${i + 1}" },
scanlator = null,
branch = null,
)
}
}
private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it } private fun String.parseTagKey() = split('/').findLast { TAG_REGEX matches it }
private companion object { private companion object {

@ -4,7 +4,10 @@ import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.WordSet
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.* import java.util.*
class MangareadRepository( class MangareadRepository(
@ -52,7 +55,7 @@ class MangareadRepository(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.inContextOf(div), publicUrl = href.inContextOf(div),
coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(), coverUrl = div.selectFirst("img")?.absUrl("data-src").orEmpty(),
title = summary?.selectFirst("h3")?.text().orEmpty(), title = summary?.selectFirst("h3")?.text().orEmpty(),
rating = div.selectFirst("span.total_votes")?.ownText() rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f, ?.toFloatOrNull()?.div(5f) ?: -1f,
@ -104,16 +107,7 @@ class MangareadRepository(
val root2 = doc.body().selectFirst("div.content-area") val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page") ?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found") ?: throw ParseException("Root2 not found")
val mangaId = doc.getElementsByAttribute("data-post").firstOrNull() val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US)
?.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()
return manga.copy( return manga.copy(
tags = root.selectFirst("div.genres-content")?.select("a") tags = root.selectFirst("div.genres-content")?.select("a")
?.mapNotNullToSet { a -> ?.mapNotNullToSet { a ->
@ -128,7 +122,7 @@ class MangareadRepository(
?.select("p") ?.select("p")
?.filterNot { it.ownText().startsWith("A brief description") } ?.filterNot { it.ownText().startsWith("A brief description") }
?.joinToString { it.html() }, ?.joinToString { it.html() },
chapters = doc2.select("li").asReversed().mapIndexed { i, li -> chapters = root2.select("li").asReversed().mapIndexed { i, li ->
val a = li.selectFirst("a") val a = li.selectFirst("a")
val href = a?.relUrl("href").orEmpty().ifEmpty { val href = a?.relUrl("href").orEmpty().ifEmpty {
parseFailed("Link is missing") parseFailed("Link is missing")
@ -138,7 +132,13 @@ class MangareadRepository(
name = a!!.ownText(), name = a!!.ownText(),
number = i + 1, number = i + 1,
url = href, 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") ?.selectFirst("div.reading-content")
?: throw ParseException("Root not found") ?: throw ParseException("Root not found")
return root.select("div.page-break").map { div -> return root.select("div.page-break").map { div ->
val img = div.selectFirst("img") val img = div.selectFirst("img") ?: parseFailed("Page image not found")
val url = img?.relUrl("src") ?: parseFailed("Page image not found") val url = img.relUrl("data-src").ifEmpty {
img.relUrl("src")
}
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
preview = null,
referer = fullUrl, referer = fullUrl,
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD,
) )
} }
} }
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 companion object {
private const val PAGE_SIZE = 12 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.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.* import java.util.*
abstract class NineMangaRepository( abstract class NineMangaRepository(
@ -40,7 +41,7 @@ abstract class NineMangaRepository(
append("&page=") append("&page=")
} }
!tags.isNullOrEmpty() -> { !tags.isNullOrEmpty() -> {
append("/search/&category_id=") append("/search/?category_id=")
for (tag in tags) { for (tag in tags) {
append(tag.key) append(tag.key)
append(',') append(',')
@ -99,19 +100,22 @@ abstract class NineMangaRepository(
) )
}.orEmpty(), }.orEmpty(),
author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(), author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(),
state = parseStatus(infoRoot.select("li a.red").text()),
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first() description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
?.html()?.substringAfter("</b>"), ?.html()?.substringAfter("</b>"),
chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul") chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li")
?.select("li")?.asReversed()?.mapIndexed { i, li -> ?.asReversed()?.mapIndexed { i, li ->
val a = li.selectFirst("a") val a = li.selectFirst("a.chapter_list_a")
val href = a?.relUrl("href") ?: parseFailed("Link not found") val href = a?.relUrl("href")?.replace("%20", " ") ?: parseFailed("Link not found")
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.text(), name = a.text(),
number = i + 1, number = i + 1,
url = href, url = href,
branch = null, uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()),
source = source, source = source,
scanlator = null,
branch = null,
) )
} }
) )
@ -153,6 +157,50 @@ abstract class NineMangaRepository(
} ?: parseFailed("Root not found") } ?: 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( class English(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext, loaderContext,
MangaSource.NINEMANGA_EN, MangaSource.NINEMANGA_EN,

@ -5,6 +5,6 @@ import org.koitharu.kotatsu.core.model.MangaSource
class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) { class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) {
override val defaultDomain = "readmanga.live" override val defaultDomain = "readmanga.io"
override val source = MangaSource.READMANGA_RU 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.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.text.SimpleDateFormat
import java.util.* import java.util.*
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext),
MangaRepositoryAuthProvider {
override val source = MangaSource.REMANGA override val source = MangaSource.REMANGA
override val defaultDomain = "remanga.org" override val defaultDomain = "remanga.org"
override val authUrl: String
get() = "https://${getDomain()}/user/login"
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED, SortOrder.UPDATED,
@ -29,6 +34,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
copyCookies()
val domain = getDomain() val domain = getDomain()
val urlBuilder = StringBuilder() val urlBuilder = StringBuilder()
.append("https://api.") .append("https://api.")
@ -77,6 +83,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
} }
override suspend fun getDetails(manga: Manga): Manga { override suspend fun getDetails(manga: Manga): Manga {
copyCookies()
val domain = getDomain() val domain = getDomain()
val slug = manga.url.find(LAST_URL_PATH_REGEX) val slug = manga.url.find(LAST_URL_PATH_REGEX)
?: throw ParseException("Cannot obtain slug from ${manga.url}") ?: throw ParseException("Cannot obtain slug from ${manga.url}")
@ -93,6 +100,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
val chapters = loaderContext.httpGet( val chapters = loaderContext.httpGet(
url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId" url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId"
).parseJson().getJSONArray("content") ).parseJson().getJSONArray("content")
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
return manga.copy( return manga.copy(
description = content.getString("description"), description = content.getString("description"),
state = when (content.optJSONObject("status")?.getInt("id")) { state = when (content.optJSONObject("status")?.getInt("id")) {
@ -109,20 +117,27 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
}, },
chapters = chapters.mapIndexed { i, jo -> chapters = chapters.mapIndexed { i, jo ->
val id = jo.getLong("id") val id = jo.getLong("id")
val name = jo.getString("name") val name = jo.getString("name").toTitleCase(Locale.ROOT)
val publishers = jo.getJSONArray("publishers")
MangaChapter( MangaChapter(
id = generateUid(id), id = generateUid(id),
url = "/api/titles/chapters/$id/", url = "/api/titles/chapters/$id/",
number = chapters.length() - i, number = chapters.length() - i,
name = buildString { name = buildString {
append("Том ")
append(jo.optString("tome", "0"))
append(". ")
append("Глава ") append("Глава ")
append(jo.getString("chapter")) append(jo.optString("chapter", "0"))
if (name.isNotEmpty()) { if (name.isNotEmpty()) {
append(" - ") append(" - ")
append(name) append(name)
} }
}, },
source = MangaSource.REMANGA uploadDate = dateFormat.tryParse(jo.getString("upload_date")),
scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"),
source = MangaSource.REMANGA,
branch = null,
) )
}.asReversed() }.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) { private fun getSortKey(order: SortOrder?) = when (order) {
SortOrder.UPDATED -> "-chapter_date" SortOrder.UPDATED -> "-chapter_date"
SortOrder.POPULARITY -> "-rating" SortOrder.POPULARITY -> "-rating"
@ -167,8 +193,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
private fun parsePage(jo: JSONObject, referer: String) = MangaPage( private fun parsePage(jo: JSONObject, referer: String) = MangaPage(
id = generateUid(jo.getLong("id")), id = generateUid(jo.getLong("id")),
url = jo.getString("link"), url = jo.getString("link"),
preview = null,
referer = referer, referer = referer,
source = source source = source,
) )
private companion object { private companion object {

@ -29,7 +29,10 @@ class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loa
name = a.text().trim(), name = a.text().trim(),
number = i + 1, number = i + 1,
url = href, 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.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.sendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.delegates.prefs.* import org.koitharu.kotatsu.utils.delegates.prefs.*
import java.io.File import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class AppSettings private constructor(private val prefs: SharedPreferences) : class AppSettings private constructor(private val prefs: SharedPreferences) :
SharedPreferences by prefs { SharedPreferences by prefs {
@ -39,6 +44,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
) )
val isDynamicTheme by BoolPreferenceDelegate(KEY_DYNAMIC_THEME, defaultValue = false)
val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false) val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false)
val isToolbarHideWhenScrolling by BoolPreferenceDelegate(KEY_HIDE_TOOLBAR, defaultValue = true) 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 historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true)
var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false)
var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false) var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false)
val zoomMode by EnumPreferenceDelegate( val zoomMode by EnumPreferenceDelegate(
@ -104,6 +113,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
val isSourcesSelected: Boolean val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs get() = KEY_SOURCES_HIDDEN in prefs
val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false)
fun getStorageDir(context: Context): File? { fun getStorageDir(context: Context): File? {
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it) File(it)
@ -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()") @Deprecated("Use observe()")
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)
@ -132,7 +149,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
fun observe() = callbackFlow<String> { fun observe() = callbackFlow<String> {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
sendBlocking(key) trySendBlocking(key)
} }
prefs.registerOnSharedPreferenceChangeListener(listener) prefs.registerOnSharedPreferenceChangeListener(listener)
awaitClose { awaitClose {
@ -151,7 +168,9 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_LIST_MODE = "list_mode_2" const val KEY_LIST_MODE = "list_mode_2"
const val KEY_APP_SECTION = "app_section" const val KEY_APP_SECTION = "app_section"
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_DYNAMIC_THEME = "dynamic_theme"
const val KEY_THEME_AMOLED = "amoled_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_HIDE_TOOLBAR = "hide_toolbar"
const val KEY_SOURCES_ORDER = "sources_order" const val KEY_SOURCES_ORDER = "sources_order"
const val KEY_SOURCES_HIDDEN = "sources_hidden" 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_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
const val KEY_PAGES_NUMBERS = "pages_numbers"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"
@ -191,5 +212,12 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda" const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
const val KEY_FEEDBACK_GITHUB = "about_feedback_github" const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
const val KEY_SUPPORT_DEVELOPER = "about_support_developer" 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 { override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes) return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MinutesAgo
return minutes == other.minutes
} }
data class HoursAgo(val hours: Int) : DateTimeAgo() { override fun hashCode(): Int = minutes
}
class HoursAgo(val hours: Int) : DateTimeAgo() {
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.hours_ago, hours, hours) return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HoursAgo
return hours == other.hours
}
override fun hashCode(): Int = hours
} }
object Today : DateTimeAgo() { object Today : DateTimeAgo() {
@ -38,10 +57,19 @@ sealed class DateTimeAgo : ListModel {
} }
} }
data class DaysAgo(val days: Int) : DateTimeAgo() { class DaysAgo(val days: Int) : DateTimeAgo() {
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.days_ago, days, days) return resources.getQuantityString(R.plurals.days_ago, days, days)
} }
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DaysAgo
return days == other.days
}
override fun hashCode(): Int = days
} }
object LongAgo : DateTimeAgo() { object LongAgo : DateTimeAgo() {

@ -5,6 +5,7 @@ import coil.ImageLoader
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.parser.FaviconMapper
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
val uiModule val uiModule
@ -15,6 +16,7 @@ val uiModule
.componentRegistry( .componentRegistry(
ComponentRegistry.Builder() ComponentRegistry.Builder()
.add(CbzFetcher()) .add(CbzFetcher())
.add(FaviconMapper())
.build() .build()
).build() ).build()
} }

@ -9,8 +9,8 @@ import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDividerItemDecoration
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
@ -51,12 +51,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
chaptersAdapter = ChaptersAdapter(this) chaptersAdapter = ChaptersAdapter(this)
selectionDecoration = ChaptersSelectionDecoration(view.context) selectionDecoration = ChaptersSelectionDecoration(view.context)
with(binding.recyclerViewChapters) { with(binding.recyclerViewChapters) {
addItemDecoration( addItemDecoration(MaterialDividerItemDecoration(view.context, RecyclerView.VERTICAL))
DividerItemDecoration(
view.context,
RecyclerView.VERTICAL
)
)
addItemDecoration(selectionDecoration!!) addItemDecoration(selectionDecoration!!)
setHasFixedSize(true) setHasFixedSize(true)
adapter = chaptersAdapter adapter = chaptersAdapter
@ -117,7 +112,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
} }
return return
} }
if (item.isMissing) { if (item.hasFlag(ChapterListItem.FLAG_MISSING)) {
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id) (activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
return return
} }

@ -6,15 +6,17 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
@ -44,7 +46,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
TabLayoutMediator.TabConfigurationStrategy { TabLayoutMediator.TabConfigurationStrategy {
private val viewModel by viewModel<DetailsViewModel>(mode = LazyThreadSafetyMode.NONE) { private val viewModel by viewModel<DetailsViewModel> {
parametersOf(MangaIntent.from(intent)) parametersOf(MangaIntent.from(intent))
} }
@ -85,18 +87,24 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
finishAfterTransition() finishAfterTransition()
} }
else -> { else -> {
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG) binding.snackbar.show(e.getDisplayMessage(resources))
.show()
} }
} }
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.toolbar.updatePadding( binding.snackbar.updatePadding(
top = insets.top, bottom = insets.bottom
)
with(binding.toolbar) {
updatePadding(
left = insets.left, left = insets.left,
right = insets.right right = insets.right
) )
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
if (binding.tabs.parent !is Toolbar) { if (binding.tabs.parent !is Toolbar) {
binding.tabs.updatePadding( binding.tabs.updatePadding(
left = insets.left, left = insets.left,
@ -147,7 +155,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
} }
R.id.action_delete -> { R.id.action_delete -> {
viewModel.manga.value?.let { m -> viewModel.manga.value?.let { m ->
AlertDialog.Builder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_manga) .setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title)) .setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ -> .setPositiveButton(R.string.delete) { _, _ ->
@ -162,7 +170,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
viewModel.manga.value?.let { viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0 val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) { if (chaptersCount > 5) {
AlertDialog.Builder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.save_manga) .setTitle(R.string.save_manga)
.setMessage( .setMessage(
getString( getString(

@ -1,16 +1,19 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.app.ActivityOptions
import android.os.Bundle import android.os.Bundle
import android.text.Spanned import android.text.Spanned
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest
import coil.util.CoilUtils import coil.util.CoilUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -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.Manga
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaState
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSizeUtils import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import kotlin.random.Random
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener, class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
View.OnLongClickListener { View.OnLongClickListener {
@ -39,11 +44,16 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = FragmentDetailsBinding.inflate(inflater, container, false) ) = FragmentDetailsBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) 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.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
@ -52,12 +62,8 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
private fun onMangaUpdated(manga: Manga) { private fun onMangaUpdated(manga: Manga) {
with(binding) { with(binding) {
imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl) // Main
.referer(manga.publicUrl) loadCover(manga)
.fallback(R.drawable.ic_placeholder)
.placeholderMemoryCacheKey(CoilUtils.metadata(imageViewCover)?.memoryCacheKey)
.lifecycle(viewLifecycleOwner)
.enqueueWith(coil)
textViewTitle.text = manga.title textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author textViewAuthor.textAndVisible = manga.author
@ -66,6 +72,27 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
textViewDescription.text = textViewDescription.text =
manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank) manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
?: getString(R.string.no_description) ?: 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) { if (manga.chapters?.isNotEmpty() == true) {
chaptersContainer.isVisible = true chaptersContainer.isVisible = true
textViewChapters.text = manga.chapters.let { textViewChapters.text = manga.chapters.let {
@ -96,10 +123,11 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
} else { } else {
sizeContainer.isVisible = false sizeContainer.isVisible = false
} }
buttonFavorite.setOnClickListener(this@DetailsFragment)
buttonRead.setOnClickListener(this@DetailsFragment) // Buttons
buttonRead.setOnLongClickListener(this@DetailsFragment)
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty() buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
// Chips
bindTags(manga) 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 package org.koitharu.kotatsu.details.ui
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.asFlow import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope 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.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository 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.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.iterator
import org.koitharu.kotatsu.utils.ext.mapToSet import org.koitharu.kotatsu.utils.ext.mapToSet
import org.koitharu.kotatsu.utils.ext.toTitleCase
import java.io.IOException import java.io.IOException
class DetailsViewModel( class DetailsViewModel(
@ -58,16 +60,6 @@ class DetailsViewModel(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val remoteManga = MutableStateFlow<Manga?>(null) 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() private val chaptersReversed = settings.observe()
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS } .filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
@ -107,10 +99,10 @@ class DetailsViewModel(
selectedBranch selectedBranch
) { chapters, sourceManga, currentId, newCount, branch -> ) { chapters, sourceManga, currentId, newCount, branch ->
val sourceChapters = sourceManga?.chapters val sourceChapters = sourceManga?.chapters
if (sourceChapters.isNullOrEmpty()) { if (sourceManga?.source != MangaSource.LOCAL && !sourceChapters.isNullOrEmpty()) {
mapChapters(chapters, currentId, newCount, branch)
} else {
mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch) mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch)
} else {
mapChapters(chapters, sourceChapters, currentId, newCount, branch)
} }
}.combine(chaptersReversed) { list, reversed -> }.combine(chaptersReversed) { list, reversed ->
if (reversed) list.asReversed() else list if (reversed) list.asReversed() else list
@ -121,23 +113,23 @@ class DetailsViewModel(
var manga = mangaDataRepository.resolveIntent(intent) var manga = mangaDataRepository.resolveIntent(intent)
?: throw MangaNotFoundException("Cannot find manga") ?: throw MangaNotFoundException("Cannot find manga")
mangaData.value = manga mangaData.value = manga
manga = manga.source.repository.getDetails(manga) manga = MangaRepository(manga.source).getDetails(manga)
// find default branch // find default branch
val hist = historyRepository.getOne(manga) val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) { selectedBranch.value = if (hist != null) {
manga.chapters?.find { it.id == hist.chapterId }?.branch manga.chapters?.find { it.id == hist.chapterId }?.branch
} else { } else {
manga.chapters predictBranch(manga.chapters)
?.groupBy { it.branch }
?.maxByOrNull { it.value.size }?.key
} }
mangaData.value = manga mangaData.value = manga
if (manga.source == MangaSource.LOCAL) {
remoteManga.value = runCatching { remoteManga.value = runCatching {
if (manga.source == MangaSource.LOCAL) {
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
MangaRepository(m.source).getDetails(m) MangaRepository(m.source).getDetails(m)
}.getOrNull() } else {
localMangaRepository.findSavedManga(manga)
} }
}.getOrNull()
} }
} }
@ -166,26 +158,28 @@ class DetailsViewModel(
private fun mapChapters( private fun mapChapters(
chapters: List<MangaChapter>, chapters: List<MangaChapter>,
downloadedChapters: List<MangaChapter>?,
currentId: Long?, currentId: Long?,
newCount: Int, newCount: Int,
branch: String?, branch: String?,
): List<ChapterListItem> { ): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size) val result = ArrayList<ChapterListItem>(chapters.size)
val dateFormat = settings.dateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId } val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapToSet { it.id }
for (i in chapters.indices) { for (i in chapters.indices) {
val chapter = chapters[i] val chapter = chapters[i]
if (chapter.branch != branch) { if (chapter.branch != branch) {
continue continue
} }
result += chapter.toListItem( result += chapter.toListItem(
extra = when { isCurrent = i == currentIndex,
i >= firstNewIndex -> ChapterExtra.NEW isUnread = i > currentIndex,
i == currentIndex -> ChapterExtra.CURRENT isNew = i >= firstNewIndex,
i < currentIndex -> ChapterExtra.READ isMissing = false,
else -> ChapterExtra.UNREAD isDownloaded = downloadedIds?.contains(chapter.id) == true,
}, dateFormat = dateFormat,
isMissing = false
) )
} }
return result return result
@ -202,6 +196,7 @@ class DetailsViewModel(
val result = ArrayList<ChapterListItem>(sourceChapters.size) val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId } val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount val firstNewIndex = sourceChapters.size - newCount
val dateFormat = settings.dateFormat()
for (i in sourceChapters.indices) { for (i in sourceChapters.indices) {
val chapter = sourceChapters[i] val chapter = sourceChapters[i]
if (chapter.branch != branch) { if (chapter.branch != branch) {
@ -209,30 +204,53 @@ class DetailsViewModel(
} }
val localChapter = chaptersMap.remove(chapter.id) val localChapter = chaptersMap.remove(chapter.id)
result += localChapter?.toListItem( result += localChapter?.toListItem(
extra = when { isCurrent = i == currentIndex,
i >= firstNewIndex -> ChapterExtra.NEW isUnread = i > currentIndex,
i == currentIndex -> ChapterExtra.CURRENT isNew = i >= firstNewIndex,
i < currentIndex -> ChapterExtra.READ isMissing = false,
else -> ChapterExtra.UNREAD isDownloaded = false,
}, dateFormat = dateFormat,
isMissing = false
) ?: chapter.toListItem( ) ?: chapter.toListItem(
extra = when { isCurrent = i == currentIndex,
i >= firstNewIndex -> ChapterExtra.NEW isUnread = i > currentIndex,
i == currentIndex -> ChapterExtra.CURRENT isNew = i >= firstNewIndex,
i < currentIndex -> ChapterExtra.READ isMissing = true,
else -> ChapterExtra.UNREAD isDownloaded = false,
}, dateFormat = dateFormat,
isMissing = true
) )
} }
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size) result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapTo(result) { 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 } result.sortBy { it.chapter.number }
} }
return result 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 package org.koitharu.kotatsu.details.ui.adapter
import android.view.View
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem 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.getThemeColor
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun chapterListItemAD( fun chapterListItemAD(
clickListener: OnListItemClickListener<ChapterListItem>, clickListener: OnListItemClickListener<ChapterListItem>,
@ -14,35 +21,40 @@ fun chapterListItemAD(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) { ) {
itemView.setOnClickListener { val eventListener = object : View.OnClickListener, View.OnLongClickListener {
clickListener.onItemClick(item, it) override fun onClick(v: View) = clickListener.onItemClick(item, v)
} override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v)
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item, it)
} }
bind { payload -> itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
bind { payloads ->
if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.name binding.textViewTitle.text = item.chapter.name
binding.textViewNumber.text = item.chapter.number.toString() binding.textViewNumber.text = item.chapter.number.toString()
when (item.extra) { binding.textViewDescription.textAndVisible = item.description()
ChapterExtra.UNREAD -> { }
when (item.status) {
FLAG_UNREAD -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default) binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse)) binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse))
} }
ChapterExtra.READ -> { FLAG_CURRENT -> {
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 -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent) binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse)) binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
} }
else -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
} }
binding.textViewTitle.alpha = if (item.isMissing) 0.3f else 1f }
binding.textViewNumber.alpha = if (item.isMissing) 0.3f else 1f 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 return items[position].chapter.id
} }
fun setItems(newItems: List<ChapterListItem>, callback: Runnable) {
differ.submitList(newItems, callback)
}
private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() { private class DiffCallback : DiffUtil.ItemCallback<ChapterListItem>() {
override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean { override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean {
@ -37,8 +33,8 @@ class ChaptersAdapter(
} }
override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? { override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? {
if (oldItem.extra != newItem.extra && oldItem.chapter == newItem.chapter) { if (oldItem.flags != newItem.flags && oldItem.chapter == newItem.chapter) {
return newItem.extra return newItem.flags
} }
return null return null
} }

@ -4,20 +4,14 @@ import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Rect import android.graphics.Rect
import androidx.collection.ArraySet
import androidx.core.content.ContextCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.resolveDp
class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoration() { 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 bounds = Rect()
private val selection = ArraySet<Long>() private val selection = HashSet<Long>()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
init { init {
@ -54,7 +48,6 @@ class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoratio
} }
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
icon ?: return
canvas.save() canvas.save()
if (parent.clipToPadding) { if (parent.clipToPadding) {
canvas.clipRect( canvas.clipRect(
@ -73,36 +66,4 @@ class ChaptersSelectionDecoration(context: Context) : RecyclerView.ItemDecoratio
} }
canvas.restore() 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 package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.history.domain.ChapterExtra
data class ChapterListItem( class ChapterListItem(
val chapter: MangaChapter, val chapter: MangaChapter,
val extra: ChapterExtra, val flags: Int,
val isMissing: Boolean, 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 package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaChapter 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( fun MangaChapter.toListItem(
extra: ChapterExtra, isCurrent: Boolean,
isUnread: Boolean,
isNew: Boolean,
isMissing: 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, chapter = this,
extra = extra, flags = flags,
isMissing = isMissing, uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null
) )
}

@ -145,7 +145,7 @@ class DownloadManager(
while (true) { while (true) {
try { try {
val response = call.clone().await() val response = call.clone().await()
withContext(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
file.outputStream().use { out -> file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out) checkNotNull(response.body).byteStream().copyTo(out)
} }

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.download.ui package org.koitharu.kotatsu.download.ui
import androidx.core.view.isVisible import androidx.core.view.isVisible
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -10,12 +11,11 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.utils.JobStateFlow import org.koitharu.kotatsu.utils.JobStateFlow
import org.koitharu.kotatsu.utils.ext.format import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
fun downloadItemAD( fun downloadItemAD(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader,
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>( ) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) { ) {
@ -24,11 +24,16 @@ fun downloadItemAD(
bind { bind {
job?.cancel() 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.textViewTitle.text = state.manga.title
binding.imageViewCover.setImageDrawable(
state.cover ?: getDrawable(R.drawable.ic_placeholder)
)
when (state) { when (state) {
is DownloadManager.State.Cancelling -> { is DownloadManager.State.Cancelling -> {
binding.textViewStatus.setText(R.string.cancelling_) binding.textViewStatus.setText(R.string.cancelling_)

@ -3,14 +3,17 @@ package org.koitharu.kotatsu.download.ui
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.koin.android.ext.android.get
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
@ -22,7 +25,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(lifecycleScope) val adapter = DownloadsAdapter(lifecycleScope, get())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
LifecycleAwareServiceConnection.bindService( LifecycleAwareServiceConnection.bindService(
@ -44,11 +47,15 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
right = insets.right, right = insets.right,
bottom = insets.bottom bottom = insets.bottom
) )
binding.toolbar.updatePadding( with(binding.toolbar) {
updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right
top = insets.top
) )
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
} }
companion object { companion object {

@ -1,6 +1,7 @@
package org.koitharu.kotatsu.download.ui package org.koitharu.kotatsu.download.ui
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
@ -8,10 +9,11 @@ import org.koitharu.kotatsu.utils.JobStateFlow
class DownloadsAdapter( class DownloadsAdapter(
scope: CoroutineScope, scope: CoroutineScope,
coil: ImageLoader,
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(downloadItemAD(scope)) delegatesManager.addDelegate(downloadItemAD(scope, coil))
setHasStableIds(true) setHasStableIds(true)
} }

@ -16,7 +16,6 @@ import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -79,6 +78,11 @@ class DownloadService : BaseService() {
return binder ?: DownloadBinder(this).also { binder = it } return binder ?: DownloadBinder(this).also { binder = it }
} }
override fun onUnbind(intent: Intent?): Boolean {
binder = null
return super.onUnbind(intent)
}
override fun onDestroy() { override fun onDestroy() {
unregisterReceiver(controlReceiver) unregisterReceiver(controlReceiver)
binder = null binder = null

@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.SortOrder
import java.util.* import java.util.*
@Entity(tableName = "favourite_categories") @Entity(tableName = "favourite_categories")
data class FavouriteCategoryEntity( class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,

@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
) )
] ]
) )
data class FavouriteEntity( class FavouriteEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "category_id", index = true) val categoryId: Long, @ColumnInfo(name = "category_id", index = true) val categoryId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long @ColumnInfo(name = "created_at") val createdAt: Long

@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
data class FavouriteManga( class FavouriteManga(
@Embedded val favourite: FavouriteEntity, @Embedded val favourite: FavouriteEntity,
@Relation( @Relation(
parentColumn = "manga_id", parentColumn = "manga_id",

@ -30,9 +30,7 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
override val recycledViewPool = RecyclerView.RecycledViewPool() override val recycledViewPool = RecyclerView.RecycledViewPool()
private val viewModel by viewModel<FavouritesCategoriesViewModel>( private val viewModel by viewModel<FavouritesCategoriesViewModel>()
mode = LazyThreadSafetyMode.NONE
)
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
CategoriesEditDelegate(requireContext(), this) CategoriesEditDelegate(requireContext(), this)
} }

@ -2,8 +2,6 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.View import android.view.View
@ -12,9 +10,9 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDividerItemDecoration
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R 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.core.model.SortOrder
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu import org.koitharu.kotatsu.utils.ext.showPopupMenu
class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(), class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<FavouriteCategory>, OnListItemClickListener<FavouriteCategory>,
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback { View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
private val viewModel by viewModel<FavouritesCategoriesViewModel>( private val viewModel by viewModel<FavouritesCategoriesViewModel>()
mode = LazyThreadSafetyMode.NONE
)
private lateinit var adapter: CategoriesAdapter private lateinit var adapter: CategoriesAdapter
private lateinit var reorderHelper: ItemTouchHelper private lateinit var reorderHelper: ItemTouchHelper
@ -42,10 +39,9 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.fabAdd.imageTintList = ColorStateList.valueOf(Color.WHITE)
adapter = CategoriesAdapter(this) adapter = CategoriesAdapter(this)
editDelegate = CategoriesEditDelegate(this, 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.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this) binding.fabAdd.setOnClickListener(this)
@ -95,13 +91,17 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right,
bottom = insets.bottom bottom = 2 * insets.bottom + binding.fabAdd.measureHeight()
) )
binding.toolbar.updatePadding( with(binding.toolbar) {
updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right
top = insets.top
) )
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
} }
private fun onCategoriesChanged(categories: List<FavouriteCategory>) { private fun onCategoriesChanged(categories: List<FavouriteCategory>) {

@ -2,7 +2,8 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context import android.content.Context
import android.text.InputType 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.R
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
@ -13,7 +14,7 @@ class CategoriesEditDelegate(
) { ) {
fun deleteCategory(category: FavouriteCategory) { fun deleteCategory(category: FavouriteCategory) {
AlertDialog.Builder(context) MaterialAlertDialogBuilder(context)
.setMessage(context.getString(R.string.category_delete_confirm, category.title)) .setMessage(context.getString(R.string.category_delete_confirm, category.title))
.setTitle(R.string.remove_category) .setTitle(R.string.remove_category)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
@ -32,7 +33,12 @@ class CategoriesEditDelegate(
.setNegativeButton(android.R.string.cancel) .setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false) .setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.rename) { _, name -> .setPositiveButton(R.string.rename) { _, name ->
val trimmed = name.trim()
if (trimmed.isEmpty()) {
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
} else {
callback.onRenameCategory(category, name) callback.onRenameCategory(category, name)
}
}.create() }.create()
.show() .show()
} }
@ -45,7 +51,12 @@ class CategoriesEditDelegate(
.setNegativeButton(android.R.string.cancel) .setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false) .setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.add) { _, name -> .setPositiveButton(R.string.add) { _, name ->
callback.onCreateCategory(name) val trimmed = name.trim()
if (trimmed.isEmpty()) {
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
} else {
callback.onCreateCategory(trimmed)
}
}.create() }.create()
.show() .show()
} }

@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback, OnListItemClickListener<MangaCategoryItem>, CategoriesEditDelegate.CategoriesEditCallback,
View.OnClickListener { View.OnClickListener {
private val viewModel by viewModel<MangaCategoriesViewModel>(mode = LazyThreadSafetyMode.NONE) { private val viewModel by viewModel<MangaCategoriesViewModel> {
parametersOf(requireNotNull(arguments?.getParcelable<Manga>(MangaIntent.KEY_MANGA))) parametersOf(requireNotNull(arguments?.getParcelable<Manga>(MangaIntent.KEY_MANGA)))
} }
@ -36,7 +36,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet<DialogFavoriteCategoriesBindin
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup? container: ViewGroup?,
) = DialogFavoriteCategoriesBinding.inflate(inflater, container, false) ) = DialogFavoriteCategoriesBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

@ -12,7 +12,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment() { class FavouritesListFragment : MangaListFragment() {
override val viewModel by viewModel<FavouritesListViewModel>(mode = LazyThreadSafetyMode.NONE) { override val viewModel by viewModel<FavouritesListViewModel> {
parametersOf(categoryId) parametersOf(categoryId)
} }

@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule val historyModule
get() = module { get() = module {
single { HistoryRepository(get(), get()) } single { HistoryRepository(get(), get(), get()) }
viewModel { HistoryListViewModel(get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get()) }
} }

@ -18,14 +18,14 @@ import java.util.*
) )
] ]
) )
data class HistoryEntity( class HistoryEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long, @ColumnInfo(name = "updated_at") val updatedAt: Long,
@ColumnInfo(name = "chapter_id") val chapterId: Long, @ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "page") val page: Int, @ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float @ColumnInfo(name = "scroll") val scroll: Float,
) { ) {
fun toMangaHistory() = MangaHistory( fun toMangaHistory() = MangaHistory(

@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
data class HistoryWithManga( class HistoryWithManga(
@Embedded val history: HistoryEntity, @Embedded val history: HistoryEntity,
@Relation( @Relation(
parentColumn = "manga_id", parentColumn = "manga_id",

@ -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.Manga
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
@ -17,6 +18,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet
class HistoryRepository( class HistoryRepository(
private val db: MangaDatabase, private val db: MangaDatabase,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
) { ) {
suspend fun getList(offset: Int, limit: Int = 20): List<Manga> { suspend fun getList(offset: Int, limit: Int = 20): List<Manga> {
@ -46,6 +48,9 @@ class HistoryRepository(
} }
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) { suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) {
if (manga.isNsfw && settings.isHistoryExcludeNsfw) {
return
}
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.withTransaction { db.withTransaction {
db.tagsDao.upsert(tags) db.tagsDao.upsert(tags)

@ -5,7 +5,7 @@ import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -15,7 +15,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize
class HistoryListFragment : MangaListFragment() { class HistoryListFragment : MangaListFragment() {
override val viewModel by viewModel<HistoryListViewModel>(mode = LazyThreadSafetyMode.NONE) override val viewModel by viewModel<HistoryListViewModel>()
override val isSwipeRefreshEnabled = false override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -42,7 +42,7 @@ class HistoryListFragment : MangaListFragment() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_clear_history -> { R.id.action_clear_history -> {
AlertDialog.Builder(context ?: return false) MaterialAlertDialogBuilder(context ?: return false)
.setTitle(R.string.clear_history) .setTitle(R.string.clear_history)
.setMessage(R.string.text_clear_history_prompt) .setMessage(R.string.text_clear_history_prompt)
.setNegativeButton(android.R.string.cancel, null) .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

Loading…
Cancel
Save