diff --git a/.gitignore b/.gitignore index 2968239c3..a8c7b78f4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /local.properties /.idea/caches /.idea/libraries +/.idea/dictionaries /.idea/modules.xml /.idea/misc.xml /.idea/workspace.xml diff --git a/.idea/dictionaries/admin.xml b/.idea/dictionaries/admin.xml deleted file mode 100644 index 64cf6888c..000000000 --- a/.idea/dictionaries/admin.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - amoled - chucker - desu - failsafe - koin - kotatsu - manga - snackbar - upsert - webtoon - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 35ffc652f..a0de2a152 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -14,7 +14,6 @@ - diff --git a/README.md b/README.md index ce1897442..fddaf1d25 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Kotatsu is a free and open source manga reader for Android. -![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) +![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) ### Download diff --git a/app/build.gradle b/app/build.gradle index bf0ec6b6a..c6eaae16d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,16 +6,17 @@ plugins { } android { - compileSdkVersion 30 + compileSdkVersion 31 buildToolsVersion '30.0.3' defaultConfig { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 - targetSdkVersion 30 - versionCode 367 - versionName '1.1.2' + targetSdkVersion 31 + versionCode 375 + versionName '2.1-b1' generatedDensities = [] + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" kapt { arguments { @@ -41,6 +42,9 @@ android { buildFeatures { viewBinding true } + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } lintOptions { disable 'MissingTranslation' abortOnError false @@ -55,53 +59,61 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { jvmTarget = JavaVersion.VERSION_1_8.toString() freeCompilerArgs += [ '-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', - '-Xopt-in=kotlinx.coroutines.FlowPreview', - '-Xopt-in=org.koin.core.component.KoinApiExtension' + '-Xopt-in=kotlin.contracts.ExperimentalContracts', ] } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0' - implementation 'androidx.core:core-ktx:1.5.0' - implementation 'androidx.activity:activity-ktx:1.2.3' - implementation 'androidx.fragment:fragment-ktx:1.3.5' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-service:2.3.1' - implementation 'androidx.lifecycle:lifecycle-process:2.3.1' - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'androidx.activity:activity-ktx:1.4.0' + implementation 'androidx.fragment:fragment-ktx:1.4.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-service:2.4.0' + implementation 'androidx.lifecycle:lifecycle-process:2.4.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.2' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.2.1' - implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01' + implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.preference:preference-ktx:1.1.1' - implementation 'androidx.work:work-runtime-ktx:2.6.0-beta01' - implementation 'com.google.android.material:material:1.3.0' + implementation 'androidx.work:work-runtime-ktx:2.7.1' + implementation 'com.google.android.material:material:1.4.0' //noinspection LifecycleAnnotationProcessorWithJava8 - kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1' + kapt 'androidx.lifecycle:lifecycle-compiler:2.4.0' - implementation 'androidx.room:room-runtime:2.3.0' - implementation 'androidx.room:room-ktx:2.3.0' - kapt 'androidx.room:room-compiler:2.3.0' + implementation 'androidx.room:room-runtime:2.4.0' + implementation 'androidx.room:room-ktx:2.4.0' + kapt 'androidx.room:room-compiler:2.4.0' implementation 'com.squareup.okhttp3:okhttp:4.9.1' implementation 'com.squareup.okio:okio:2.10.0' - implementation 'org.jsoup:jsoup:1.13.1' + implementation 'org.jsoup:jsoup:1.14.3' - implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0' - implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.0' + implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.1' + implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.1' - implementation 'io.insert-koin:koin-android:3.1.2' - implementation 'io.coil-kt:coil-base:1.2.2' + implementation 'io.insert-koin:koin-android:3.1.4' + implementation 'io.coil-kt:coil-base:1.4.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' - implementation 'com.github.solkin:disk-lru-cache:1.2' + implementation 'com.github.solkin:disk-lru-cache:1.3' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' testImplementation 'junit:junit:4.13.2' - testImplementation 'org.json:json:20210307' - testImplementation 'io.insert-koin:koin-test-junit4:3.1.2' + testImplementation 'com.google.truth:truth:1.1.3' + testImplementation 'org.json:json:20211205' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0' + testImplementation 'io.insert-koin:koin-test-junit4:3.1.4' + + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test:rules:1.4.0' + androidTestImplementation 'androidx.test:core-ktx:1.4.0' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3' + androidTestImplementation 'androidx.room:room-testing:2.4.0' + androidTestImplementation 'com.google.truth:truth:1.1.3' } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 57fe6234e..74c093891 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -5,9 +5,7 @@ public static void checkReturnedValueIsNotNull(...); public static void checkFieldIsNotNull(...); public static void checkParameterIsNotNull(...); + public static void checkNotNullParameter(...); } -keep class org.koitharu.kotatsu.core.db.entity.* { *; } --keepclassmembers public class * extends org.koitharu.kotatsu.core.parser.MangaRepository { - public (...); -} -dontwarn okhttp3.internal.platform.ConscryptPlatform \ No newline at end of file diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt new file mode 100644 index 000000000..f0f37c2a1 --- /dev/null +++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt @@ -0,0 +1,55 @@ +package org.koitharu.kotatsu.core.db + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koitharu.kotatsu.core.db.migrations.* +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class MangaDatabaseTest { + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + MangaDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + @Test + @Throws(IOException::class) + fun migrateAll() { + helper.createDatabase(TEST_DB, 1).apply { + // TODO execSQL("") + close() + } + for (migration in migrations) { + helper.runMigrationsAndValidate( + TEST_DB, + migration.endVersion, + true, + migration + ) + } + } + + + private companion object { + + const val TEST_DB = "test-db" + + val migrations = arrayOf( + Migration1To2(), + Migration2To3(), + Migration3To4(), + Migration4To5(), + Migration5To6(), + Migration6To7(), + Migration7To8(), + ) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78f461918..2cc8e53e2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,9 +21,11 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme" - android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config" tools:ignore="UnusedAttribute"> - + @@ -32,12 +34,16 @@ android:name="android.app.default_searchable" android:value=".ui.search.SearchActivity" /> - + - + @@ -50,13 +56,19 @@ android:label="@string/settings" /> - + + @@ -83,9 +96,13 @@ + + + form: Map, ): Response { val body = FormBody.Builder() form.forEach { (k, v) -> @@ -38,7 +44,7 @@ open class MangaLoaderContext( suspend fun httpPost( url: String, - payload: String + payload: String, ): Response { val body = FormBody.Builder() payload.split('&').forEach { @@ -55,20 +61,24 @@ open class MangaLoaderContext( return okHttp.newCall(request.build()).await() } - open fun getSettings(source: MangaSource) = SourceSettings(get(), source) - - fun insertCookies(domain: String, vararg cookies: String) { - val url = HttpUrl.Builder() - .scheme(SCHEME_HTTP) - .host(domain) - .build() - cookieJar.saveFromResponse(url, cookies.mapNotNull { - Cookie.parse(url, it) - }) + suspend fun graphQLQuery(endpoint: String, query: String): JSONObject { + val body = JSONObject() + body.put("operationName", null) + body.put("variables", JSONObject()) + body.put("query", "{${query}}") + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = body.toString().toRequestBody(mediaType) + val request = Request.Builder() + .post(requestBody) + .url(endpoint) + val json = okHttp.newCall(request.build()).await().parseJson() + json.optJSONArray("errors")?.let { + if (it.length() != 0) { + throw GraphQLException(it) + } + } + return json } - private companion object { - - private const val SCHEME_HTTP = "http" - } + open fun getSettings(source: MangaSource) = SourceSettings(get(), source) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaProviderFactory.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaProviderFactory.kt index 12771e5ea..0223031d9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaProviderFactory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaProviderFactory.kt @@ -8,7 +8,6 @@ object MangaProviderFactory { fun getSources(settings: AppSettings, includeHidden: Boolean): List { val list = MangaSource.values().toList() - MangaSource.LOCAL val order = settings.sourcesOrder - val hidden = settings.hiddenSources val sorted = list.sortedBy { x -> val e = order.indexOf(x.ordinal) if (e == -1) order.size + x.ordinal else e @@ -16,6 +15,7 @@ object MangaProviderFactory { return if (includeHidden) { sorted } else { + val hidden = settings.hiddenSources sorted.filterNot { x -> x.name in hidden } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt index 2c4421399..5e91880a1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.base.ui +import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.KeyEvent @@ -60,11 +61,11 @@ abstract class BaseActivity : AppCompatActivity(), OnApplyWindo toolbar?.let(this::setSupportActionBar) ViewCompat.setOnApplyWindowInsetsListener(binding.root, this) - val toolbarParams = (toolbar ?: binding.root.findViewById(R.id.toolbar_card)) + val toolbarParams = (binding.root.findViewById(R.id.toolbar_card) ?: toolbar) ?.layoutParams as? AppBarLayout.LayoutParams if (toolbarParams != null) { if (get().isToolbarHideWhenScrolling) { - toolbarParams.scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS + toolbarParams.scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP } else { toolbarParams.scrollFlags = SCROLL_FLAG_NO_SCROLL } @@ -101,6 +102,12 @@ abstract class BaseActivity : AppCompatActivity(), OnApplyWindo (findViewById(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar) } + protected fun isDarkAmoledTheme(): Boolean { + val uiMode = resources.configuration.uiMode + val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + return isNight && get().isAmoledTheme + } + override fun onSupportActionModeStarted(mode: ActionMode) { super.onSupportActionModeStarted(mode) val insets = ViewCompat.getRootWindowInsets(binding.root) diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt index 27f923937..76f774bc8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseFragment.kt @@ -38,6 +38,7 @@ abstract class BaseFragment : Fragment(), OnApplyWindowInsetsLi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + lastInsets = Insets.NONE ViewCompat.setOnApplyWindowInsetsListener(view, this) } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt index 74a019a82..5b843358f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import android.widget.BaseAdapter import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemStorageBinding import org.koitharu.kotatsu.local.domain.LocalMangaRepository diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt index b645d8cd8..c2ff116eb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/TextInputDialog.kt @@ -6,11 +6,10 @@ import android.text.InputFilter import android.view.LayoutInflater import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.databinding.DialogInputBinding class TextInputDialog private constructor( - private val delegate: AlertDialog + private val delegate: AlertDialog, ) : DialogInterface by delegate { fun show() = delegate.show() @@ -33,7 +32,7 @@ class TextInputDialog private constructor( } fun setHint(@StringRes hintResId: Int): Builder { - binding.inputLayout.hint = binding.root.context.getString(hintResId) + binding.inputEdit.hint = binding.root.context.getString(hintResId) return this } @@ -64,7 +63,7 @@ class TextInputDialog private constructor( listener: (DialogInterface, String) -> Unit ): Builder { delegate.setPositiveButton(textId) { dialog, _ -> - listener(dialog, binding.inputEdit.text.toString().orEmpty()) + listener(dialog, binding.inputEdit.text?.toString().orEmpty()) } return this } diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterUpdater.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterUpdater.kt deleted file mode 100644 index 9e238487a..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/AdapterUpdater.kt +++ /dev/null @@ -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(oldList: List, newList: List, 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) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BaseViewHolder.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BaseViewHolder.kt deleted file mode 100644 index b878524ca..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/BaseViewHolder.kt +++ /dev/null @@ -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 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) -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt new file mode 100644 index 000000000..154303a91 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/AbstractDividerItemDecoration.kt @@ -0,0 +1,65 @@ +package org.koitharu.kotatsu.base.ui.list.decor + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.view.View +import androidx.core.view.children +import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.utils.ext.getThemeDrawable +import kotlin.math.roundToInt + +abstract class AbstractDividerItemDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val bounds = Rect() + private val divider = context.getThemeDrawable(android.R.attr.listDivider) + + override fun getItemOffsets( + outRect: Rect, + view: View, + parent: RecyclerView, + state: RecyclerView.State, + ) { + outRect.set(0, divider?.intrinsicHeight ?: 0, 0, 0) + } + + // TODO implement for horizontal lists on demand + override fun onDraw(canvas: Canvas, parent: RecyclerView, s: RecyclerView.State) { + if (parent.layoutManager == null || divider == null) { + return + } + canvas.save() + val left: Int + val right: Int + if (parent.clipToPadding) { + left = parent.paddingLeft + right = parent.width - parent.paddingRight + canvas.clipRect( + left, parent.paddingTop, right, + parent.height - parent.paddingBottom + ) + } else { + left = 0 + right = parent.width + } + + var previous: RecyclerView.ViewHolder? = null + for (child in parent.children) { + val holder = parent.getChildViewHolder(child) + if (previous != null && shouldDrawDivider(previous, holder)) { + parent.getDecoratedBoundsWithMargins(child, bounds) + val top: Int = bounds.top + child.translationY.roundToInt() + val bottom: Int = top + divider.intrinsicHeight + divider.setBounds(left, top, right, bottom) + divider.draw(canvas) + } + previous = holder + } + canvas.restore() + } + + protected abstract fun shouldDrawDivider( + above: RecyclerView.ViewHolder, + below: RecyclerView.ViewHolder, + ): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SectionItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SectionItemDecoration.kt deleted file mode 100644 index d8181e5c7..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/SectionItemDecoration.kt +++ /dev/null @@ -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(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? - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt new file mode 100644 index 000000000..f17b31f84 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/AnimatedToolbar.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.base.ui.widgets + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import androidx.appcompat.widget.Toolbar +import androidx.core.view.isGone +import com.google.android.material.R +import com.google.android.material.appbar.MaterialToolbar +import java.lang.reflect.Field + +class AnimatedToolbar @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.toolbarStyle, +) : MaterialToolbar(context, attrs, defStyleAttr) { + + private var navButtonView: View? = null + get() { + if (field == null) { + runCatching { + field = navButtonViewField?.get(this) as? View + } + } + return field + } + + override fun setNavigationIcon(icon: Drawable?) { + super.setNavigationIcon(icon) + navButtonView?.isGone = (icon == null) + } + + private companion object { + + val navButtonViewField: Field? = runCatching { + Toolbar::class.java.getDeclaredField("mNavButtonView") + .also { it.isAccessible = true } + }.getOrNull() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt index e766dbf08..d9ee9f535 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ChipsView.kt @@ -22,12 +22,21 @@ class ChipsView @JvmOverloads constructor( private var chipOnClickListener = OnClickListener { onChipClickListener?.onChipClick(it as Chip, it.tag) } + private var chipOnCloseListener = OnClickListener { + onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag) + } var onChipClickListener: OnChipClickListener? = null set(value) { field = value val isChipClickable = value != null children.forEach { it.isClickable = isChipClickable } } + var onChipCloseClickListener: OnChipCloseClickListener? = null + set(value) { + field = value + val isCloseIconVisible = value != null + children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible } + } override fun requestLayout() { if (isLayoutSuppressedCompat) { @@ -37,15 +46,15 @@ class ChipsView @JvmOverloads constructor( } } - fun setChips(items: List) { + fun setChips(items: Collection) { suppressLayoutCompat(true) try { for ((i, model) in items.withIndex()) { val chip = getChildAt(i) as Chip? ?: addChip() bindChip(chip, model) } - for (i in items.size until childCount) { - removeViewAt(i) + if (childCount > items.size) { + removeViews(items.size, childCount - items.size) } } finally { suppressLayoutCompat(false) @@ -60,6 +69,7 @@ class ChipsView @JvmOverloads constructor( chip.isCheckedIconVisible = true chip.setChipIconResource(model.icon) } + chip.isClickable = onChipClickListener != null chip.tag = model.data } @@ -67,11 +77,11 @@ class ChipsView @JvmOverloads constructor( val chip = Chip(context) val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) chip.setChipDrawable(drawable) - chip.setTextColor(ContextCompat.getColor(context, R.color.blue_primary)) - chip.isCloseIconVisible = false + chip.setTextColor(ContextCompat.getColor(context, R.color.color_primary)) + chip.isCloseIconVisible = onChipCloseClickListener != null + chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setEnsureMinTouchTargetSize(false) chip.setOnClickListener(chipOnClickListener) - chip.isClickable = onChipClickListener != null addView(chip) return chip } @@ -86,14 +96,40 @@ class ChipsView @JvmOverloads constructor( } } - data class ChipModel( + class ChipModel( @DrawableRes val icon: Int, val title: CharSequence, val data: Any? = null - ) + ) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ChipModel + + if (icon != other.icon) return false + if (title != other.title) return false + if (data != other.data) return false + + return true + } + + override fun hashCode(): Int { + var result = icon + result = 31 * result + title.hashCode() + result = 31 * result + data.hashCode() + return result + } + } fun interface OnChipClickListener { fun onChipClick(chip: Chip, data: Any?) } + + fun interface OnChipCloseClickListener { + + fun onChipCloseClick(chip: Chip, data: Any?) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt index 049452f4f..a4e7bf748 100644 --- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CoverImageView.kt @@ -6,40 +6,33 @@ import android.widget.LinearLayout import androidx.appcompat.widget.AppCompatImageView import androidx.core.content.withStyledAttributes import org.koitharu.kotatsu.R +import kotlin.math.roundToInt class CoverImageView @JvmOverloads constructor( - context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : AppCompatImageView(context, attrs, defStyleAttr) { private var orientation: Int = HORIZONTAL init { context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) { - orientation = getInt(R.styleable.CoverImageView_android_orientation, HORIZONTAL) + orientation = getInt(R.styleable.CoverImageView_android_orientation, orientation) } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val desiredWidth: Int + val desiredHeight: Int if (orientation == VERTICAL) { - val originalHeight = MeasureSpec.getSize(heightMeasureSpec) - super.onMeasure( - MeasureSpec.makeMeasureSpec( - (originalHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).toInt(), - MeasureSpec.EXACTLY - ), - MeasureSpec.makeMeasureSpec(originalHeight, MeasureSpec.EXACTLY) - ) + desiredHeight = measuredHeight + desiredWidth = (desiredHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt() } else { - val originalWidth = MeasureSpec.getSize(widthMeasureSpec) - super.onMeasure( - MeasureSpec.makeMeasureSpec(originalWidth, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec( - (originalWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).toInt(), - MeasureSpec.EXACTLY - ) - ) + desiredWidth = measuredWidth + desiredHeight = (desiredWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt() } + setMeasuredDimension(desiredWidth, desiredHeight) } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt new file mode 100644 index 000000000..8f41c695d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/FadingSnackbar.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt index e5be6808c..497d18499 100644 --- a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -92,8 +92,16 @@ class BrowserActivity : BaseActivity(), BrowserCallback } override fun onWindowInsetsChanged(insets: Insets) { - binding.appbar.updatePadding(top = insets.top) - binding.webView.updatePadding(bottom = insets.bottom) + binding.appbar.updatePadding( + top = insets.top, + left = insets.left, + right = insets.right, + ) + binding.root.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom, + ) } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt index 4a666d1c7..f39da5fb5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt +++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt @@ -1,10 +1,8 @@ package org.koitharu.kotatsu.browser import android.graphics.Bitmap -import android.webkit.WebResourceResponse import android.webkit.WebView import okhttp3.OkHttpClient -import okhttp3.Request import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.koitharu.kotatsu.core.network.WebViewClientCompat @@ -27,19 +25,4 @@ class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat super.onPageCommitVisible(view, url) callback.onTitleChanged(view.title.orEmpty(), url) } - - override fun shouldInterceptRequestCompat(view: WebView, url: String): WebResourceResponse? { - return runCatching { - val request = Request.Builder() - .url(url) - .build() - val response = okHttp.newCall(request).execute() - val ct = response.body?.contentType() - WebResourceResponse( - "${ct?.type}/${ct?.subtype}", - ct?.charset()?.name() ?: "utf-8", - response.body?.byteStream() - ) - }.getOrNull() - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt index 5de7d7e01..407365d3c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.backup import org.json.JSONArray -data class BackupEntry( +class BackupEntry( val name: String, val data: JSONArray ) { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt index 6d934330a..eb6cb8579 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt @@ -118,6 +118,7 @@ class BackupRepository(private val db: MangaDatabase) { jo.put("created_at", createdAt) jo.put("sort_key", sortKey) jo.put("title", title) + jo.put("order", order) return jo } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt index 86b732b27..9626ddb13 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt @@ -5,6 +5,7 @@ import org.json.JSONObject import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.TagEntity +import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.history.data.HistoryEntity @@ -101,7 +102,8 @@ class RestoreRepository(private val db: MangaDatabase) { categoryId = json.getInt("category_id"), createdAt = json.getLong("created_at"), sortKey = json.getInt("sort_key"), - title = json.getString("title") + title = json.getString("title"), + order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name, ) private fun parseFavourite(json: JSONObject) = FavouriteEntity( diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt index de6aead18..dc390f0f3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt @@ -20,6 +20,7 @@ val databaseModule Migration5To6(), Migration6To7(), Migration7To8(), + Migration8To9(), ).addCallback( DatabasePrePopulateCallback(androidContext().resources) ).build() diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt index ce8749d9d..79d0fbd34 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt @@ -4,13 +4,14 @@ import android.content.res.Resources import androidx.room.RoomDatabase import androidx.sqlite.db.SupportSQLiteDatabase import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.SortOrder class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { db.execSQL( - "INSERT INTO favourite_categories (created_at, sort_key, title) VALUES (?,?,?)", - arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later)) + "INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)", + arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name) ) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt index f0844571e..04b1ba764 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -16,7 +16,7 @@ import org.koitharu.kotatsu.history.data.HistoryEntity MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class - ], version = 8 + ], version = 9 ) abstract class MangaDatabase : RoomDatabase() { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt index d20b3b979..25ec915a3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt @@ -9,7 +9,7 @@ import org.koitharu.kotatsu.core.model.MangaState import org.koitharu.kotatsu.core.model.MangaTag @Entity(tableName = "manga") -data class MangaEntity( +class MangaEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "title") val title: String, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt index 7eb68a0ef..99e94b25a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt @@ -14,7 +14,7 @@ import androidx.room.PrimaryKey onDelete = ForeignKey.CASCADE )] ) -data class MangaPrefsEntity( +class MangaPrefsEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "mode") val mode: Int diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt index b96ff5d83..ea7f0b3d2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt @@ -20,7 +20,7 @@ import androidx.room.ForeignKey ) ] ) -data class MangaTagsEntity( +class MangaTagsEntity( @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "tag_id", index = true) val tagId: Long ) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt index 950fbdf33..ad80b0beb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaWithTags.kt @@ -5,7 +5,7 @@ import androidx.room.Junction import androidx.room.Relation import org.koitharu.kotatsu.utils.ext.mapToSet -data class MangaWithTags( +class MangaWithTags( @Embedded val manga: MangaEntity, @Relation( parentColumn = "manga_id", diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt index 896732e15..4459d56b8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/SuggestionEntity.kt @@ -16,7 +16,7 @@ import androidx.room.PrimaryKey ) ] ) -data class SuggestionEntity( +class SuggestionEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "relevance") val relevance: Float, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt index 1af937d06..3e25b0bed 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt @@ -8,7 +8,7 @@ import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.utils.ext.longHashCode @Entity(tableName = "tags") -data class TagEntity( +class TagEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "tag_id") val id: Long, @ColumnInfo(name = "title") val title: String, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt index 57a01888f..1b5c41492 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackEntity.kt @@ -15,7 +15,7 @@ import androidx.room.PrimaryKey ) ] ) -data class TrackEntity( +class TrackEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "chapters_total") val totalChapters: Int, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt index e73d34d74..a4f608c4d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogEntity.kt @@ -15,7 +15,7 @@ import androidx.room.PrimaryKey ) ] ) -data class TrackLogEntity( +class TrackLogEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt index e32bcde6a..263e91938 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TrackLogWithManga.kt @@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.model.TrackingLogItem import org.koitharu.kotatsu.utils.ext.mapToSet import java.util.* -data class TrackLogWithManga( +class TrackLogWithManga( @Embedded val trackLog: TrackLogEntity, @Relation( parentColumn = "manga_id", diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt new file mode 100644 index 000000000..84eedc797 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration8To9.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import org.koitharu.kotatsu.core.model.SortOrder + +class Migration8To9 : Migration(8, 9) { + + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `order` TEXT NOT NULL DEFAULT ${SortOrder.NEWEST.name}") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/exceptions/GraphQLException.kt b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/GraphQLException.kt new file mode 100644 index 000000000..92cdb325f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/exceptions/GraphQLException.kt @@ -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") +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt index 758dd5f46..09557cb47 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/github/VersionId.kt @@ -2,12 +2,12 @@ package org.koitharu.kotatsu.core.github import java.util.* -data class VersionId( +class VersionId( val major: Int, val minor: Int, val build: Int, val variantType: String, - val variantNumber: Int + val variantNumber: Int, ) : Comparable { override fun compareTo(other: VersionId): Int { @@ -30,10 +30,34 @@ data class VersionId( return variantNumber.compareTo(other.variantNumber) } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VersionId + + if (major != other.major) return false + if (minor != other.minor) return false + if (build != other.build) return false + if (variantType != other.variantType) return false + if (variantNumber != other.variantNumber) return false + + return true + } + + override fun hashCode(): Int { + var result = major + result = 31 * result + minor + result = 31 * result + build + result = 31 * result + variantType.hashCode() + result = 31 * result + variantNumber + return result + } + companion object { private fun variantWeight(variantType: String) = - when (variantType.toLowerCase(Locale.ROOT)) { + when (variantType.lowercase(Locale.ROOT)) { "a", "alpha" -> 1 "b", "beta" -> 2 "rc" -> 4 diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt index 5eedf24b8..6998a1a08 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt @@ -9,5 +9,6 @@ data class FavouriteCategory( val id: Long, val title: String, val sortKey: Int, - val createdAt: Date + val order: SortOrder, + val createdAt: Date, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaChapter.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaChapter.kt index 0682eadc6..9b0533722 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaChapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaChapter.kt @@ -9,6 +9,13 @@ data class MangaChapter( val name: String, val number: Int, val url: String, - val branch: String? = null, - val source: MangaSource -) : Parcelable \ No newline at end of file + val scanlator: String?, + val uploadDate: Long, + val branch: String?, + val source: MangaSource, +) : Parcelable, Comparable { + + override fun compareTo(other: MangaChapter): Int { + return number.compareTo(other.number) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt index 68a3b5174..498492f24 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaFilter.kt @@ -5,6 +5,6 @@ import kotlinx.parcelize.Parcelize @Parcelize data class MangaFilter( - val sortOrder: SortOrder, - val tag: MangaTag? + val sortOrder: SortOrder?, + val tags: Set, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt index eaf04e6c7..95f736f2b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaHistory.kt @@ -10,5 +10,5 @@ data class MangaHistory( val updatedAt: Date, val chapterId: Long, val page: Int, - val scroll: Int + val scroll: Int, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaPage.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaPage.kt index a85dc14bc..7576e2da4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaPage.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaPage.kt @@ -8,6 +8,6 @@ data class MangaPage( val id: Long, val url: String, val referer: String, - val preview: String? = null, - val source: MangaSource + val preview: String?, + val source: MangaSource, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt index 3f8f07973..4ef49374c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -14,7 +14,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository enum class MangaSource( val title: String, val locale: String?, - val cls: Class + val cls: Class, ) : Parcelable { LOCAL("Local", null, LocalMangaRepository::class.java), READMANGA_RU("ReadManga", "ru", ReadmangaRepository::class.java), @@ -39,10 +39,14 @@ enum class MangaSource( NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java), NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java), NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java), + EXHENTAI("ExHentai", null, ExHentaiRepository::class.java), + MANGAOWL("MangaOwl", "en", MangaOwlRepository::class.java), + MANGADEX("MangaDex", null, MangaDexRepository::class.java), ; @get:Throws(NoBeanDefFoundException::class) - @Deprecated("") + @Deprecated("", ReplaceWith("MangaRepository(this)", + "org.koitharu.kotatsu.core.parser.MangaRepository")) val repository: MangaRepository get() = GlobalContext.get().get(named(this)) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTag.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTag.kt index be5aa63d2..fe10cb690 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTag.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTag.kt @@ -7,5 +7,5 @@ import kotlinx.parcelize.Parcelize data class MangaTag( val title: String, val key: String, - val source: MangaSource + val source: MangaSource, ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index afd162a81..18e5bbf55 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -6,4 +6,5 @@ object CommonHeaders { const val USER_AGENT = "User-Agent" const val ACCEPT = "Accept" const val CONTENT_DISPOSITION = "Content-Disposition" + const val COOKIE = "Cookie" } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt index 4d5c26d3b..6c41a5291 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -8,6 +8,7 @@ import org.koin.dsl.bind import org.koin.dsl.module import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.utils.CacheUtils +import org.koitharu.kotatsu.utils.DownloadManagerHelper import java.util.concurrent.TimeUnit val networkModule @@ -28,4 +29,5 @@ val networkModule } }.build() } + factory { DownloadManagerHelper(get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt index cd17805d4..c8904b2a8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -1,16 +1,19 @@ package org.koitharu.kotatsu.core.parser +import org.koin.core.component.KoinComponent +import org.koin.core.component.get +import org.koin.core.qualifier.named import org.koitharu.kotatsu.core.model.* interface MangaRepository { val sortOrders: Set - suspend fun getList( + suspend fun getList2( offset: Int, query: String? = null, + tags: Set? = null, sortOrder: SortOrder? = null, - tag: MangaTag? = null ): List suspend fun getDetails(manga: Manga): Manga @@ -20,4 +23,11 @@ interface MangaRepository { suspend fun getPageUrl(page: MangaPage): String suspend fun getTags(): Set + + companion object : KoinComponent { + + operator fun invoke(source: MangaSource): MangaRepository { + return get(named(source)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepositoryAuthProvider.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepositoryAuthProvider.kt new file mode 100644 index 000000000..16cc5e39f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepositoryAuthProvider.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.core.parser + +interface MangaRepositoryAuthProvider { + + val authUrl: String + + fun isAuthorized(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt index baf3156e3..c97f7a4eb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/ParserModule.kt @@ -32,4 +32,7 @@ val parserModule factory(named(MangaSource.NINEMANGA_RU)) { NineMangaRepository.Russian(get()) } factory(named(MangaSource.NINEMANGA_IT)) { NineMangaRepository.Italiano(get()) } factory(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) } + factory(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) } + factory(named(MangaSource.MANGAOWL)) { MangaOwlRepository(get()) } + factory(named(MangaSource.MANGADEX)) { MangaDexRepository(get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index ce91c0ddc..ef4c55817 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.parser import org.koitharu.kotatsu.base.domain.MangaLoaderContext +import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaTag @@ -19,6 +20,9 @@ abstract class RemoteMangaRepository( loaderContext.getSettings(source) } + val title: String + get() = source.title + override val sortOrders: Set get() = emptySet() override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain() @@ -75,4 +79,8 @@ abstract class RemoteMangaRepository( h = 31 * h + id return h } + + protected fun parseFailed(message: String? = null): Nothing { + throw ParseException(message) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt index 35c5e6187..03853cd1f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/AnibelRepository.kt @@ -1,10 +1,14 @@ package org.koitharu.kotatsu.core.parser.site +import androidx.collection.ArraySet +import org.json.JSONArray +import org.json.JSONObject import org.koitharu.kotatsu.base.domain.MangaLoaderContext -import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.map +import org.koitharu.kotatsu.utils.ext.mapIndexed +import org.koitharu.kotatsu.utils.ext.stringIterator import java.util.* class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { @@ -17,151 +21,239 @@ class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor SortOrder.NEWEST ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, + tags: Set?, sortOrder: SortOrder?, - tag: MangaTag? ): List { if (!query.isNullOrEmpty()) { - return if (offset == 0) search(query) else emptyList() - } - val page = (offset / 12f).toIntUp().inc() - val link = when { - tag != null -> "/manga?genre[]=${tag.key}&page=$page".withDomain() - else -> "/manga?page=$page".withDomain() + return if (offset == 0) { + search(query) + } else { + emptyList() + } } - val doc = loaderContext.httpGet(link).parseHtml() - val root = doc.body().select("div.manga-block") ?: throw ParseException("Cannot find root") - val items = root.select("div.anime-card") - return items.mapNotNull { card -> - val href = card.selectFirst("a").attr("href") - val status = card.select("tr")[2].text() - val fullTitle = card.selectFirst("h1.anime-card-title").text() - .substringBeforeLast('[') - val titleParts = fullTitle.splitTwoParts('/') + val filters = tags?.takeUnless { it.isEmpty() }?.joinToString( + separator = ",", + prefix = "genres: [", + postfix = "]" + ) { "\"it.key\"" }.orEmpty() + val array = apiCall( + """ + getMediaList(offset: $offset, limit: 20, mediaType: manga, filters: {$filters}) { + docs { + mediaId + title { + be + alt + } + rating + poster + genres + slug + mediaType + status + } + } + """.trimIndent() + ).getJSONObject("getMediaList").getJSONArray("docs") + return array.map { jo -> + val mediaId = jo.getString("mediaId") + val title = jo.getJSONObject("title") + val href = "${jo.getString("mediaType")}/${jo.getString("slug")}" Manga( - id = generateUid(href), - title = titleParts?.first?.trim() ?: fullTitle, - coverUrl = card.selectFirst("img").attr("data-src").withDomain(), - altTitle = titleParts?.second?.trim(), + id = generateUid(mediaId), + title = title.getString("be"), + coverUrl = jo.getString("poster").removePrefix("/cdn") + .withDomain("cdn") + "?width=200&height=280", + altTitle = title.getString("alt").takeUnless(String::isEmpty), author = null, - rating = Manga.NO_RATING, + rating = jo.getDouble("rating").toFloat() / 10f, url = href, - publicUrl = href.withDomain(), - tags = card.select("p.tupe.tag")?.select("a")?.mapNotNullToSet tags@{ x -> - MangaTag( - title = x.text(), - key = x.attr("href")?.substringAfterLast("=") ?: return@tags null, - source = source - ) - }.orEmpty(), - state = when (status) { - "выпускаецца" -> MangaState.ONGOING - "завершанае" -> MangaState.FINISHED + publicUrl = "https://${getDomain()}/${href}", + tags = jo.getJSONArray("genres").mapToTags(), + state = when (jo.getString("status")) { + "ongoing" -> MangaState.ONGOING + "finished" -> MangaState.FINISHED else -> null }, - source = source + source = source, ) } } override suspend fun getDetails(manga: Manga): Manga { - val doc = loaderContext.httpGet(manga.publicUrl).parseHtml() - val root = doc.body().select("div.container") ?: throw ParseException("Cannot find root") + val (type, slug) = manga.url.split('/') + val details = apiCall( + """ + media(mediaType: $type, slug: "$slug") { + mediaId + title { + be + alt + } + description { + be + } + status + poster + rating + genres + } + """.trimIndent() + ).getJSONObject("media") + val title = details.getJSONObject("title") + val poster = details.getString("poster").removePrefix("/cdn") + .withDomain("cdn") + val chapters = apiCall( + """ + chapters(mediaId: "${details.getString("mediaId")}") { + id + chapter + released + } + """.trimIndent() + ).getJSONArray("chapters") return manga.copy( - description = root.select("div.manga-block.grid-12")[2].select("p").text(), - chapters = root.select("ul.series").flatMap { table -> - table.select("li") - }.map { it.selectFirst("a") }.mapIndexedNotNull { i, a -> - val href = a.select("a").first().attr("href").toRelativeUrl(getDomain()) + title = title.getString("be"), + altTitle = title.getString("alt"), + coverUrl = "$poster?width=200&height=280", + largeCoverUrl = poster, + description = details.getJSONObject("description").getString("be"), + rating = details.getDouble("rating").toFloat() / 10f, + tags = details.getJSONArray("genres").mapToTags(), + state = when (details.getString("status")) { + "ongoing" -> MangaState.ONGOING + "finished" -> MangaState.FINISHED + else -> null + }, + chapters = chapters.map { jo -> + val number = jo.getInt("chapter") MangaChapter( - id = generateUid(href), - name = a.select("a").first().text(), - number = i + 1, - url = href, - source = source + id = generateUid(jo.getString("id")), + name = "Глава $number", + number = number, + url = "${manga.url}/read/$number", + scanlator = null, + uploadDate = jo.getLong("released"), + branch = null, + source = source, ) } ) } override suspend fun getPages(chapter: MangaChapter): List { - val fullUrl = chapter.url.withDomain() - val doc = loaderContext.httpGet(fullUrl).parseHtml() - val scripts = doc.select("script") - for (script in scripts) { - val data = script.html() - val pos = data.indexOf("dataSource") - if (pos == -1) { - continue - } - val json = data.substring(pos).substringAfter('[').substringBefore(']') - val domain = getDomain() - return json.split(",").mapNotNull { - it.trim() - .removeSurrounding('"', '\'') - .toRelativeUrl(domain) - .takeUnless(String::isBlank) - }.map { url -> - MangaPage( - id = generateUid(url), - url = url, - referer = fullUrl, - source = source - ) + val (_, slug, _, number) = chapter.url.split('/') + val chapterJson = apiCall( + """ + chapter(slug: "$slug", chapter: $number) { + id + images { + large + thumbnail + } } + """.trimIndent() + ).getJSONObject("chapter") + val pages = chapterJson.getJSONArray("images") + val chapterUrl = "https://${getDomain()}/${chapter.url}" + return pages.mapIndexed { i, jo -> + MangaPage( + id = generateUid("${chapter.url}/$i"), + url = jo.getString("large"), + referer = chapterUrl, + preview = jo.getString("thumbnail"), + source = source, + ) } - throw ParseException("Pages list not found at ${chapter.url.withDomain()}") } override suspend fun getTags(): Set { - val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml() - val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums") - return root.select("p.menu-tags.tupe").mapToSet { a -> - MangaTag( - title = a.select("a").text().capitalize(Locale.ROOT), - key = a.select("a").attr("data-name"), - source = source - ) - } + val json = apiCall( + """ + getFilters(mediaType: manga) { + genres + } + """.trimIndent() + ) + val array = json.getJSONObject("getFilters").getJSONArray("genres") + return array.mapToTags() } private suspend fun search(query: String): List { - val domain = getDomain() - val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml() - val root = doc.body().select("div.manga-block").select("article.tab-2") ?: throw ParseException("Cannot find root") - val items = root.select("div.anime-card") - return items.mapNotNull { card -> - val href = card.select("a").attr("href") - val status = card.select("tr")[2].text() - val fullTitle = card.selectFirst("h1.anime-card-title").text() - .substringBeforeLast('[') - val titleParts = fullTitle.splitTwoParts('/') + val json = apiCall( + """ + search(query: "$query", limit: 40) { + id + title { + be + en + } + poster + url + type + } + """.trimIndent() + ) + val array = json.getJSONArray("search") + return array.map { jo -> + val mediaId = jo.getString("id") + val title = jo.getJSONObject("title") + val href = "${jo.getString("type").lowercase()}/${jo.getString("url")}" Manga( - id = generateUid(href), - title = titleParts?.first?.trim() ?: fullTitle, - coverUrl = card.selectFirst("img").attr("src").withDomain(), - altTitle = titleParts?.second?.trim(), + id = generateUid(mediaId), + title = title.getString("be"), + coverUrl = jo.getString("poster").removePrefix("/cdn") + .withDomain("cdn") + "?width=200&height=280", + altTitle = title.getString("en").takeUnless(String::isEmpty), author = null, rating = Manga.NO_RATING, url = href, - publicUrl = href.withDomain(), - tags = card.select("p.tupe.tag")?.select("a")?.mapNotNullToSet tags@{ x -> - MangaTag( - title = x.text(), - key = x.attr("href")?.substringAfterLast("=") ?: return@tags null, - source = source - ) - }.orEmpty(), - state = when (status) { - "выпускаецца" -> MangaState.ONGOING - "завершанае" -> MangaState.FINISHED - else -> null - }, - source = source + publicUrl = "https://${getDomain()}/${href}", + tags = emptySet(), + state = null, + source = source, ) } } + private suspend fun apiCall(request: String): JSONObject { + return loaderContext.graphQLQuery("https://api.${getDomain()}/graphql", request) + .getJSONObject("data") + } + + private fun JSONArray.mapToTags(): Set { + + 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(length()) + stringIterator().forEach { + result.add( + MangaTag( + title = toTitle(it), + key = it, + source = source, + ) + ) + } + return result + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt index e5828c154..82a0a3268 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ChanRepository.kt @@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository( @@ -17,11 +18,11 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe SortOrder.ALPHABETICAL ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { val domain = getDomain() val url = when { @@ -31,11 +32,15 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe } "https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}" } - tag != null -> "https://$domain/tags/${tag.key}&n=${getSortKey2(sortOrder)}?offset=$offset" + !tags.isNullOrEmpty() -> tags.joinToString( + prefix = "https://$domain/tags/", + postfix = "&n=${getSortKey2(sortOrder)}?offset=$offset", + separator = "+", + ) { tag -> tag.key } else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset" } val doc = loaderContext.httpGet(url).parseHtml() - val root = doc.body().selectFirst("div.main_fon").getElementById("content") + val root = doc.body().selectFirst("div.main_fon")?.getElementById("content") ?: throw ParseException("Cannot find root") return root.select("div.content_row").mapNotNull { row -> val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a") @@ -72,19 +77,21 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml() val root = doc.body().getElementById("dle-content") ?: throw ParseException("Cannot find root") + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) return manga.copy( description = root.getElementById("description")?.html()?.substringBeforeLast(" - table.select("div.manga2") - }.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a -> - val href = a.relUrl("href") + chapters = root.select("table.table_cha tr:gt(1)").reversed().mapIndexedNotNull { i, tr -> + val href = tr?.selectFirst("a")?.relUrl("href") ?: return@mapIndexedNotNull null MangaChapter( id = generateUid(href), - name = a.text().trim(), + name = tr.selectFirst("a")?.text().orEmpty(), number = i + 1, url = href, - source = source + scanlator = null, + branch = null, + uploadDate = dateFormat.tryParse(tr.selectFirst("div.date")?.text()), + source = source, ) } ) @@ -112,8 +119,9 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe MangaPage( id = generateUid(url), url = url, + preview = null, referer = fullUrl, - source = source + source = source, ) } } @@ -123,12 +131,12 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe override suspend fun getTags(): Set { val domain = getDomain() val doc = loaderContext.httpGet("https://$domain/catalog").parseHtml() - val root = doc.body().selectFirst("div.main_fon").getElementById("side") - .select("ul").last() + val root = doc.body().selectFirst("div.main_fon")?.getElementById("side") + ?.select("ul")?.last() ?: throw ParseException("Cannot find root") return root.select("li.sidetag").mapToSet { li -> - val a = li.children().last() + val a = li.children().last() ?: throw ParseException("a is null") MangaTag( - title = a.text().capitalize(), + title = a.text().toCamelCase(), key = a.attr("href").substringAfterLast('/'), source = source ) @@ -150,4 +158,5 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe SortOrder.NEWEST -> "datedesc" else -> "favdesc" } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt index c09530342..afae79bab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/DesuMeRepository.kt @@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* import java.util.* -import kotlin.collections.ArrayList class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { @@ -21,11 +20,11 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor SortOrder.ALPHABETICAL ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { if (query != null && offset != 0) { return emptyList() @@ -38,9 +37,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor append(getSortKey(sortOrder)) append("&page=") append((offset / 20) + 1) - if (tag != null) { + if (!tags.isNullOrEmpty()) { append("&genres=") - append(tag.key) + appendAll(tags, ",") { it.key } } if (query != null) { append("&search=") @@ -94,12 +93,17 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor description = json.getString("description"), chapters = chaptersList.mapIndexed { i, it -> val chid = it.getLong("id") + val volChap = "Том " + it.getString("vol") + ". " + "Глава " + it.getString("ch") + val title = if (it.getString("title") == "null") "" else it.getString("title") MangaChapter( id = generateUid(chid), source = manga.source, url = "$baseChapterUrl$chid", - name = it.getStringOrNull("title") ?: "${manga.title} #${it.getDouble("ch")}", - number = totalChapters - i + uploadDate = it.getLong("date") * 1000, + name = if (title.isEmpty()) volChap else "$volChap: $title", + number = totalChapters - i, + scanlator = null, + branch = null, ) }.reversed() ) @@ -114,20 +118,22 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor MangaPage( id = generateUid(jo.getLong("id")), referer = fullUrl, + preview = null, source = chapter.source, - url = jo.getString("img") + url = jo.getString("img"), ) } } override suspend fun getTags(): Set { val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml() - val root = doc.body().getElementById("animeFilter").selectFirst(".catalog-genres") + val root = doc.body().getElementById("animeFilter") + ?.selectFirst(".catalog-genres") ?: throw ParseException("Root not found") return root.select("li").mapToSet { MangaTag( source = source, - key = it.selectFirst("input").attr("data-genre"), - title = it.selectFirst("label").text() + key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(), + title = it.selectFirst("label")?.text() ?: parseFailed() ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt new file mode 100644 index 000000000..41b86750e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ExHentaiRepository.kt @@ -0,0 +1,260 @@ +package org.koitharu.kotatsu.core.parser.site + +import org.jsoup.nodes.Element +import org.koitharu.kotatsu.base.domain.MangaLoaderContext +import org.koitharu.kotatsu.core.model.* +import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.utils.ext.* +import kotlin.math.pow + +private const val DOMAIN_UNAUTHORIZED = "e-hentai.org" +private const val DOMAIN_AUTHORIZED = "exhentai.org" + +class ExHentaiRepository( + loaderContext: MangaLoaderContext, +) : RemoteMangaRepository(loaderContext), MangaRepositoryAuthProvider { + + override val source = MangaSource.EXHENTAI + + override val defaultDomain: String + get() = if (isAuthorized()) DOMAIN_AUTHORIZED else DOMAIN_UNAUTHORIZED + + override val authUrl: String + get() = "https://${getDomain()}/bounce_login.php" + + private val ratingPattern = Regex("-?[0-9]+px") + private val authCookies = arrayOf("ipb_member_id", "ipb_pass_hash") + private var updateDm = false + + init { + loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "nw=1", "sl=dm_2") + loaderContext.cookieJar.insertCookies(DOMAIN_UNAUTHORIZED, "nw=1", "sl=dm_2") + } + + override suspend fun getList2( + offset: Int, + query: String?, + tags: Set?, + sortOrder: SortOrder?, + ): List { + val page = (offset / 25f).toIntUp() + var search = query?.urlEncoded().orEmpty() + val url = buildString { + append("https://") + append(getDomain()) + append("/?page=") + append(page) + if (!tags.isNullOrEmpty()) { + var fCats = 0 + for (tag in tags) { + tag.key.toIntOrNull()?.let { fCats = fCats or it } ?: run { + search += tag.key + " " + } + } + if (fCats != 0) { + append("&f_cats=") + append(1023 - fCats) + } + } + if (search.isNotEmpty()) { + append("&f_search=") + append(search.trim().replace(' ', '+')) + } + // by unknown reason cookie "sl=dm_2" is ignored, so, we should request it again + if (updateDm) { + append("&inline_set=dm_e") + } + } + val body = loaderContext.httpGet(url).parseHtml().body() + val root = body.selectFirst("table.itg") + ?.selectFirst("tbody") + ?: if (updateDm) { + parseFailed("Cannot find root") + } else { + updateDm = true + return getList2(offset, query, tags, sortOrder) + } + updateDm = false + return root.children().mapNotNull { tr -> + if (tr.childrenSize() != 2) return@mapNotNull null + val (td1, td2) = tr.children() + val glink = td2.selectFirst("div.glink") ?: parseFailed("glink not found") + val a = glink.parents().select("a").first() ?: parseFailed("link not found") + val href = a.relUrl("href") + val tagsDiv = glink.nextElementSibling() ?: parseFailed("tags div not found") + val mainTag = td2.selectFirst("div.cn")?.let { div -> + MangaTag( + title = div.text(), + key = tagIdByClass(div.classNames()) ?: return@let null, + source = source, + ) + } + Manga( + id = generateUid(href), + title = glink.text().cleanupTitle(), + altTitle = null, + url = href, + publicUrl = a.absUrl("href"), + rating = td2.selectFirst("div.ir")?.parseRating() ?: Manga.NO_RATING, + isNsfw = true, + coverUrl = td1.selectFirst("img")?.absUrl("src").orEmpty(), + tags = setOfNotNull(mainTag), + state = null, + author = tagsDiv.getElementsContainingOwnText("artist:").first() + ?.nextElementSibling()?.text(), + source = source, + ) + } + } + + override suspend fun getDetails(manga: Manga): Manga { + val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml() + val root = doc.body().selectFirst("div.gm") ?: parseFailed("Cannot find root") + val cover = root.getElementById("gd1")?.children()?.first() + val title = root.getElementById("gd2") + val taglist = root.getElementById("taglist") + val tabs = doc.body().selectFirst("table.ptt")?.selectFirst("tr") + return manga.copy( + title = title?.getElementById("gn")?.text()?.cleanupTitle() ?: manga.title, + altTitle = title?.getElementById("gj")?.text()?.cleanupTitle() ?: manga.altTitle, + publicUrl = doc.baseUri().ifEmpty { manga.publicUrl }, + rating = root.getElementById("rating_label")?.text() + ?.substringAfterLast(' ') + ?.toFloatOrNull() + ?.div(5f) ?: manga.rating, + largeCoverUrl = cover?.css("background")?.cssUrl(), + description = taglist?.select("tr")?.joinToString("
") { tr -> + val (tc, td) = tr.children() + val subtags = td.select("a").joinToString { it.html() } + "${tc.html()} $subtags" + }, + chapters = tabs?.select("a")?.findLast { a -> + a.text().toIntOrNull() != null + }?.let { a -> + val count = a.text().toInt() + val chapters = ArrayList(count) + for (i in 1..count) { + val url = "${manga.url}?p=$i" + chapters += MangaChapter( + id = generateUid(url), + name = "${manga.title} #$i", + number = i, + url = url, + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, + ) + } + chapters + }, + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val doc = loaderContext.httpGet(chapter.url.withDomain()).parseHtml() + val root = doc.body().getElementById("gdt") ?: parseFailed("Root not found") + return root.select("a").mapNotNull { a -> + val url = a.relUrl("href") + MangaPage( + id = generateUid(url), + url = url, + referer = a.absUrl("href"), + preview = null, + source = source, + ) + } + } + + override suspend fun getPageUrl(page: MangaPage): String { + val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml() + return doc.body().getElementById("img")?.absUrl("src") + ?: parseFailed("Image not found") + } + + override suspend fun getTags(): Set { + val doc = loaderContext.httpGet("https://${getDomain()}").parseHtml() + val root = doc.body().getElementById("searchbox")?.selectFirst("table") + ?: parseFailed("Root not found") + return root.select("div.cs").mapNotNullToSet { div -> + val id = div.id().substringAfterLast('_').toIntOrNull() + ?: return@mapNotNullToSet null + MangaTag( + title = div.text(), + key = id.toString(), + source = source + ) + } + } + + override fun isAuthorized(): Boolean { + val authorized = isAuthorized(DOMAIN_UNAUTHORIZED) + if (authorized) { + if (!isAuthorized(DOMAIN_AUTHORIZED)) { + loaderContext.cookieJar.copyCookies( + DOMAIN_UNAUTHORIZED, + DOMAIN_AUTHORIZED, + authCookies, + ) + loaderContext.cookieJar.insertCookies(DOMAIN_AUTHORIZED, "yay=louder") + } + return true + } + return false + } + + private fun isAuthorized(domain: String): Boolean { + val cookies = loaderContext.cookieJar.getCookies(domain).mapToSet { x -> x.name } + return authCookies.all { it in cookies } + } + + private fun Element.parseRating(): Float { + return runCatching { + val style = requireNotNull(attr("style")) + val (v1, v2) = ratingPattern.find(style)!!.destructured + var p1 = v1.dropLast(2).toInt() + val p2 = v2.dropLast(2).toInt() + if (p2 != -1) { + p1 += 8 + } + (80 - p1) / 80f + }.getOrDefault(Manga.NO_RATING) + } + + private fun String.cleanupTitle(): String { + val result = StringBuilder(length) + var skip = false + for (c in this) { + when { + c == '[' -> skip = true + c == ']' -> skip = false + c.isWhitespace() && result.isEmpty() -> continue + !skip -> result.append(c) + } + } + while (result.lastOrNull()?.isWhitespace() == true) { + result.deleteCharAt(result.lastIndex) + } + return result.toString() + } + + private fun String.cssUrl(): String? { + val fromIndex = indexOf("url(") + if (fromIndex == -1) { + return null + } + val toIndex = indexOf(')', startIndex = fromIndex) + return if (toIndex == -1) { + null + } else { + substring(fromIndex + 4, toIndex).trim() + } + } + + private fun tagIdByClass(classNames: Collection): String? { + val className = classNames.find { x -> x.startsWith("ct") } ?: return null + val num = className.drop(2).toIntOrNull(16) ?: return null + return 2.0.pow(num).toInt().toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt index b680b5cf0..598a43bf0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/GroupleRepository.kt @@ -1,11 +1,14 @@ package org.koitharu.kotatsu.core.parser.site +import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Response import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* abstract class GroupleRepository(loaderContext: MangaLoaderContext) : @@ -18,11 +21,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : SortOrder.RATING ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { val domain = getDomain() val doc = when { @@ -33,22 +36,24 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : "offset" to (offset upBy PAGE_SIZE_SEARCH).toString() ) ) - tag == null -> loaderContext.httpGet( + tags.isNullOrEmpty() -> loaderContext.httpGet( "https://$domain/list?sortType=${ getSortKey( sortOrder ) - }&offset=${offset upBy PAGE_SIZE}" + }&offset=${offset upBy PAGE_SIZE}", HEADER ) - else -> loaderContext.httpGet( - "https://$domain/list/genre/${tag.key}?sortType=${ + tags.size == 1 -> loaderContext.httpGet( + "https://$domain/list/genre/${tags.first().key}?sortType=${ getSortKey( sortOrder ) - }&offset=${offset upBy PAGE_SIZE}" + }&offset=${offset upBy PAGE_SIZE}", HEADER ) - }.parseHtml() - val root = doc.body().getElementById("mangaBox") + offset > 0 -> return emptyList() + else -> advancedSearch(domain, tags) + }.parseHtml().body() + val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults")) ?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root") val baseHost = root.baseUri().toHttpUrl().host return root.select("div.tile").mapNotNull { node -> @@ -57,7 +62,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : if (descDiv.selectFirst("i.fa-user") != null) { return@mapNotNull null //skip author } - val href = imgDiv.selectFirst("a").attr("href")?.inContextOf(node) + val href = imgDiv.selectFirst("a")?.attr("href")?.inContextOf(node) if (href == null || href.toHttpUrl().host != baseHost) { return@mapNotNull null // skip external links } @@ -101,14 +106,15 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : } override suspend fun getDetails(manga: Manga): Manga { - val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml() + val doc = loaderContext.httpGet(manga.url.withDomain(), HEADER).parseHtml() val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent") ?: throw ParseException("Cannot find root") + val dateFormat = SimpleDateFormat("dd.MM.yy", Locale.US) + val coverImg = root.selectFirst("div.subject-cover")?.selectFirst("img") return manga.copy( description = root.selectFirst("div.manga-description")?.html(), - largeCoverUrl = root.selectFirst("div.subject-cower")?.selectFirst("img")?.attr( - "data-full" - ), + largeCoverUrl = coverImg?.attr("data-full"), + coverUrl = coverImg?.attr("data-thumb") ?: manga.coverUrl, tags = manga.tags + root.select("div.subject-meta").select("span.elem_genre ") .mapNotNull { val a = it.selectFirst("a.element-link") ?: return@mapNotNull null @@ -119,21 +125,32 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : ) }, chapters = root.selectFirst("div.chapters-link")?.selectFirst("table") - ?.select("a")?.asReversed()?.mapIndexed { i, a -> + ?.select("tr:has(td > a)")?.asReversed()?.mapIndexedNotNull { i, tr -> + val a = tr.selectFirst("a") ?: return@mapIndexedNotNull null val href = a.relUrl("href") + var translators = "" + val translatorElement = a.attr("title") + if (!translatorElement.isNullOrBlank()) { + translators = translatorElement + .replace("(Переводчик),", "&") + .removeSuffix(" (Переводчик)") + } MangaChapter( id = generateUid(href), - name = a.ownText().removePrefix(manga.title).trim(), + name = tr.selectFirst("a")?.text().orEmpty().removePrefix(manga.title).trim(), number = i + 1, url = href, - source = source + uploadDate = dateFormat.tryParse(tr.selectFirst("td.d-none")?.text()), + scanlator = translators, + source = source, + branch = null, ) } ) } override suspend fun getPages(chapter: MangaChapter): List { - val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1").parseHtml() + val doc = loaderContext.httpGet(chapter.url.withDomain() + "?mtr=1", HEADER).parseHtml() val scripts = doc.select("script") for (script in scripts) { val data = script.html() @@ -151,8 +168,9 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : MangaPage( id = generateUid(url), url = url, + preview = null, referer = chapter.url, - source = source + source = source, ) } } @@ -160,12 +178,12 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : } override suspend fun getTags(): Set { - val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name").parseHtml() - val root = doc.body().getElementById("mangaBox").selectFirst("div.leftContent") - .selectFirst("table.table") + val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name", HEADER).parseHtml() + val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent") + ?.selectFirst("table.table") ?: parseFailed("Cannot find root") return root.select("a.element-link").mapToSet { a -> MangaTag( - title = a.text().capitalize(), + title = a.text().toCamelCase(), key = a.attr("href").substringAfterLast('/'), source = source ) @@ -182,9 +200,50 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) : null -> "updated" } + private suspend fun advancedSearch(domain: String, tags: Set): Response { + val url = "https://$domain/search/advanced" + // Step 1: map catalog genres names to advanced-search genres ids + val tagsIndex = loaderContext.httpGet(url, HEADER).parseHtml() + .body().selectFirst("form.search-form") + ?.select("div.form-group") + ?.get(1) ?: parseFailed("Genres filter element not found") + val tagNames = tags.map { it.title.lowercase() } + val payload = HashMap() + var foundGenres = 0 + tagsIndex.select("li.property").forEach { li -> + val name = li.text().trim().lowercase() + val id = li.selectFirst("input")?.id() + ?: parseFailed("Id for tag $name not found") + payload[id] = if (name in tagNames) { + foundGenres++ + "in" + } else "" + } + if (foundGenres != tags.size) { + parseFailed("Some genres are not found") + } + // Step 2: advanced search + payload["q"] = "" + payload["s_high_rate"] = "" + payload["s_single"] = "" + payload["s_mature"] = "" + payload["s_completed"] = "" + payload["s_translated"] = "" + payload["s_many_chapters"] = "" + payload["s_wait_upload"] = "" + payload["s_sale"] = "" + payload["years"] = "1900,2099" + payload["+"] = "Искать".urlEncoded() + return loaderContext.httpPost(url, payload) + } + private companion object { private const val PAGE_SIZE = 70 private const val PAGE_SIZE_SEARCH = 50 + private val HEADER = Headers.Builder() + .add("User-Agent", "readmangafun") + .build() } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt index e405aed9c..072c7611b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/HenChanRepository.kt @@ -8,22 +8,20 @@ import org.koitharu.kotatsu.utils.ext.parseHtml class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) { - override val defaultDomain = "hentaichan.pro" + override val defaultDomain = "hentaichan.live" override val source = MangaSource.HENCHAN - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { - return super.getList(offset, query, sortOrder, tag).map { - val cover = it.coverUrl - if (cover.contains("_blur")) { - it.copy(coverUrl = cover.replace("_blur", "")) - } else { - it - } + return super.getList2(offset, query, tags, sortOrder).map { + it.copy( + coverUrl = it.coverUrl.replace("_blur", ""), + isNsfw = true, + ) } } @@ -36,7 +34,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load description = root.getElementById("description")?.html()?.substringBeforeLast(" = EnumSet.of( + SortOrder.UPDATED, + SortOrder.ALPHABETICAL, + SortOrder.NEWEST, + SortOrder.POPULARITY, + ) + + override suspend fun getList2( + offset: Int, + query: String?, + tags: Set?, + sortOrder: SortOrder?, + ): List { + 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 { + 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.optJSONArray("data").isNullOrEmpty()) { + return@mapNotNull null + } + val locale = Locale.forLanguageTag(attrs.getString("translatedLanguage")) + val relations = jo.getJSONArray("relationships").associateByKey("type") + val number = attrs.optInt("chapter", 0) + MangaChapter( + id = generateUid(id), + name = attrs.getStringOrNull("title")?.takeUnless(String::isEmpty) + ?: "Chapter #$number", + number = number, + url = id, + scanlator = relations["scanlation_group"]?.getStringOrNull("name"), + uploadDate = dateFormat.tryParse(attrs.getString("publishAt")), + branch = locale.displayName.toTitleCase(locale), + source = source, + ) + } + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + val domain = getDomain() + val attrs = loaderContext.httpGet("https://api.$domain/chapter/${chapter.url}") + .parseJson() + .getJSONObject("data") + .getJSONObject("attributes") + val data = attrs.getJSONArray("data") + val prefix = "https://uploads.$domain/data/${attrs.getString("hash")}/" + val referer = "https://$domain/" + return List(data.length()) { i -> + val url = prefix + data.getString(i) + MangaPage( + id = generateUid(url), + url = url, + referer = referer, + preview = null, // TODO prefix + dataSaver.getString(i), + source = source, + ) + } + } + + override suspend fun getTags(): Set { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt index 8d2c6c87f..ed58f073c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaLibRepository.kt @@ -9,8 +9,8 @@ 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.* -import kotlin.collections.ArrayList open class MangaLibRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { @@ -27,11 +27,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : SortOrder.NEWEST ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { if (!query.isNullOrEmpty()) { return if (offset == 0) search(query) else emptyList() @@ -44,20 +44,21 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : append(getSortKey(sortOrder)) append("&page=") append(page) - if (tag != null) { - append("&includeGenres[]=") + tags?.forEach { tag -> + append("&genres[include][]=") append(tag.key) } } val doc = loaderContext.httpGet(url).parseHtml() val root = doc.body().getElementById("manga-list") ?: throw ParseException("Root not found") - val items = root.selectFirst("div.media-cards-grid").select("div.media-card-wrap") + val items = root.selectFirst("div.media-cards-grid")?.select("div.media-card-wrap") + ?: return emptyList() return items.mapNotNull { card -> val a = card.selectFirst("a.media-card") ?: return@mapNotNull null val href = a.relUrl("href") Manga( id = generateUid(href), - title = card.selectFirst("h3").text(), + title = card.selectFirst("h3")?.text().orEmpty(), coverUrl = a.absUrl("data-src"), altTitle = null, author = null, @@ -79,6 +80,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : val info = root.selectFirst("div.media-content") val chaptersDoc = loaderContext.httpGet("$fullUrl?section=chapters").parseHtml() val scripts = chaptersDoc.select("script") + val dateFormat = SimpleDateFormat("yyy-MM-dd", Locale.US) var chapters: ArrayList? = null scripts@ for (script in scripts) { val raw = script.html().lines() @@ -91,29 +93,33 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : for (i in 0 until total) { val item = list.getJSONObject(i) val chapterId = item.getLong("chapter_id") - val branchName = item.getStringOrNull("username") + val scanlator = item.getStringOrNull("username") val url = buildString { append(manga.url) append("/v") append(item.getInt("chapter_volume")) append("/c") append(item.getString("chapter_number")) + @Suppress("BlockingMethodInNonBlockingContext") // lint issue append('/') append(item.optString("chapter_string")) } - var name = item.getString("chapter_name") - if (name.isNullOrBlank() || name == "null") { - name = "Том " + item.getInt("chapter_volume") + - " Глава " + item.getString("chapter_number") - } + val nameChapter = item.getStringOrNull("chapter_name") + val volume = item.getInt("chapter_volume") + val number = item.getString("chapter_number") + val fullNameChapter = "Том $volume. Глава $number" chapters.add( MangaChapter( id = generateUid(chapterId), url = url, source = source, - branch = branchName, number = total - i, - name = name + uploadDate = dateFormat.tryParse( + item.getString("chapter_created_at").substringBefore(" ") + ), + scanlator = scanlator, + branch = null, + name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter", ) ) } @@ -128,17 +134,17 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : rating = root.selectFirst("div.media-stats-item__score") ?.selectFirst("span") ?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating, - author = info.getElementsMatchingOwnText("Автор").firstOrNull() + author = info?.getElementsMatchingOwnText("Автор")?.firstOrNull() ?.nextElementSibling()?.text() ?: manga.author, - tags = info.selectFirst("div.media-tags") + tags = info?.selectFirst("div.media-tags") ?.select("a.media-tag-item")?.mapToSet { a -> MangaTag( - title = a.text().capitalize(), + title = a.text().toCamelCase(), key = a.attr("href").substringAfterLast('='), source = source ) } ?: manga.tags, - description = info.selectFirst("div.media-description__text")?.html(), + description = info?.selectFirst("div.media-description__text")?.html(), chapters = chapters ) } @@ -146,11 +152,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : override suspend fun getPages(chapter: MangaChapter): List { val fullUrl = chapter.url.withDomain() val doc = loaderContext.httpGet(fullUrl).parseHtml() - if (doc.location()?.endsWith("/register") == true) { + if (doc.location().endsWith("/register")) { throw AuthRequiredException("/login".inContextOf(doc)) } val scripts = doc.head().select("script") - val pg = doc.body().getElementById("pg").html() + val pg = (doc.body().getElementById("pg")?.html() ?: parseFailed("Element #pg not found")) .substringAfter('=') .substringBeforeLast(';') val pages = JSONArray(pg) @@ -173,8 +179,9 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : MangaPage( id = generateUid(pageUrl), url = pageUrl, + preview = null, referer = fullUrl, - source = source + source = source, ) } } @@ -196,7 +203,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : result += MangaTag( source = source, key = x.getInt("id").toString(), - title = x.getString("name").capitalize() + title = x.getString("name").toCamelCase() ) } return result @@ -234,8 +241,8 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) : .toFloatOrNull()?.div(5f) ?: Manga.NO_RATING, state = null, source = source, - coverUrl = "https://$domain${covers.getString("thumbnail")}", - largeCoverUrl = "https://$domain${covers.getString("default")}" + coverUrl = covers.getString("thumbnail"), + largeCoverUrl = covers.getString("default") ) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt new file mode 100644 index 000000000..da9af43bd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaOwlRepository.kt @@ -0,0 +1,164 @@ +package org.koitharu.kotatsu.core.parser.site + +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 = EnumSet.of( + SortOrder.POPULARITY, + SortOrder.NEWEST, + SortOrder.UPDATED + ) + + override suspend fun getList2( + offset: Int, + query: String?, + tags: Set?, + sortOrder: SortOrder?, + ): List { + 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) + 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, + scanlator = null, + branch = null, + uploadDate = dateFormat.tryParse(li.selectFirst("small:last-of-type")?.text()), + source = MangaSource.MANGAOWL, + ) + } + ) + } + + override suspend fun getPages(chapter: MangaChapter): List { + 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 = fullUrl, + 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 { + 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt index 4fb4085dc..7f1285369 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangaTownRepository.kt @@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.utils.ext.* +import java.text.DateFormat +import java.text.SimpleDateFormat import java.util.* class MangaTownRepository(loaderContext: MangaLoaderContext) : @@ -23,11 +25,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : SortOrder.UPDATED ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { val sortKey = when (sortOrder) { SortOrder.ALPHABETICAL -> "?name.az" @@ -43,22 +45,28 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : } "/search?name=${query.urlEncoded()}".withDomain() } - tag != null -> "/directory/${tag.key}/$page.htm$sortKey".withDomain() - else -> "/directory/$page.htm$sortKey".withDomain() + tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".withDomain() + tags.size == 1 -> "/directory/${tags.first().key}/$page.htm$sortKey".withDomain() + else -> tags.joinToString( + prefix = "/search?page=$page".withDomain() + ) { tag -> + "&genres[${tag.key}]=1" + } } val doc = loaderContext.httpGet(url).parseHtml() val root = doc.body().selectFirst("ul.manga_pic_list") ?: throw ParseException("Root not found") return root.select("li").mapNotNull { li -> val a = li.selectFirst("a.manga_cover") - val href = a.relUrl("href") + val href = a?.relUrl("href") + ?: return@mapNotNull null val views = li.select("p.view") val status = views.findOwnText { x -> x.startsWith("Status:") } - ?.substringAfter(':')?.trim()?.toLowerCase(Locale.ROOT) + ?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT) Manga( id = generateUid(href), title = a.attr("title"), - coverUrl = a.selectFirst("img").absUrl("src"), + coverUrl = a.selectFirst("img")?.absUrl("src").orEmpty(), source = MangaSource.MANGATOWN, altTitle = null, rating = li.selectFirst("p.score")?.selectFirst("b") @@ -87,11 +95,12 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml() val root = doc.body().selectFirst("section.main") ?.selectFirst("div.article_content") ?: throw ParseException("Cannot find root") - 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") ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed() + val dateFormat = SimpleDateFormat("MMM dd,yyyy", Locale.US) return manga.copy( - tags = manga.tags + info.select("li").find { x -> + tags = manga.tags + info?.select("li")?.find { x -> x.selectFirst("b")?.ownText() == "Genre(s):" }?.select("a")?.mapNotNull { a -> MangaTag( @@ -100,9 +109,10 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : source = MangaSource.MANGATOWN ) }.orEmpty(), - description = info.getElementById("show")?.ownText(), + description = info?.getElementById("show")?.ownText(), chapters = chaptersList?.mapIndexedNotNull { i, li -> - val href = li.selectFirst("a").relUrl("href") + val href = li.selectFirst("a")?.relUrl("href") + ?: return@mapIndexedNotNull null val name = li.select("span").filter { it.className().isEmpty() } .joinToString(" - ") { it.text() }.trim() MangaChapter( @@ -110,7 +120,13 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : url = href, source = MangaSource.MANGATOWN, number = i + 1, - name = if (name.isEmpty()) "${manga.title} - ${i + 1}" else name + uploadDate = parseChapterDate( + dateFormat, + li.selectFirst("span.time")?.text() + ), + name = name.ifEmpty { "${manga.title} - ${i + 1}" }, + scanlator = null, + branch = null, ) } ) @@ -121,7 +137,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : val doc = loaderContext.httpGet(fullUrl).parseHtml() val root = doc.body().selectFirst("div.page_select") ?: throw ParseException("Cannot find root") - return root.selectFirst("select").select("option").mapNotNull { + return root.selectFirst("select")?.select("option")?.mapNotNull { val href = it.relUrl("value") if (href.endsWith("featured.html")) { return@mapNotNull null @@ -129,23 +145,24 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) : MangaPage( id = generateUid(href), url = href, + preview = null, referer = fullUrl, - source = MangaSource.MANGATOWN + source = MangaSource.MANGATOWN, ) - } + } ?: parseFailed("Pages list not found") } override suspend fun getPageUrl(page: MangaPage): String { val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml() - return doc.getElementById("image").absUrl("src") + return doc.getElementById("image")?.absUrl("src") ?: parseFailed("Image not found") } override suspend fun getTags(): Set { val doc = loaderContext.httpGet("/directory/".withDomain()).parseHtml() val root = doc.body().selectFirst("aside.right") - .getElementsContainingOwnText("Genres") - .first() - .nextElementSibling() + ?.getElementsContainingOwnText("Genres") + ?.first() + ?.nextElementSibling() ?: parseFailed("Root not found") return root.select("li").mapNotNullToSet { li -> val a = li.selectFirst("a") ?: return@mapNotNullToSet null val key = a.attr("href").parseTagKey() @@ -160,6 +177,15 @@ 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) { super.onCreatePreferences(map) map[SourceSettings.KEY_USE_SSL] = true diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt index 96430b755..17aced99d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/MangareadRepository.kt @@ -4,7 +4,10 @@ import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository +import org.koitharu.kotatsu.utils.WordSet import org.koitharu.kotatsu.utils.ext.* +import java.text.DateFormat +import java.text.SimpleDateFormat import java.util.* class MangareadRepository( @@ -20,17 +23,19 @@ class MangareadRepository( SortOrder.POPULARITY ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { - if (offset % PAGE_SIZE != 0) { - return emptyList() + val tag = when { + tags.isNullOrEmpty() -> null + tags.size == 1 -> tags.first() + else -> throw NotImplementedError("Multiple genres are not supported by this source") } val payload = createRequestTemplate() - payload["page"] = (offset / PAGE_SIZE).toString() + payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString() payload["vars[meta_key]"] = when (sortOrder) { SortOrder.POPULARITY -> "_wp_manga_views" SortOrder.UPDATED -> "_latest_update" @@ -43,25 +48,26 @@ class MangareadRepository( payload ).parseHtml() return doc.select("div.row.c-tabs-item__content").map { div -> - val href = div.selectFirst("a").relUrl("href") + val href = div.selectFirst("a")?.relUrl("href") + ?: parseFailed("Link not found") val summary = div.selectFirst(".tab-summary") Manga( id = generateUid(href), url = href, publicUrl = href.inContextOf(div), - coverUrl = div.selectFirst("img").absUrl("src"), - title = summary.selectFirst("h3").text(), + coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(), + title = summary?.selectFirst("h3")?.text().orEmpty(), rating = div.selectFirst("span.total_votes")?.ownText() ?.toFloatOrNull()?.div(5f) ?: -1f, - tags = summary.selectFirst(".mg_genres")?.select("a")?.mapToSet { a -> + tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a -> MangaTag( key = a.attr("href").removeSuffix("/").substringAfterLast('/'), title = a.text(), source = MangaSource.MANGAREAD ) }.orEmpty(), - author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(), - state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content") + author = summary?.selectFirst(".mg_author")?.selectFirst("a")?.ownText(), + state = when (summary?.selectFirst(".mg_status")?.selectFirst(".summary-content") ?.ownText()?.trim()) { "OnGoing" -> MangaState.ONGOING "Completed" -> MangaState.FINISHED @@ -75,9 +81,9 @@ class MangareadRepository( override suspend fun getTags(): Set { val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml() val root = doc.body().selectFirst("header") - .selectFirst("ul.second-menu") + ?.selectFirst("ul.second-menu") ?: parseFailed("Root not found") return root.select("li").mapNotNullToSet { li -> - val a = li.selectFirst("a") + val a = li.selectFirst("a") ?: return@mapNotNullToSet null val href = a.attr("href").removeSuffix("/") .substringAfterLast("genres/", "") if (href.isEmpty()) { @@ -101,8 +107,8 @@ class MangareadRepository( val root2 = doc.body().selectFirst("div.content-area") ?.selectFirst("div.c-page") ?: throw ParseException("Root2 not found") - val mangaId = doc.getElementsByAttribute("data-postid").firstOrNull() - ?.attr("data-postid")?.toLongOrNull() + val mangaId = doc.getElementsByAttribute("data-post").firstOrNull() + ?.attr("data-post")?.toLongOrNull() ?: throw ParseException("Cannot obtain manga id") val doc2 = loaderContext.httpPost( "https://${getDomain()}/wp-admin/admin-ajax.php", @@ -111,6 +117,7 @@ class MangareadRepository( "manga" to mangaId.toString() ) ).parseHtml() + val dateFormat = SimpleDateFormat("MMMM dd, yyyy", Locale.US) return manga.copy( tags = root.selectFirst("div.genres-content")?.select("a") ?.mapNotNullToSet { a -> @@ -127,13 +134,21 @@ class MangareadRepository( ?.joinToString { it.html() }, chapters = doc2.select("li").asReversed().mapIndexed { i, li -> val a = li.selectFirst("a") - val href = a.relUrl("href") + val href = a?.relUrl("href").orEmpty().ifEmpty { + parseFailed("Link is missing") + } MangaChapter( id = generateUid(href), - name = a.ownText(), + name = a!!.ownText(), number = i + 1, url = href, - source = MangaSource.MANGAREAD + uploadDate = parseChapterDate( + dateFormat, + doc2.selectFirst("span.chapter-release-date i")?.text() + ), + source = MangaSource.MANGAREAD, + scanlator = null, + branch = null, ) } ) @@ -147,16 +162,82 @@ class MangareadRepository( ?: throw ParseException("Root not found") return root.select("div.page-break").map { div -> val img = div.selectFirst("img") - val url = img.relUrl("src") + val url = img?.relUrl("src") ?: parseFailed("Page image not found") MangaPage( id = generateUid(url), url = url, + preview = null, referer = fullUrl, - source = MangaSource.MANGAREAD + source = MangaSource.MANGAREAD, ) } } + private fun parseChapterDate(dateFormat: DateFormat, date: String?): Long { + + date ?: return 0 + return when { + date.endsWith(" ago", ignoreCase = true) -> { + parseRelativeDate(date) + } + // Handle translated 'ago' in Portuguese. + date.endsWith(" atrás", ignoreCase = true) -> { + parseRelativeDate(date) + } + // Handle translated 'ago' in Turkish. + date.endsWith(" önce", ignoreCase = true) -> { + parseRelativeDate(date) + } + // Handle 'yesterday' and 'today', using midnight + date.startsWith("year", ignoreCase = true) -> { + Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -1) // yesterday + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + date.startsWith("today", ignoreCase = true) -> { + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + date.contains(Regex("""\d(st|nd|rd|th)""")) -> { + // Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it + date.split(" ").map { + if (it.contains(Regex("""\d\D\D"""))) { + it.replace(Regex("""\D"""), "") + } else { + it + } + } + .let { dateFormat.tryParse(it.joinToString(" ")) } + } + else -> dateFormat.tryParse(date) + } + } + + // Parses dates in this form: + // 21 hours ago + private fun parseRelativeDate(date: String): Long { + val number = Regex("""(\d+)""").find(date)?.value?.toIntOrNull() ?: return 0 + val cal = Calendar.getInstance() + + return when { + WordSet("hari", "gün", "jour", "día", "dia", "day").anyWordIn(date) -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + WordSet("jam", "saat", "heure", "hora", "hour").anyWordIn(date) -> cal.apply { add(Calendar.HOUR, -number) }.timeInMillis + WordSet("menit", "dakika", "min", "minute", "minuto").anyWordIn(date) -> cal.apply { add(Calendar.MINUTE, -number) }.timeInMillis + WordSet("detik", "segundo", "second").anyWordIn(date) -> cal.apply { add(Calendar.SECOND, -number) }.timeInMillis + WordSet("month").anyWordIn(date) -> cal.apply { add(Calendar.MONTH, -number) }.timeInMillis + WordSet("year").anyWordIn(date) -> cal.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0 + } + } + private companion object { private const val PAGE_SIZE = 12 @@ -169,4 +250,4 @@ class MangareadRepository( it.substring(0, pos) to it.substring(pos + 1) }.toMutableMap() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt index aca8baca3..7b782ab1c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/NineMangaRepository.kt @@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* abstract class NineMangaRepository( @@ -16,62 +17,66 @@ abstract class NineMangaRepository( ) : RemoteMangaRepository(loaderContext) { init { - loaderContext.insertCookies(getDomain(), "ninemanga_template_desk=yes") + loaderContext.cookieJar.insertCookies(getDomain(), "ninemanga_template_desk=yes") } override val sortOrders: Set = EnumSet.of( SortOrder.POPULARITY, ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag?, + tags: Set?, + sortOrder: SortOrder? ): List { val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1 val url = buildString { append("https://") append(getDomain()) - if (query.isNullOrEmpty()) { - append("/category/") - if (tag != null) { - append(tag.key) - } else { - append("index") + when { + !query.isNullOrEmpty() -> { + append("/search/?name_sel=&wd=") + append(query.urlEncoded()) + append("&page=") + } + !tags.isNullOrEmpty() -> { + append("/search/?category_id=") + for (tag in tags) { + append(tag.key) + append(',') + } + append("&page=") + } + else -> { + append("/category/index_") } - append("_") - append(page) - append(".html") - } else { - append("/search/?name_sel=&wd=") - append(query.urlEncoded()) - append("&page=") - append(page) - append(".html") } + append(page) + append(".html") } val doc = loaderContext.httpGet(url, PREDEFINED_HEADERS).parseHtml() val root = doc.body().selectFirst("ul.direlist") ?: throw ParseException("Cannot find root") val baseHost = root.baseUri().toHttpUrl().host return root.select("li").map { node -> - val href = node.selectFirst("a").absUrl("href") + val href = node.selectFirst("a")?.absUrl("href") + ?: parseFailed("Link not found") val relUrl = href.toRelativeUrl(baseHost) val dd = node.selectFirst("dd") Manga( id = generateUid(relUrl), url = relUrl, publicUrl = href, - title = dd.selectFirst("a.bookname").text().toCamelCase(), + title = dd?.selectFirst("a.bookname")?.text()?.toCamelCase().orEmpty(), altTitle = null, - coverUrl = node.selectFirst("img").absUrl("src"), + coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(), rating = Manga.NO_RATING, author = null, tags = emptySet(), state = null, source = source, - description = dd.selectFirst("p").html(), + description = dd?.selectFirst("p")?.html(), ) } } @@ -86,7 +91,7 @@ abstract class NineMangaRepository( val infoRoot = root.selectFirst("div.bookintro") ?: throw ParseException("Cannot find info") return manga.copy( - tags = infoRoot.getElementsByAttributeValue("itemprop", "genre")?.first() + tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first() ?.select("a")?.mapToSet { a -> MangaTag( title = a.text(), @@ -94,20 +99,23 @@ abstract class NineMangaRepository( source = source, ) }.orEmpty(), - author = infoRoot.getElementsByAttributeValue("itemprop", "author")?.first()?.text(), - description = infoRoot.getElementsByAttributeValue("itemprop", "description")?.first() + author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(), + state = parseStatus(infoRoot.select("li a.red").text()), + description = infoRoot.getElementsByAttributeValue("itemprop", "description").first() ?.html()?.substringAfter("
"), - chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul") - ?.select("li")?.asReversed()?.mapIndexed { i, li -> - val a = li.selectFirst("a") - val href = a.relUrl("href") + chapters = root.selectFirst("div.chapterbox")?.select("ul.sub_vol_ul > li") + ?.asReversed()?.mapIndexed { i, li -> + val a = li.selectFirst("a.chapter_list_a") + val href = a?.relUrl("href")?.replace("%20", " ") ?: parseFailed("Link not found") MangaChapter( id = generateUid(href), name = a.text(), number = i + 1, url = href, - branch = null, + uploadDate = parseChapterDateByLang(li.selectFirst("span")?.text().orEmpty()), source = source, + scanlator = null, + branch = null, ) } ) @@ -135,17 +143,62 @@ abstract class NineMangaRepository( } override suspend fun getTags(): Set { - val doc = loaderContext.httpGet("https://${getDomain()}/category/", PREDEFINED_HEADERS) + val doc = loaderContext.httpGet("https://${getDomain()}/search/?type=high", PREDEFINED_HEADERS) .parseHtml() - val root = doc.body().selectFirst("ul.genreidex") - return root.select("li").mapToSet { li -> - val a = li.selectFirst("a") + val root = doc.body().getElementById("search_form") + return root?.select("li.cate_list")?.mapNotNullToSet { li -> + val cateId = li.attr("cate_id") ?: return@mapNotNullToSet null + val a = li.selectFirst("a") ?: return@mapNotNullToSet null MangaTag( - title = a.text(), - key = a.attr("href").substringBetweenLast("/", "."), + title = a.text().toTitleCase(), + key = cateId, source = source ) + } ?: 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( diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt index 3871a966f..ab95903d5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/ReadmangaRepository.kt @@ -5,6 +5,6 @@ import org.koitharu.kotatsu.core.model.MangaSource class ReadmangaRepository(loaderContext: MangaLoaderContext) : GroupleRepository(loaderContext) { - override val defaultDomain = "readmanga.live" + override val defaultDomain = "readmanga.io" override val source = MangaSource.READMANGA_RU } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt index f368db41e..d589a9490 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/RemangaRepository.kt @@ -6,16 +6,20 @@ import org.json.JSONObject import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.model.* +import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.utils.ext.* +import java.text.SimpleDateFormat import java.util.* -import kotlin.collections.ArrayList -class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { +class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext), + MangaRepositoryAuthProvider { override val source = MangaSource.REMANGA override val defaultDomain = "remanga.org" + override val authUrl: String + get() = "https://${getDomain()}/user/login" override val sortOrders: Set = EnumSet.of( SortOrder.UPDATED, @@ -24,12 +28,13 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito SortOrder.NEWEST ) - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { + copyCookies() val domain = getDomain() val urlBuilder = StringBuilder() .append("https://api.") @@ -40,8 +45,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito } else { urlBuilder.append("/api/search/catalog/?ordering=") .append(getSortKey(sortOrder)) - if (tag != null) { - urlBuilder.append("&genres=" + tag.key) + tags?.forEach { tag -> + urlBuilder.append("&genres=") + urlBuilder.append(tag.key) } } urlBuilder @@ -77,6 +83,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito } override suspend fun getDetails(manga: Manga): Manga { + copyCookies() val domain = getDomain() val slug = manga.url.find(LAST_URL_PATH_REGEX) ?: throw ParseException("Cannot obtain slug from ${manga.url}") @@ -93,6 +100,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito val chapters = loaderContext.httpGet( url = "https://api.$domain/api/titles/chapters/?branch_id=$branchId" ).parseJson().getJSONArray("content") + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US) return manga.copy( description = content.getString("description"), state = when (content.optJSONObject("status")?.getInt("id")) { @@ -109,12 +117,16 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito }, chapters = chapters.mapIndexed { i, jo -> val id = jo.getLong("id") - val name = jo.getString("name") + val name = jo.getString("name").toTitleCase(Locale.ROOT) + val publishers = jo.getJSONArray("publishers") MangaChapter( id = generateUid(id), url = "/api/titles/chapters/$id/", number = chapters.length() - i, name = buildString { + append("Том ") + append(jo.getString("tome")) + append(". ") append("Глава ") append(jo.getString("chapter")) if (name.isNotEmpty()) { @@ -122,7 +134,10 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito append(name) } }, - source = MangaSource.REMANGA + uploadDate = dateFormat.tryParse(jo.getString("upload_date")), + scanlator = publishers.optJSONObject(0)?.getStringOrNull("name"), + source = MangaSource.REMANGA, + branch = null, ) }.asReversed() ) @@ -156,6 +171,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito } } + override fun isAuthorized(): Boolean { + return loaderContext.cookieJar.getCookies(getDomain()).any { + it.name == "user" + } + } + + private fun copyCookies() { + val domain = getDomain() + loaderContext.cookieJar.copyCookies(domain, "api.$domain") + } + private fun getSortKey(order: SortOrder?) = when (order) { SortOrder.UPDATED -> "-chapter_date" SortOrder.POPULARITY -> "-rating" @@ -167,8 +193,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito private fun parsePage(jo: JSONObject, referer: String) = MangaPage( id = generateUid(jo.getLong("id")), url = jo.getString("link"), + preview = null, referer = referer, - source = source + source = source, ) private companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt index 076da352d..77edbb5b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/site/YaoiChanRepository.kt @@ -29,7 +29,10 @@ class YaoiChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loa name = a.text().trim(), number = i + 1, url = href, - source = source + uploadDate = 0L, + source = source, + scanlator = null, + branch = null, ) } ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 370f69720..da6b349b6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -8,12 +8,15 @@ import androidx.collection.arraySetOf import androidx.core.content.edit import androidx.preference.PreferenceManager import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.utils.delegates.prefs.* import java.io.File +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.* class AppSettings private constructor(private val prefs: SharedPreferences) : SharedPreferences by prefs { @@ -76,6 +79,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : var historyGrouping by BoolPreferenceDelegate(KEY_HISTORY_GROUPING, true) + var isHistoryExcludeNsfw by BoolPreferenceDelegate(KEY_HISTORY_EXCLUDE_NSFW, false) + var chaptersReverse by BoolPreferenceDelegate(KEY_REVERSE_CHAPTERS, false) val zoomMode by EnumPreferenceDelegate( @@ -104,6 +109,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : val isSourcesSelected: Boolean get() = KEY_SOURCES_HIDDEN in prefs + val isPagesNumbersEnabled by BoolPreferenceDelegate(KEY_PAGES_NUMBERS, false) + fun getStorageDir(context: Context): File? { val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { File(it) @@ -121,6 +128,12 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : } } + fun dateFormat(format: String? = prefs.getString(KEY_DATE_FORMAT, "")): DateFormat = + when (format) { + "" -> DateFormat.getDateInstance(DateFormat.SHORT) + else -> SimpleDateFormat(format, Locale.getDefault()) + } + @Deprecated("Use observe()") fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { prefs.registerOnSharedPreferenceChangeListener(listener) @@ -132,7 +145,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : fun observe() = callbackFlow { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - sendBlocking(key) + trySendBlocking(key) } prefs.registerOnSharedPreferenceChangeListener(listener) awaitClose { @@ -152,6 +165,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : const val KEY_APP_SECTION = "app_section" const val KEY_THEME = "theme" const val KEY_THEME_AMOLED = "amoled_theme" + const val KEY_DATE_FORMAT = "date_format" const val KEY_HIDE_TOOLBAR = "hide_toolbar" const val KEY_SOURCES_ORDER = "sources_order" const val KEY_SOURCES_HIDDEN = "sources_hidden" @@ -167,8 +181,6 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : const val KEY_READER_SWITCHERS = "reader_switchers" const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_WARNING = "track_warning" - const val KEY_APP_UPDATE = "app_update" - const val KEY_APP_UPDATE_AUTO = "app_update_auto" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" @@ -184,5 +196,16 @@ class AppSettings private constructor(private val prefs: SharedPreferences) : const val KEY_RESTORE = "restore" const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_REVERSE_CHAPTERS = "reverse_chapters" + const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw" + const val KEY_PAGES_NUMBERS = "pages_numbers" + + // About + const val KEY_APP_UPDATE = "app_update" + const val KEY_APP_UPDATE_AUTO = "app_update_auto" + const val KEY_APP_TRANSLATION = "about_app_translation" + const val KEY_APP_GRATITUDES = "about_gratitudes" + const val KEY_FEEDBACK_4PDA = "about_feedback_4pda" + const val KEY_FEEDBACK_GITHUB = "about_feedback_github" + const val KEY_SUPPORT_DEVELOPER = "about_support_developer" } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt index 6ee3d0747..7a6036d06 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/SourceSettings.kt @@ -27,5 +27,6 @@ interface SourceSettings { const val KEY_DOMAIN = "domain" const val KEY_USE_SSL = "ssl" + const val KEY_AUTH = "auth" } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt index 8deefacfe..0d38d95ce 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt @@ -14,16 +14,35 @@ sealed class DateTimeAgo : ListModel { } } - data class MinutesAgo(val minutes: Int) : DateTimeAgo() { + class MinutesAgo(val minutes: Int) : DateTimeAgo() { + override fun format(resources: Resources): String { return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as MinutesAgo + return minutes == other.minutes + } + + override fun hashCode(): Int = minutes } - data class HoursAgo(val hours: Int) : DateTimeAgo() { + class HoursAgo(val hours: Int) : DateTimeAgo() { override fun format(resources: Resources): String { return resources.getQuantityString(R.plurals.hours_ago, hours, hours) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as HoursAgo + return hours == other.hours + } + + override fun hashCode(): Int = hours } object Today : DateTimeAgo() { @@ -38,10 +57,19 @@ sealed class DateTimeAgo : ListModel { } } - data class DaysAgo(val days: Int) : DateTimeAgo() { + class DaysAgo(val days: Int) : DateTimeAgo() { override fun format(resources: Resources): String { return resources.getQuantityString(R.plurals.days_ago, days, days) } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DaysAgo + return days == other.days + } + + override fun hashCode(): Int = days } object LongAgo : DateTimeAgo() { diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index fdf29d88e..c2205bf30 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -15,19 +15,20 @@ import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.model.ChapterListItem -import org.koitharu.kotatsu.download.DownloadService +import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState class ChaptersFragment : BaseFragment(), - OnListItemClickListener, ActionMode.Callback, AdapterView.OnItemSelectedListener { + OnListItemClickListener, + ActionMode.Callback, + AdapterView.OnItemSelectedListener { private val viewModel by sharedViewModel() @@ -105,9 +106,9 @@ class ChaptersFragment : BaseFragment(), else -> super.onOptionsItemSelected(item) } - override fun onItemClick(item: MangaChapter, view: View) { + override fun onItemClick(item: ChapterListItem, view: View) { if (selectionDecoration?.checkedItemsCount != 0) { - selectionDecoration?.toggleItemChecked(item.id) + selectionDecoration?.toggleItemChecked(item.chapter.id) if (selectionDecoration?.checkedItemsCount == 0) { actionMode?.finish() } else { @@ -116,6 +117,10 @@ class ChaptersFragment : BaseFragment(), } return } + if (item.isMissing) { + (activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id) + return + } val options = ActivityOptions.makeScaleUpAnimation( view, 0, @@ -127,17 +132,17 @@ class ChaptersFragment : BaseFragment(), ReaderActivity.newIntent( view.context, viewModel.manga.value ?: return, - ReaderState(item.id, 0, 0) + ReaderState(item.chapter.id, 0, 0) ), options.toBundle() ) } - override fun onItemLongClick(item: MangaChapter, view: View): Boolean { + override fun onItemLongClick(item: ChapterListItem, view: View): Boolean { if (actionMode == null) { actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) } return actionMode?.also { - selectionDecoration?.setItemIsChecked(item.id, true) + selectionDecoration?.setItemIsChecked(item.chapter.id, true) binding.recyclerViewChapters.invalidateItemDecorations() it.invalidate() } != null @@ -148,7 +153,7 @@ class ChaptersFragment : BaseFragment(), R.id.action_save -> { DownloadService.start( context ?: return false, - viewModel.manga.value ?: return false, + viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false, selectionDecoration?.checkedItemsIds ) mode.finish() @@ -174,17 +179,20 @@ class ChaptersFragment : BaseFragment(), override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { val manga = viewModel.manga.value mode.menuInflater.inflate(R.menu.mode_chapters, menu) - menu.findItem(R.id.action_save).isVisible = manga?.source != MangaSource.LOCAL mode.title = manga?.title return true } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - val count = selectionDecoration?.checkedItemsCount ?: return false + val selectedIds = selectionDecoration?.checkedItemsIds ?: return false + val items = chaptersAdapter?.items?.filter { x -> x.chapter.id in selectedIds }.orEmpty() + menu.findItem(R.id.action_save).isVisible = items.none { x -> + x.chapter.source == MangaSource.LOCAL + } mode.subtitle = resources.getQuantityString( R.plurals.chapters_from_x, - count, - count, + items.size, + items.size, chaptersAdapter?.itemCount ?: 0 ) return true diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 8cc025234..2d7166ae6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -33,15 +33,18 @@ import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.databinding.ActivityDetailsBinding -import org.koitharu.kotatsu.download.DownloadService +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity import org.koitharu.kotatsu.utils.ShareHelper +import org.koitharu.kotatsu.utils.ext.buildAlertDialog import org.koitharu.kotatsu.utils.ext.getDisplayMessage class DetailsActivity : BaseActivity(), TabLayoutMediator.TabConfigurationStrategy { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + private val viewModel by viewModel { parametersOf(MangaIntent.from(intent)) } @@ -82,13 +85,15 @@ class DetailsActivity : BaseActivity(), finishAfterTransition() } else -> { - Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG) - .show() + binding.snackbar.show(e.getDisplayMessage(resources)) } } } override fun onWindowInsetsChanged(insets: Insets) { + binding.snackbar.updatePadding( + bottom = insets.bottom + ) binding.toolbar.updatePadding( top = insets.top, left = insets.left, @@ -228,6 +233,33 @@ class DetailsActivity : BaseActivity(), binding.pager.isUserInputEnabled = true } + fun showChapterMissingDialog(chapterId: Long) { + val remoteManga = viewModel.getRemoteManga() + if (remoteManga == null) { + Snackbar.make(binding.pager, R.string.chapter_is_missing, Snackbar.LENGTH_LONG) + .show() + return + } + buildAlertDialog(this) { + setMessage(R.string.chapter_is_missing_text) + setTitle(R.string.chapter_is_missing) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.read) { _, _ -> + startActivity( + ReaderActivity.newIntent( + this@DetailsActivity, + remoteManga, + ReaderState(chapterId, 0, 0) + ) + ) + } + setNeutralButton(R.string.download) { _, _ -> + DownloadService.start(this@DetailsActivity, remoteManga, setOf(chapterId)) + } + setCancelable(true) + }.show() + } + companion object { const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA" diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt index b3d11d3df..844cb06ac 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt @@ -1,19 +1,21 @@ package org.koitharu.kotatsu.details.ui +import android.app.ActivityOptions import android.os.Bundle import android.text.Spanned import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.Insets import androidx.core.net.toUri import androidx.core.text.parseAsHtml import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader +import coil.request.ImageRequest import coil.util.CoilUtils import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject @@ -23,20 +25,22 @@ import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.model.MangaState import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog +import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.utils.FileSizeUtils import org.koitharu.kotatsu.utils.ext.* -import kotlin.math.roundToInt class DetailsFragment : BaseFragment(), View.OnClickListener, View.OnLongClickListener { private val viewModel by sharedViewModel() private val coil by inject(mode = LazyThreadSafetyMode.NONE) - private var tagsJob: Job? = null override fun onInflateView( inflater: LayoutInflater, @@ -45,6 +49,11 @@ class DetailsFragment : BaseFragment(), View.OnClickList override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.textViewAuthor.setOnClickListener(this) + binding.buttonFavorite.setOnClickListener(this) + binding.buttonRead.setOnClickListener(this) + binding.buttonRead.setOnLongClickListener(this) + binding.coverCard.setOnClickListener(this) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged) viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged) @@ -53,19 +62,33 @@ class DetailsFragment : BaseFragment(), View.OnClickList private fun onMangaUpdated(manga: Manga) { with(binding) { - imageViewCover.newImageRequest(manga.largeCoverUrl ?: manga.coverUrl) - .referer(manga.publicUrl) - .fallback(R.drawable.ic_placeholder) - .placeholderMemoryCacheKey(CoilUtils.metadata(imageViewCover)?.memoryCacheKey) - .lifecycle(viewLifecycleOwner) - .enqueueWith(coil) + // Main + loadCover(manga) textViewTitle.text = manga.title textViewSubtitle.textAndVisible = manga.altTitle textViewAuthor.textAndVisible = manga.author + sourceContainer.isVisible = manga.source != MangaSource.LOCAL textViewSource.text = manga.source.title textViewDescription.text = manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank) ?: getString(R.string.no_description) + when (manga.state) { + MangaState.FINISHED -> { + textViewState.apply { + textAndVisible = resources.getString(R.string.state_finished) + drawableStart = ResourcesCompat.getDrawable(resources, R.drawable.ic_state_finished, context.theme) + } + } + MangaState.ONGOING -> { + textViewState.apply { + textAndVisible = resources.getString(R.string.state_ongoing) + drawableStart = ResourcesCompat.getDrawable(resources, R.drawable.ic_state_ongoing, context.theme) + } + } + else -> textViewState.isVisible = false + } + + // Info containers if (manga.chapters?.isNotEmpty() == true) { chaptersContainer.isVisible = true textViewChapters.text = manga.chapters.let { @@ -96,10 +119,11 @@ class DetailsFragment : BaseFragment(), View.OnClickList } else { sizeContainer.isVisible = false } - buttonFavorite.setOnClickListener(this@DetailsFragment) - buttonRead.setOnClickListener(this@DetailsFragment) - buttonRead.setOnLongClickListener(this@DetailsFragment) + + // Buttons buttonRead.isEnabled = !manga.chapters.isNullOrEmpty() + + // Chips bindTags(manga) } } @@ -127,24 +151,53 @@ class DetailsFragment : BaseFragment(), View.OnClickList } private fun onLoadingStateChanged(isLoading: Boolean) { - binding.progressBar.isVisible = isLoading + if (isLoading) { + binding.progressBar.show() + } else { + binding.progressBar.hide() + } } override fun onClick(v: View) { - val manga = viewModel.manga.value + val manga = viewModel.manga.value ?: return when (v.id) { R.id.button_favorite -> { - FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return) + FavouriteCategoriesDialog.show(childFragmentManager, manga) } R.id.button_read -> { + val chapterId = viewModel.readingHistory.value?.chapterId + if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { + (activity as? DetailsActivity)?.showChapterMissingDialog(chapterId) + } else { + startActivity( + ReaderActivity.newIntent( + context ?: return, + manga, + null + ) + ) + } + } + R.id.textView_author -> { startActivity( - ReaderActivity.newIntent( - context ?: return, - manga ?: return, - null + 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() + ) + } } } @@ -186,16 +239,31 @@ class DetailsFragment : BaseFragment(), View.OnClickList } private fun bindTags(manga: Manga) { - tagsJob?.cancel() - tagsJob = viewLifecycleScope.launch { - val tags = ArrayList(manga.tags.size + 2) - for (tag in manga.tags) { - tags += ChipsView.ChipModel( + binding.chipsTags.setChips( + manga.tags.map { tag -> + ChipsView.ChipModel( title = tag.title, icon = 0 ) } - binding.chipsTags.setChips(tags) + ) + } + + 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) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 4c0292ad5..8b7b241e6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -11,7 +11,11 @@ import org.koitharu.kotatsu.base.domain.MangaIntent import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.model.MangaChapter +import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.domain.ChapterExtra @@ -20,7 +24,9 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.mapToSet +import org.koitharu.kotatsu.utils.ext.toTitleCase import java.io.IOException +import java.util.* class DetailsViewModel( intent: MangaIntent, @@ -29,7 +35,7 @@ class DetailsViewModel( private val localMangaRepository: LocalMangaRepository, private val trackingRepository: TrackingRepository, private val mangaDataRepository: MangaDataRepository, - private val settings: AppSettings + private val settings: AppSettings, ) : BaseViewModel() { private val mangaData = MutableStateFlow(intent.manga) @@ -53,6 +59,18 @@ class DetailsViewModel( trackingRepository.getNewChaptersCount(mangaId) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) + private val remoteManga = MutableStateFlow(null) + /*private val remoteManga = mangaData.mapLatest { + if (it?.source == MangaSource.LOCAL) { + runCatching { + val m = localMangaRepository.getRemoteManga(it) ?: return@mapLatest null + MangaRepository(m.source).getDetails(m) + }.getOrNull() + } else { + null + } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)*/ + private val chaptersReversed = settings.observe() .filter { it == AppSettings.KEY_REVERSE_CHAPTERS } .map { settings.chaptersReverse } @@ -85,24 +103,19 @@ class DetailsViewModel( val chapters = combine( mangaData.map { it?.chapters.orEmpty() }, + remoteManga, history.map { it?.chapterId }, newChapters, - chaptersReversed, selectedBranch - ) { chapters, currentId, newCount, reversed, branch -> - val currentIndex = chapters.indexOfFirst { it.id == currentId } - val firstNewIndex = chapters.size - newCount - val res = chapters.mapIndexed { index, chapter -> - chapter.toListItem( - when { - index >= firstNewIndex -> ChapterExtra.NEW - index == currentIndex -> ChapterExtra.CURRENT - index < currentIndex -> ChapterExtra.READ - else -> ChapterExtra.UNREAD - } - ) - }.filter { it.chapter.branch == branch } - if (reversed) res.asReversed() else res + ) { chapters, sourceManga, currentId, newCount, branch -> + val sourceChapters = sourceManga?.chapters + if (sourceChapters.isNullOrEmpty()) { + mapChapters(chapters, currentId, newCount, branch) + } else { + mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch) + } + }.combine(chaptersReversed) { list, reversed -> + if (reversed) list.asReversed() else list }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) init { @@ -116,11 +129,15 @@ class DetailsViewModel( selectedBranch.value = if (hist != null) { manga.chapters?.find { it.id == hist.chapterId }?.branch } else { - manga.chapters - ?.groupBy { it.branch } - ?.maxByOrNull { it.value.size }?.key + predictBranch(manga.chapters) } mangaData.value = manga + if (manga.source == MangaSource.LOCAL) { + remoteManga.value = runCatching { + val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null + MangaRepository(m.source).getDetails(m) + }.getOrNull() + } } } @@ -142,4 +159,102 @@ class DetailsViewModel( fun setSelectedBranch(branch: String?) { selectedBranch.value = branch } + + fun getRemoteManga(): Manga? { + return remoteManga.value + } + + private fun mapChapters( + chapters: List, + currentId: Long?, + newCount: Int, + branch: String?, + ): List { + val result = ArrayList(chapters.size) + val dateFormat = settings.dateFormat() + val currentIndex = chapters.indexOfFirst { it.id == currentId } + val firstNewIndex = chapters.size - newCount + for (i in chapters.indices) { + val chapter = chapters[i] + if (chapter.branch != branch) { + continue + } + result += chapter.toListItem( + extra = when { + i >= firstNewIndex -> ChapterExtra.NEW + i == currentIndex -> ChapterExtra.CURRENT + i < currentIndex -> ChapterExtra.READ + else -> ChapterExtra.UNREAD + }, + isMissing = false, + dateFormat = dateFormat, + ) + } + return result + } + + private fun mapChaptersWithSource( + chapters: List, + sourceChapters: List, + currentId: Long?, + newCount: Int, + branch: String?, + ): List { + val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id } + val result = ArrayList(sourceChapters.size) + val currentIndex = sourceChapters.indexOfFirst { it.id == currentId } + val firstNewIndex = sourceChapters.size - newCount + val dateFormat = settings.dateFormat() + for (i in sourceChapters.indices) { + val chapter = sourceChapters[i] + if (chapter.branch != branch) { + continue + } + val localChapter = chaptersMap.remove(chapter.id) + result += localChapter?.toListItem( + extra = when { + i >= firstNewIndex -> ChapterExtra.NEW + i == currentIndex -> ChapterExtra.CURRENT + i < currentIndex -> ChapterExtra.READ + else -> ChapterExtra.UNREAD + }, + isMissing = false, + dateFormat = dateFormat, + ) ?: chapter.toListItem( + extra = when { + i >= firstNewIndex -> ChapterExtra.NEW + i == currentIndex -> ChapterExtra.CURRENT + i < currentIndex -> ChapterExtra.READ + else -> ChapterExtra.UNREAD + }, + isMissing = true, + dateFormat = dateFormat, + ) + } + if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source + result.ensureCapacity(result.size + chaptersMap.size) + chaptersMap.values.mapTo(result) { + it.toListItem(ChapterExtra.UNREAD, false, dateFormat) + } + result.sortBy { it.chapter.number } + } + return result + } + + private fun predictBranch(chapters: List?): String? { + if (chapters.isNullOrEmpty()) { + return null + } + val groups = chapters.groupBy { it.branch } + val locale = Locale.getDefault() + var language = locale.displayLanguage.toTitleCase(locale) + if (groups.containsKey(language)) { + return language + } + language = locale.displayName.toTitleCase(locale) + if (groups.containsKey(language)) { + return language + } + return groups.maxByOrNull { it.value.size }?.key + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index d339216c0..8a6d9b3e4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -3,28 +3,29 @@ package org.koitharu.kotatsu.details.ui.adapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.history.domain.ChapterExtra import org.koitharu.kotatsu.utils.ext.getThemeColor +import org.koitharu.kotatsu.utils.ext.textAndVisible fun chapterListItemAD( - clickListener: OnListItemClickListener + clickListener: OnListItemClickListener, ) = adapterDelegateViewBinding( { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } ) { itemView.setOnClickListener { - clickListener.onItemClick(item.chapter, it) + clickListener.onItemClick(item, it) } itemView.setOnLongClickListener { - clickListener.onItemLongClick(item.chapter, it) + clickListener.onItemLongClick(item, it) } - bind { payload -> + bind { binding.textViewTitle.text = item.chapter.name binding.textViewNumber.text = item.chapter.number.toString() + binding.textViewDescription.textAndVisible = item.description() when (item.extra) { ChapterExtra.UNREAD -> { binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default) @@ -43,5 +44,8 @@ fun chapterListItemAD( binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse)) } } + binding.textViewTitle.alpha = if (item.isMissing) 0.3f else 1f + binding.textViewDescription.alpha = if (item.isMissing) 0.3f else 1f + binding.textViewNumber.alpha = if (item.isMissing) 0.3f else 1f } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt index b98a427e7..855f0f4e9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/adapter/ChaptersAdapter.kt @@ -3,12 +3,11 @@ package org.koitharu.kotatsu.details.ui.adapter import androidx.recyclerview.widget.DiffUtil import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.details.ui.model.ChapterListItem import kotlin.jvm.internal.Intrinsics class ChaptersAdapter( - onItemClickListener: OnListItemClickListener + onItemClickListener: OnListItemClickListener, ) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { @@ -20,10 +19,6 @@ class ChaptersAdapter( return items[position].chapter.id } - fun setItems(newItems: List, callback: Runnable) { - differ.submitList(newItems, callback) - } - private class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ChapterListItem, newItem: ChapterListItem): Boolean { @@ -38,7 +33,7 @@ class ChaptersAdapter( } override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? { - if (oldItem.extra != newItem.extra) { + if (oldItem.extra != newItem.extra && oldItem.chapter == newItem.chapter) { return newItem.extra } return null diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt index 5fad6e03a..f45228688 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt @@ -5,5 +5,17 @@ import org.koitharu.kotatsu.history.domain.ChapterExtra data class ChapterListItem( val chapter: MangaChapter, - val extra: ChapterExtra -) + val extra: ChapterExtra, + val isMissing: Boolean, + val uploadDate: String?, +) { + + fun description(): CharSequence? { + val scanlator = chapter.scanlator?.takeUnless { it.isBlank() } + return when { + uploadDate != null && scanlator != null -> "$uploadDate • $scanlator" + scanlator != null -> scanlator + else -> uploadDate + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt index dc1df8e0f..a5de76fd1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ListModelConversionExt.kt @@ -2,8 +2,15 @@ package org.koitharu.kotatsu.details.ui.model import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.history.domain.ChapterExtra +import java.text.DateFormat -fun MangaChapter.toListItem(extra: ChapterExtra) = ChapterListItem( +fun MangaChapter.toListItem( + extra: ChapterExtra, + isMissing: Boolean, + dateFormat: DateFormat, +) = ChapterListItem( chapter = this, - extra = extra + extra = extra, + isMissing = isMissing, + uploadDate = if (uploadDate != 0L) dateFormat.format(uploadDate) else null ) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt deleted file mode 100644 index 10868d5e3..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/DownloadNotification.kt +++ /dev/null @@ -1,153 +0,0 @@ -package org.koitharu.kotatsu.download - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.graphics.drawable.Drawable -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat -import androidx.core.graphics.drawable.toBitmap -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.utils.PendingIntentCompat -import org.koitharu.kotatsu.utils.ext.getDisplayMessage -import kotlin.math.roundToInt - -class DownloadNotification(private val context: Context) { - - private val builder = NotificationCompat.Builder(context, CHANNEL_ID) - private val manager = - context.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - init { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - && manager.getNotificationChannel(CHANNEL_ID) == null - ) { - val channel = NotificationChannel( - CHANNEL_ID, - context.getString(R.string.downloads), - NotificationManager.IMPORTANCE_LOW - ) - channel.enableVibration(false) - channel.enableLights(false) - channel.setSound(null, null) - manager.createNotificationChannel(channel) - } - builder.setOnlyAlertOnce(true) - builder.setDefaults(0) - builder.color = ContextCompat.getColor(context, R.color.blue_primary) - } - - fun fillFrom(manga: Manga) { - builder.setContentTitle(manga.title) - builder.setContentText(context.getString(R.string.manga_downloading_)) - builder.setProgress(1, 0, true) - builder.setSmallIcon(android.R.drawable.stat_sys_download) - builder.setLargeIcon(null) - builder.setContentIntent(null) - builder.setStyle(null) - } - - fun setCancelId(startId: Int) { - if (startId == 0) { - builder.clearActions() - } else { - val intent = DownloadService.getCancelIntent(context, startId) - builder.addAction( - R.drawable.ic_cross, - context.getString(android.R.string.cancel), - PendingIntent.getService( - context, - startId, - intent, - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE - ) - ) - } - } - - fun setError(e: Throwable) { - val message = e.getDisplayMessage(context.resources) - builder.setProgress(0, 0, false) - builder.setSmallIcon(android.R.drawable.stat_notify_error) - builder.setSubText(context.getString(R.string.error)) - builder.setContentText(message) - builder.setAutoCancel(true) - builder.setContentIntent(null) - builder.setCategory(NotificationCompat.CATEGORY_ERROR) - builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) - } - - fun setLargeIcon(icon: Drawable?) { - builder.setLargeIcon(icon?.toBitmap()) - } - - fun setProgress(chaptersTotal: Int, pagesTotal: Int, chapter: Int, page: Int) { - val max = chaptersTotal * PROGRESS_STEP - val progress = - chapter * PROGRESS_STEP + (page / pagesTotal.toFloat() * PROGRESS_STEP).roundToInt() - val percent = (progress / max.toFloat() * 100).roundToInt() - builder.setProgress(max, progress, false) - builder.setContentText("%d%%".format(percent)) - builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) - builder.setStyle(null) - } - - fun setWaitingForNetwork() { - builder.setProgress(0, 0, false) - builder.setContentText(context.getString(R.string.waiting_for_network)) - builder.setStyle(null) - } - - fun setPostProcessing() { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.processing_)) - builder.setStyle(null) - } - - fun setDone(manga: Manga) { - builder.setProgress(0, 0, false) - builder.setContentText(context.getString(R.string.download_complete)) - builder.setContentIntent(createIntent(context, manga)) - builder.setAutoCancel(true) - builder.setSmallIcon(android.R.drawable.stat_sys_download_done) - builder.setCategory(null) - builder.setStyle(null) - } - - fun setCancelling() { - builder.setProgress(1, 0, true) - builder.setContentText(context.getString(R.string.cancelling_)) - builder.setContentIntent(null) - builder.setStyle(null) - } - - fun update(id: Int = NOTIFICATION_ID) { - manager.notify(id, builder.build()) - } - - fun dismiss(id: Int = NOTIFICATION_ID) { - manager.cancel(id) - } - - operator fun invoke(): Notification = builder.build() - - companion object { - - const val NOTIFICATION_ID = 201 - const val CHANNEL_ID = "download" - - private const val PROGRESS_STEP = 20 - - private fun createIntent(context: Context, manga: Manga) = PendingIntent.getActivity( - context, - manga.hashCode(), - DetailsActivity.newIntent(context, manga), - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt deleted file mode 100644 index 2cc0ca30d..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/download/DownloadService.kt +++ /dev/null @@ -1,274 +0,0 @@ -package org.koitharu.kotatsu.download - -import android.content.Context -import android.content.Intent -import android.net.ConnectivityManager -import android.os.PowerManager -import android.webkit.MimeTypeMap -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import coil.ImageLoader -import coil.request.ImageRequest -import kotlinx.coroutines.* -import kotlinx.coroutines.sync.Mutex -import okhttp3.OkHttpClient -import okhttp3.Request -import okio.IOException -import org.koin.android.ext.android.get -import org.koin.android.ext.android.inject -import org.koin.core.context.GlobalContext -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.ui.BaseService -import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog -import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.core.network.CommonHeaders -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.local.data.MangaZip -import org.koitharu.kotatsu.local.data.PagesCache -import org.koitharu.kotatsu.local.domain.LocalMangaRepository -import org.koitharu.kotatsu.utils.CacheUtils -import org.koitharu.kotatsu.utils.ext.* -import java.io.File -import java.util.concurrent.TimeUnit -import kotlin.collections.set -import kotlin.math.absoluteValue - -class DownloadService : BaseService() { - - private lateinit var notification: DownloadNotification - private lateinit var wakeLock: PowerManager.WakeLock - private lateinit var connectivityManager: ConnectivityManager - - private val okHttp by inject() - private val cache by inject() - private val settings by inject() - private val imageLoader by inject() - private val jobs = HashMap() - private val mutex = Mutex() - - override fun onCreate() { - super.onCreate() - notification = DownloadNotification(this) - connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) - .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - when (intent?.action) { - ACTION_DOWNLOAD_START -> { - val manga = intent.getParcelableExtra(EXTRA_MANGA) - val chapters = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet() - if (manga != null) { - jobs[startId] = downloadManga(manga, chapters, startId) - Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show() - } else { - stopSelf(startId) - } - } - ACTION_DOWNLOAD_CANCEL -> { - val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) - jobs.remove(cancelId)?.cancel() - stopSelf(startId) - } - else -> stopSelf(startId) - } - return START_NOT_STICKY - } - - private fun downloadManga(manga: Manga, chaptersIds: Set?, startId: Int): Job { - return lifecycleScope.launch(Dispatchers.Default) { - mutex.lock() - wakeLock.acquire(TimeUnit.HOURS.toMillis(1)) - notification.fillFrom(manga) - notification.setCancelId(startId) - withContext(Dispatchers.Main) { - startForeground(DownloadNotification.NOTIFICATION_ID, notification()) - } - val destination = settings.getStorageDir(this@DownloadService) - checkNotNull(destination) { getString(R.string.cannot_find_available_storage) } - var output: MangaZip? = null - try { - val repo = mangaRepositoryOf(manga.source) - val cover = runCatching { - imageLoader.execute( - ImageRequest.Builder(this@DownloadService) - .data(manga.coverUrl) - .build() - ).drawable - }.getOrNull() - notification.setLargeIcon(cover) - notification.update() - val data = if (manga.chapters == null) repo.getDetails(manga) else manga - output = MangaZip.findInDir(destination, data) - output.prepare(data) - val coverUrl = data.largeCoverUrl ?: data.coverUrl - downloadFile(coverUrl, data.publicUrl, destination).let { file -> - output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) - } - val chapters = if (chaptersIds == null) { - data.chapters.orEmpty() - } else { - data.chapters.orEmpty().filter { x -> x.id in chaptersIds } - } - for ((chapterIndex, chapter) in chapters.withIndex()) { - if (chaptersIds == null || chapter.id in chaptersIds) { - val pages = repo.getPages(chapter) - for ((pageIndex, page) in pages.withIndex()) { - failsafe@ do { - try { - val url = repo.getPageUrl(page) - val file = - cache[url] ?: downloadFile(url, page.referer, destination) - output.addPage( - chapter, - file, - pageIndex, - MimeTypeMap.getFileExtensionFromUrl(url) - ) - } catch (e: IOException) { - notification.setWaitingForNetwork() - notification.update() - connectivityManager.waitForNetwork() - continue@failsafe - } - } while (false) - notification.setProgress( - chapters.size, - pages.size, - chapterIndex, - pageIndex - ) - notification.update() - } - } - } - notification.setCancelId(0) - notification.setPostProcessing() - notification.update() - if (!output.compress()) { - throw RuntimeException("Cannot create target file") - } - val result = get().getFromFile(output.file) - notification.setDone(result) - notification.dismiss() - notification.update(manga.id.toInt().absoluteValue) - } catch (_: CancellationException) { - withContext(NonCancellable) { - notification.setCancelling() - notification.setCancelId(0) - notification.update() - } - } catch (e: Throwable) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } - notification.setError(e) - notification.setCancelId(0) - notification.dismiss() - notification.update(manga.id.toInt().absoluteValue) - } finally { - withContext(NonCancellable) { - jobs.remove(startId) - output?.cleanup() - destination.sub(TEMP_PAGE_FILE).deleteAwait() - withContext(Dispatchers.Main) { - stopForeground(true) - notification.dismiss() - stopSelf(startId) - } - if (wakeLock.isHeld) { - wakeLock.release() - } - mutex.unlock() - } - } - } - } - - private suspend fun downloadFile(url: String, referer: String, destination: File): File { - val request = Request.Builder() - .url(url) - .header(CommonHeaders.REFERER, referer) - .cacheControl(CacheUtils.CONTROL_DISABLED) - .get() - .build() - val call = okHttp.newCall(request) - var attempts = MAX_DOWNLOAD_ATTEMPTS - val file = destination.sub(TEMP_PAGE_FILE) - while (true) { - try { - val response = call.clone().await() - withContext(Dispatchers.IO) { - file.outputStream().use { out -> - checkNotNull(response.body).byteStream().copyTo(out) - } - } - return file - } catch (e: IOException) { - attempts-- - if (attempts <= 0) { - throw e - } else { - delay(DOWNLOAD_ERROR_DELAY) - } - } - } - } - - companion object { - - private const val ACTION_DOWNLOAD_START = - "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_START" - private const val ACTION_DOWNLOAD_CANCEL = - "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" - - private const val EXTRA_MANGA = "manga" - private const val EXTRA_CHAPTERS_IDS = "chapters_ids" - private const val EXTRA_CANCEL_ID = "cancel_id" - - private const val MAX_DOWNLOAD_ATTEMPTS = 3 - private const val DOWNLOAD_ERROR_DELAY = 500L - private const val TEMP_PAGE_FILE = "page.tmp" - - fun start(context: Context, manga: Manga, chaptersIds: Collection? = null) { - confirmDataTransfer(context) { - val intent = Intent(context, DownloadService::class.java) - intent.action = ACTION_DOWNLOAD_START - intent.putExtra(EXTRA_MANGA, manga) - if (chaptersIds != null) { - intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) - } - ContextCompat.startForegroundService(context, intent) - } - } - - fun getCancelIntent(context: Context, startId: Int) = - Intent(context, DownloadService::class.java) - .setAction(ACTION_DOWNLOAD_CANCEL) - .putExtra(ACTION_DOWNLOAD_CANCEL, startId) - - private fun confirmDataTransfer(context: Context, callback: () -> Unit) { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val settings = GlobalContext.get().get() - if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) { - CheckBoxAlertDialog.Builder(context) - .setTitle(R.string.warning) - .setMessage(R.string.network_consumption_warning) - .setCheckBoxText(R.string.dont_ask_again) - .setCheckBoxChecked(false) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string._continue) { _, doNotAsk -> - settings.isTrafficWarningEnabled = !doNotAsk - callback() - }.create() - .show() - } else { - callback() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt new file mode 100644 index 000000000..75905aa51 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt @@ -0,0 +1,239 @@ +package org.koitharu.kotatsu.download.domain + +import android.content.Context +import android.graphics.drawable.Drawable +import android.net.ConnectivityManager +import android.webkit.MimeTypeMap +import coil.ImageLoader +import coil.request.ImageRequest +import coil.size.Scale +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.IOException +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.local.data.MangaZip +import org.koitharu.kotatsu.local.data.PagesCache +import org.koitharu.kotatsu.local.domain.LocalMangaRepository +import org.koitharu.kotatsu.utils.CacheUtils +import org.koitharu.kotatsu.utils.ext.await +import org.koitharu.kotatsu.utils.ext.deleteAwait +import org.koitharu.kotatsu.utils.ext.waitForNetwork +import java.io.File + +class DownloadManager( + private val context: Context, + private val settings: AppSettings, + private val imageLoader: ImageLoader, + private val okHttp: OkHttpClient, + private val cache: PagesCache, + private val localMangaRepository: LocalMangaRepository, +) { + + private val connectivityManager = context.getSystemService( + Context.CONNECTIVITY_SERVICE + ) as ConnectivityManager + private val coverWidth = context.resources.getDimensionPixelSize( + androidx.core.R.dimen.compat_notification_large_icon_max_width + ) + private val coverHeight = context.resources.getDimensionPixelSize( + androidx.core.R.dimen.compat_notification_large_icon_max_height + ) + + fun downloadManga(manga: Manga, chaptersIds: Set?, startId: Int) = flow { + emit(State.Preparing(startId, manga, null)) + var cover: Drawable? = null + val destination = settings.getStorageDir(context) + checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } + var output: MangaZip? = null + try { + val repo = MangaRepository(manga.source) + cover = runCatching { + imageLoader.execute( + ImageRequest.Builder(context) + .data(manga.coverUrl) + .size(coverWidth, coverHeight) + .scale(Scale.FILL) + .build() + ).drawable + }.getOrNull() + emit(State.Preparing(startId, manga, cover)) + val data = if (manga.chapters == null) repo.getDetails(manga) else manga + output = MangaZip.findInDir(destination, data) + output.prepare(data) + val coverUrl = data.largeCoverUrl ?: data.coverUrl + downloadFile(coverUrl, data.publicUrl, destination).let { file -> + output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) + } + val chapters = if (chaptersIds == null) { + data.chapters.orEmpty() + } else { + data.chapters.orEmpty().filter { x -> x.id in chaptersIds } + } + for ((chapterIndex, chapter) in chapters.withIndex()) { + if (chaptersIds == null || chapter.id in chaptersIds) { + val pages = repo.getPages(chapter) + for ((pageIndex, page) in pages.withIndex()) { + failsafe@ do { + try { + val url = repo.getPageUrl(page) + val file = + cache[url] ?: downloadFile(url, page.referer, destination) + output.addPage( + chapter, + file, + pageIndex, + MimeTypeMap.getFileExtensionFromUrl(url) + ) + } catch (e: IOException) { + emit(State.WaitingForNetwork(startId, manga, cover)) + connectivityManager.waitForNetwork() + continue@failsafe + } + } while (false) + + emit(State.Progress( + startId, manga, cover, + totalChapters = chapters.size, + currentChapter = chapterIndex, + totalPages = pages.size, + currentPage = pageIndex, + )) + } + } + } + emit(State.PostProcessing(startId, manga, cover)) + if (!output.compress()) { + throw RuntimeException("Cannot create target file") + } + val localManga = localMangaRepository.getFromFile(output.file) + emit(State.Done(startId, manga, cover, localManga)) + } catch (_: CancellationException) { + emit(State.Cancelling(startId, manga, cover)) + } catch (e: Throwable) { + if (BuildConfig.DEBUG) { + e.printStackTrace() + } + emit(State.Error(startId, manga, cover, e)) + } finally { + withContext(NonCancellable) { + output?.cleanup() + File(destination, TEMP_PAGE_FILE).deleteAwait() + } + } + }.catch { e -> + emit(State.Error(startId, manga, null, e)) + } + + private suspend fun downloadFile(url: String, referer: String, destination: File): File { + val request = Request.Builder() + .url(url) + .header(CommonHeaders.REFERER, referer) + .cacheControl(CacheUtils.CONTROL_DISABLED) + .get() + .build() + val call = okHttp.newCall(request) + var attempts = MAX_DOWNLOAD_ATTEMPTS + val file = File(destination, TEMP_PAGE_FILE) + while (true) { + try { + val response = call.clone().await() + withContext(Dispatchers.IO) { + file.outputStream().use { out -> + checkNotNull(response.body).byteStream().copyTo(out) + } + } + return file + } catch (e: IOException) { + attempts-- + if (attempts <= 0) { + throw e + } else { + delay(DOWNLOAD_ERROR_DELAY) + } + } + } + } + + sealed interface State { + + val startId: Int + val manga: Manga + val cover: Drawable? + + data class Queued( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : State + + data class Preparing( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : State + + data class Progress( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val totalChapters: Int, + val currentChapter: Int, + val totalPages: Int, + val currentPage: Int, + ): State { + + val max: Int = totalChapters * totalPages + + val progress: Int = totalPages * currentChapter + currentPage + 1 + + val percent: Float = progress.toFloat() / max + } + + data class WaitingForNetwork( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ): State + + data class Done( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val localManga: Manga, + ) : State + + data class Error( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + val error: Throwable, + ) : State + + data class Cancelling( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ): State + + data class PostProcessing( + override val startId: Int, + override val manga: Manga, + override val cover: Drawable?, + ) : State + } + + private companion object { + + private const val MAX_DOWNLOAD_ATTEMPTS = 3 + private const val DOWNLOAD_ERROR_DELAY = 500L + private const val TEMP_PAGE_FILE = "page.tmp" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt new file mode 100644 index 000000000..20724d769 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadItemAD.kt @@ -0,0 +1,106 @@ +package org.koitharu.kotatsu.download.ui + +import androidx.core.view.isVisible +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemDownloadBinding +import org.koitharu.kotatsu.download.domain.DownloadManager +import org.koitharu.kotatsu.utils.JobStateFlow +import org.koitharu.kotatsu.utils.ext.* + +fun downloadItemAD( + scope: CoroutineScope, + coil: ImageLoader, +) = adapterDelegateViewBinding, JobStateFlow, ItemDownloadBinding>( + { inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) } +) { + + var job: Job? = null + + bind { + job?.cancel() + 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 + when (state) { + is DownloadManager.State.Cancelling -> { + binding.textViewStatus.setText(R.string.cancelling_) + binding.progressBar.setIndeterminateCompat(true) + binding.progressBar.isVisible = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadManager.State.Done -> { + binding.textViewStatus.setText(R.string.download_complete) + binding.progressBar.setIndeterminateCompat(false) + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadManager.State.Error -> { + binding.textViewStatus.setText(R.string.error_occurred) + binding.progressBar.setIndeterminateCompat(false) + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.text = state.error.getDisplayMessage(context.resources) + binding.textViewDetails.isVisible = true + } + is DownloadManager.State.PostProcessing -> { + binding.textViewStatus.setText(R.string.processing_) + binding.progressBar.setIndeterminateCompat(true) + binding.progressBar.isVisible = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadManager.State.Preparing -> { + binding.textViewStatus.setText(R.string.preparing_) + binding.progressBar.setIndeterminateCompat(true) + binding.progressBar.isVisible = true + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadManager.State.Progress -> { + binding.textViewStatus.setText(R.string.manga_downloading_) + binding.progressBar.setIndeterminateCompat(false) + binding.progressBar.isVisible = true + binding.progressBar.max = state.max + binding.progressBar.setProgressCompat(state.progress, true) + binding.textViewPercent.text = (state.percent * 100f).format(1) + "%" + binding.textViewPercent.isVisible = true + binding.textViewDetails.isVisible = false + } + is DownloadManager.State.Queued -> { + binding.textViewStatus.setText(R.string.queued) + binding.progressBar.setIndeterminateCompat(false) + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + is DownloadManager.State.WaitingForNetwork -> { + binding.textViewStatus.setText(R.string.waiting_for_network) + binding.progressBar.setIndeterminateCompat(false) + binding.progressBar.isVisible = false + binding.textViewPercent.isVisible = false + binding.textViewDetails.isVisible = false + } + } + }.launchIn(scope) + } + + onViewRecycled { + job?.cancel() + job = null + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt new file mode 100644 index 000000000..b9f3695da --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsActivity.kt @@ -0,0 +1,59 @@ +package org.koitharu.kotatsu.download.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.koin.android.ext.android.get +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding +import org.koitharu.kotatsu.download.ui.service.DownloadService +import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection + +class DownloadsActivity : BaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + val adapter = DownloadsAdapter(lifecycleScope, get()) + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.adapter = adapter + LifecycleAwareServiceConnection.bindService( + this, + this, + Intent(this, DownloadService::class.java), + 0 + ).service.flatMapLatest { binder -> + (binder as? DownloadService.DownloadBinder)?.downloads ?: flowOf(null) + }.onEach { + adapter.items = it?.toList().orEmpty() + binding.textViewHolder.isVisible = it.isNullOrEmpty() + }.launchIn(lifecycleScope) + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.recyclerView.updatePadding( + left = insets.left, + right = insets.right, + bottom = insets.bottom + ) + binding.toolbar.updatePadding( + left = insets.left, + right = insets.right, + top = insets.top + ) + } + + companion object { + + fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt new file mode 100644 index 000000000..325180a79 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/DownloadsAdapter.kt @@ -0,0 +1,40 @@ +package org.koitharu.kotatsu.download.ui + +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import kotlinx.coroutines.CoroutineScope +import org.koitharu.kotatsu.download.domain.DownloadManager +import org.koitharu.kotatsu.utils.JobStateFlow + +class DownloadsAdapter( + scope: CoroutineScope, + coil: ImageLoader, +) : AsyncListDifferDelegationAdapter>(DiffCallback()) { + + init { + delegatesManager.addDelegate(downloadItemAD(scope, coil)) + setHasStableIds(true) + } + + override fun getItemId(position: Int): Long { + return items[position].value.startId.toLong() + } + + private class DiffCallback : DiffUtil.ItemCallback>() { + + override fun areItemsTheSame( + oldItem: JobStateFlow, + newItem: JobStateFlow, + ): Boolean { + return oldItem.value.startId == newItem.value.startId + } + + override fun areContentsTheSame( + oldItem: JobStateFlow, + newItem: JobStateFlow, + ): Boolean { + return oldItem.value == newItem.value + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt new file mode 100644 index 000000000..0d38a3326 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt @@ -0,0 +1,144 @@ +package org.koitharu.kotatsu.download.ui.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.toBitmap +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.download.domain.DownloadManager +import org.koitharu.kotatsu.download.ui.DownloadsActivity +import org.koitharu.kotatsu.utils.PendingIntentCompat +import org.koitharu.kotatsu.utils.ext.format +import org.koitharu.kotatsu.utils.ext.getDisplayMessage + +class DownloadNotification( + private val context: Context, + startId: Int, +) { + + private val builder = NotificationCompat.Builder(context, CHANNEL_ID) + private val cancelAction = NotificationCompat.Action( + R.drawable.ic_cross, + context.getString(android.R.string.cancel), + PendingIntent.getBroadcast( + context, + startId, + DownloadService.getCancelIntent(startId), + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + ) + private val listIntent = PendingIntent.getActivity( + context, + REQUEST_LIST, + DownloadsActivity.newIntent(context), + PendingIntentCompat.FLAG_IMMUTABLE, + ) + + init { + builder.setOnlyAlertOnce(true) + builder.setDefaults(0) + builder.color = ContextCompat.getColor(context, R.color.blue_primary) + } + + fun create(state: DownloadManager.State): Notification { + builder.setContentTitle(state.manga.title) + builder.setContentText(context.getString(R.string.manga_downloading_)) + builder.setProgress(1, 0, true) + builder.setSmallIcon(android.R.drawable.stat_sys_download) + builder.setContentIntent(listIntent) + builder.setStyle(null) + builder.setLargeIcon(state.cover?.toBitmap()) + builder.clearActions() + when (state) { + is DownloadManager.State.Cancelling -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.cancelling_)) + builder.setContentIntent(null) + builder.setStyle(null) + } + is DownloadManager.State.Done -> { + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.download_complete)) + builder.setContentIntent(createMangaIntent(context, state.localManga)) + builder.setAutoCancel(true) + builder.setSmallIcon(android.R.drawable.stat_sys_download_done) + builder.setCategory(null) + builder.setStyle(null) + } + is DownloadManager.State.Error -> { + val message = state.error.getDisplayMessage(context.resources) + builder.setProgress(0, 0, false) + builder.setSmallIcon(android.R.drawable.stat_notify_error) + builder.setSubText(context.getString(R.string.error)) + builder.setContentText(message) + builder.setAutoCancel(true) + builder.setCategory(NotificationCompat.CATEGORY_ERROR) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(message)) + } + is DownloadManager.State.PostProcessing -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.processing_)) + builder.setStyle(null) + } + is DownloadManager.State.Queued, + is DownloadManager.State.Preparing -> { + builder.setProgress(1, 0, true) + builder.setContentText(context.getString(R.string.preparing_)) + builder.setStyle(null) + builder.addAction(cancelAction) + } + is DownloadManager.State.Progress -> { + builder.setProgress(state.max, state.progress, false) + builder.setContentText((state.percent * 100).format() + "%") + builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) + builder.setStyle(null) + builder.addAction(cancelAction) + } + is DownloadManager.State.WaitingForNetwork -> { + builder.setProgress(0, 0, false) + builder.setContentText(context.getString(R.string.waiting_for_network)) + builder.setStyle(null) + builder.addAction(cancelAction) + } + } + return builder.build() + } + + private fun createMangaIntent(context: Context, manga: Manga) = PendingIntent.getActivity( + context, + manga.hashCode(), + DetailsActivity.newIntent(context, manga), + PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + + companion object { + + private const val CHANNEL_ID = "download" + private const val REQUEST_LIST = 6 + + fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = NotificationManagerCompat.from(context) + if (manager.getNotificationChannel(CHANNEL_ID) == null) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.downloads), + NotificationManager.IMPORTANCE_LOW + ) + channel.enableVibration(false) + channel.enableLights(false) + channel.setSound(null, null) + manager.createNotificationChannel(channel) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt new file mode 100644 index 000000000..d44204533 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt @@ -0,0 +1,201 @@ +package org.koitharu.kotatsu.download.ui.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.os.Binder +import android.os.IBinder +import android.os.PowerManager +import android.widget.Toast +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.koin.android.ext.android.get +import org.koin.core.context.GlobalContext +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseService +import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog +import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.download.domain.DownloadManager +import org.koitharu.kotatsu.utils.JobStateFlow +import org.koitharu.kotatsu.utils.ext.toArraySet +import java.util.concurrent.TimeUnit +import kotlin.collections.set + +class DownloadService : BaseService() { + + private lateinit var notificationManager: NotificationManagerCompat + private lateinit var wakeLock: PowerManager.WakeLock + private lateinit var downloadManager: DownloadManager + + private val jobs = LinkedHashMap>() + private val jobCount = MutableStateFlow(0) + private val mutex = Mutex() + private val controlReceiver = ControlReceiver() + private var binder: DownloadBinder? = null + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(this) + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") + downloadManager = DownloadManager(this, get(), get(), get(), get(), get()) + DownloadNotification.createChannel(this) + registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + val manga = intent?.getParcelableExtra(EXTRA_MANGA) + val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet() + return if (manga != null) { + jobs[startId] = downloadManga(startId, manga, chapters) + jobCount.value = jobs.size + Toast.makeText(this, R.string.manga_downloading_, Toast.LENGTH_SHORT).show() + START_REDELIVER_INTENT + } else { + stopSelf(startId) + START_NOT_STICKY + } + } + + override fun onBind(intent: Intent): IBinder { + super.onBind(intent) + return binder ?: DownloadBinder(this).also { binder = it } + } + + override fun onDestroy() { + unregisterReceiver(controlReceiver) + binder = null + super.onDestroy() + } + + private fun downloadManga( + startId: Int, + manga: Manga, + chaptersIds: Set?, + ): JobStateFlow { + val initialState = DownloadManager.State.Queued(startId, manga, null) + val stateFlow = MutableStateFlow(initialState) + val job = lifecycleScope.launch { + mutex.withLock { + wakeLock.acquire(TimeUnit.HOURS.toMillis(1)) + val notification = DownloadNotification(this@DownloadService, startId) + startForeground(startId, notification.create(initialState)) + try { + withContext(Dispatchers.Default) { + downloadManager.downloadManga(manga, chaptersIds, startId) + .collect { state -> + stateFlow.value = state + notificationManager.notify(startId, notification.create(state)) + } + } + if (stateFlow.value is DownloadManager.State.Done) { + sendBroadcast( + Intent(ACTION_DOWNLOAD_COMPLETE) + .putExtra(EXTRA_MANGA, manga) + ) + } + } finally { + ServiceCompat.stopForeground( + this@DownloadService, + if (isActive) { + ServiceCompat.STOP_FOREGROUND_DETACH + } else { + ServiceCompat.STOP_FOREGROUND_REMOVE + } + ) + if (wakeLock.isHeld) { + wakeLock.release() + } + stopSelf(startId) + } + } + } + return JobStateFlow(stateFlow, job) + } + + inner class ControlReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent?) { + when (intent?.action) { + ACTION_DOWNLOAD_CANCEL -> { + val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0) + jobs.remove(cancelId)?.cancel() + jobCount.value = jobs.size + } + } + } + } + + class DownloadBinder(private val service: DownloadService) : Binder() { + + val downloads: Flow>> + get() = service.jobCount.mapLatest { service.jobs.values } + } + + companion object { + + const val ACTION_DOWNLOAD_COMPLETE = + "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" + + private const val ACTION_DOWNLOAD_CANCEL = + "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL" + + private const val EXTRA_MANGA = "manga" + private const val EXTRA_CHAPTERS_IDS = "chapters_ids" + private const val EXTRA_CANCEL_ID = "cancel_id" + + fun start(context: Context, manga: Manga, chaptersIds: Collection? = null) { + if (chaptersIds?.isEmpty() == true) { + return + } + confirmDataTransfer(context) { + val intent = Intent(context, DownloadService::class.java) + intent.putExtra(EXTRA_MANGA, manga) + if (chaptersIds != null) { + intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) + } + ContextCompat.startForegroundService(context, intent) + } + } + + fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL) + .putExtra(ACTION_DOWNLOAD_CANCEL, startId) + + private fun confirmDataTransfer(context: Context, callback: () -> Unit) { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val settings = GlobalContext.get().get() + if (cm.isActiveNetworkMetered && settings.isTrafficWarningEnabled) { + CheckBoxAlertDialog.Builder(context) + .setTitle(R.string.warning) + .setMessage(R.string.network_consumption_warning) + .setCheckBoxText(R.string.dont_ask_again) + .setCheckBoxChecked(false) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string._continue) { _, doNotAsk -> + settings.isTrafficWarningEnabled = !doNotAsk + callback() + }.create() + .show() + } else { + callback() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt index e56cf6999..436dc12ea 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt @@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites.data import androidx.room.* import kotlinx.coroutines.flow.Flow -import org.koitharu.kotatsu.core.model.FavouriteCategory @Dao abstract class FavouriteCategoriesDao { @@ -13,6 +12,9 @@ abstract class FavouriteCategoriesDao { @Query("SELECT * FROM favourite_categories ORDER BY sort_key") abstract fun observeAll(): Flow> + @Query("SELECT * FROM favourite_categories WHERE category_id = :id") + abstract fun observe(id: Long): Flow + @Insert(onConflict = OnConflictStrategy.ABORT) abstract suspend fun insert(category: FavouriteCategoryEntity): Long @@ -23,10 +25,13 @@ abstract class FavouriteCategoriesDao { abstract suspend fun delete(id: Long) @Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id") - abstract suspend fun update(id: Long, title: String) + abstract suspend fun updateTitle(id: Long, title: String) + + @Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id") + abstract suspend fun updateOrder(id: Long, order: String) @Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id") - abstract suspend fun update(id: Long, sortKey: Int) + abstract suspend fun updateSortKey(id: Long, sortKey: Int) @Query("SELECT MAX(sort_key) FROM favourite_categories") protected abstract suspend fun getMaxSortKey(): Int? diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt index 1da715ae5..aa054c441 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt @@ -4,21 +4,24 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.SortOrder import java.util.* @Entity(tableName = "favourite_categories") -data class FavouriteCategoryEntity( +class FavouriteCategoryEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "sort_key") val sortKey: Int, - @ColumnInfo(name = "title") val title: String + @ColumnInfo(name = "title") val title: String, + @ColumnInfo(name = "order") val order: String, ) { fun toFavouriteCategory(id: Long? = null) = FavouriteCategory( id = id ?: categoryId.toLong(), title = title, sortKey = sortKey, - createdAt = Date(createdAt) + order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST, + createdAt = Date(createdAt), ) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt index 95ae66e87..d79660a12 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt @@ -21,7 +21,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity ) ] ) -data class FavouriteEntity( +class FavouriteEntity( @ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "category_id", index = true) val categoryId: Long, @ColumnInfo(name = "created_at") val createdAt: Long diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt index 5b74bc08f..e98c84904 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteManga.kt @@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity -data class FavouriteManga( +class FavouriteManga( @Embedded val favourite: FavouriteEntity, @Relation( parentColumn = "manga_id", diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 7928ba470..cf7d0f8f9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -1,8 +1,11 @@ package org.koitharu.kotatsu.favourites.data import androidx.room.* +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.model.SortOrder @Dao abstract class FavouritesDao { @@ -11,9 +14,13 @@ abstract class FavouritesDao { @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC") abstract suspend fun findAll(): List - @Transaction - @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC") - abstract fun observeAll(): Flow> + fun observeAll(order: SortOrder): Flow> { + val orderBy = getOrderBy(order) + val query = SimpleSQLiteQuery( + "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id GROUP BY favourites.manga_id ORDER BY $orderBy", + ) + return observeAllRaw(query) + } @Transaction @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @@ -23,9 +30,14 @@ abstract class FavouritesDao { @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC") abstract suspend fun findAll(categoryId: Long): List - @Transaction - @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC") - abstract fun observeAll(categoryId: Long): Flow> + fun observeAll(categoryId: Long, order: SortOrder): Flow> { + val orderBy = getOrderBy(order) + val query = SimpleSQLiteQuery( + "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id WHERE category_id = ? GROUP BY favourites.manga_id ORDER BY $orderBy", + arrayOf(categoryId), + ) + return observeAllRaw(query) + } @Transaction @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @@ -63,4 +75,16 @@ abstract class FavouritesDao { insert(entity) } } + + @Transaction + @RawQuery(observedEntities = [FavouriteEntity::class]) + protected abstract fun observeAllRaw(query: SupportSQLiteQuery): Flow> + + private fun getOrderBy(sortOrder: SortOrder) = when(sortOrder) { + SortOrder.RATING -> "rating DESC" + SortOrder.NEWEST, + SortOrder.UPDATED -> "created_at DESC" + SortOrder.ALPHABETICAL -> "title ASC" + else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 15cefac92..48d6a34aa 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -3,12 +3,14 @@ package org.koitharu.kotatsu.favourites.domain import androidx.room.withTransaction import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.utils.ext.mapItems @@ -21,26 +23,26 @@ class FavouritesRepository(private val db: MangaDatabase) { return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } } - fun observeAll(): Flow> { - return db.favouritesDao.observeAll() + fun observeAll(order: SortOrder): Flow> { + return db.favouritesDao.observeAll(order) .mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } } - suspend fun getAllManga(offset: Int): List { - val entities = db.favouritesDao.findAll(offset, 20) - return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } - } - suspend fun getManga(categoryId: Long): List { val entities = db.favouritesDao.findAll(categoryId) return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } } - fun observeAll(categoryId: Long): Flow> { - return db.favouritesDao.observeAll(categoryId) + fun observeAll(categoryId: Long, order: SortOrder): Flow> { + return db.favouritesDao.observeAll(categoryId, order) .mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } } + fun observeAll(categoryId: Long): Flow> { + return observeOrder(categoryId) + .flatMapLatest { order -> observeAll(categoryId, order) } + } + suspend fun getManga(categoryId: Long, offset: Int): List { val entities = db.favouritesDao.findAll(categoryId, offset, 20) return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } @@ -77,25 +79,30 @@ class FavouritesRepository(private val db: MangaDatabase) { title = title, createdAt = System.currentTimeMillis(), sortKey = db.favouriteCategoriesDao.getNextSortKey(), - categoryId = 0 + categoryId = 0, + order = SortOrder.UPDATED.name, ) val id = db.favouriteCategoriesDao.insert(entity) return entity.toFavouriteCategory(id) } suspend fun renameCategory(id: Long, title: String) { - db.favouriteCategoriesDao.update(id, title) + db.favouriteCategoriesDao.updateTitle(id, title) } suspend fun removeCategory(id: Long) { db.favouriteCategoriesDao.delete(id) } + suspend fun setCategoryOrder(id: Long, order: SortOrder) { + db.favouriteCategoriesDao.updateOrder(id, order.name) + } + suspend fun reorderCategories(orderedIds: List) { val dao = db.favouriteCategoriesDao db.withTransaction { for ((i, id) in orderedIds.withIndex()) { - dao.update(id, i) + dao.updateSortKey(id, i) } } } @@ -117,4 +124,10 @@ class FavouritesRepository(private val db: MangaDatabase) { suspend fun removeFromFavourites(manga: Manga) { db.favouritesDao.delete(manga.id) } + + private fun observeOrder(categoryId: Long): Flow { + return db.favouriteCategoriesDao.observe(categoryId) + .map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST } + .distinctUntilChanged() + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt index c20c2c6f3..096d26cfd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.favourites.ui import android.os.Bundle import android.view.* import androidx.core.graphics.Insets +import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar @@ -11,15 +12,17 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel +import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.utils.RecycledViewPoolHolder import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.showPopupMenu import java.util.* -import kotlin.collections.ArrayList class FavouritesContainerFragment : BaseFragment(), FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback, @@ -27,9 +30,7 @@ class FavouritesContainerFragment : BaseFragment(), override val recycledViewPool = RecyclerView.RecycledViewPool() - private val viewModel by viewModel( - mode = LazyThreadSafetyMode.NONE - ) + private val viewModel by viewModel() private val editDelegate by lazy(LazyThreadSafetyMode.NONE) { CategoriesEditDelegate(requireContext(), this) } @@ -65,10 +66,22 @@ class FavouritesContainerFragment : BaseFragment(), } override fun onWindowInsetsChanged(insets: Insets) { - binding.tabs.updatePadding( - left = insets.left, - right = insets.right + val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top + binding.root.updatePadding( + top = headerHeight - insets.top ) + binding.pager.updatePadding( + top = -headerHeight + ) + binding.tabs.apply { + updatePadding( + left = insets.left, + right = insets.right + ) + updateLayoutParams { + topMargin = insets.top + } + } } private fun onCategoriesChanged(categories: List) { @@ -100,11 +113,19 @@ class FavouritesContainerFragment : BaseFragment(), override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean { val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category - tabView.showPopupMenu(menuRes) { + tabView.showPopupMenu(menuRes, { menu -> + createOrderSubmenu(menu, category) + }) { when (it.itemId) { R.id.action_remove -> editDelegate.deleteCategory(category) R.id.action_rename -> editDelegate.renameCategory(category) R.id.action_create -> editDelegate.createCategory() + R.id.action_order -> return@showPopupMenu false + else -> { + val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order) + ?: return@showPopupMenu false + viewModel.setCategoryOrder(category.id, order) + } } true } @@ -125,11 +146,26 @@ class FavouritesContainerFragment : BaseFragment(), private fun wrapCategories(categories: List): List { val data = ArrayList(categories.size + 1) - data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, Date()) + data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date()) data += categories return data } + private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) { + val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return + for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) { + val menuItem = submenu.add( + R.id.group_order, + Menu.NONE, + i, + item.titleRes + ) + menuItem.isCheckable = true + menuItem.isChecked = item == category.order + } + submenu.setGroupCheckable(R.id.group_order, true, true) + } + companion object { fun newInstance() = FavouritesContainerFragment() diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt index 83368d802..29de80809 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt @@ -36,7 +36,7 @@ class FavouritesPagerAdapter( override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { val item = differ.currentList[position] tab.text = item.title - tab.view.tag = item + tab.view.tag = item.id tab.view.setOnLongClickListener(this) } @@ -45,7 +45,8 @@ class FavouritesPagerAdapter( } override fun onLongClick(v: View): Boolean { - val item = v.tag as? FavouriteCategory ?: return false + val itemId = v.tag as? Long ?: return false + val item = differ.currentList.find { x -> x.id == itemId } ?: return false return longClickListener.onTabLongClick(v, item) } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt index e2ad1153e..5461152a6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color import android.os.Bundle +import android.view.Menu import android.view.View import android.view.ViewGroup import androidx.core.graphics.Insets @@ -20,6 +21,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.showPopupMenu @@ -28,9 +30,7 @@ class CategoriesActivity : BaseActivity(), OnListItemClickListener, View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback { - private val viewModel by viewModel( - mode = LazyThreadSafetyMode.NONE - ) + private val viewModel by viewModel() private lateinit var adapter: CategoriesAdapter private lateinit var reorderHelper: ItemTouchHelper @@ -44,6 +44,7 @@ class CategoriesActivity : BaseActivity(), adapter = CategoriesAdapter(this) editDelegate = CategoriesEditDelegate(this, this) binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) + binding.recyclerView.setHasFixedSize(true) binding.recyclerView.adapter = adapter binding.fabAdd.setOnClickListener(this) reorderHelper = ItemTouchHelper(ReorderHelperCallback()) @@ -60,10 +61,17 @@ class CategoriesActivity : BaseActivity(), } override fun onItemClick(item: FavouriteCategory, view: View) { - view.showPopupMenu(R.menu.popup_category) { + view.showPopupMenu(R.menu.popup_category, { menu -> + createOrderSubmenu(menu, item) + }) { when (it.itemId) { R.id.action_remove -> editDelegate.deleteCategory(item) R.id.action_rename -> editDelegate.renameCategory(item) + R.id.action_order -> return@showPopupMenu false + else -> { + val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false + viewModel.setCategoryOrder(item.id, order) + } } true } @@ -116,6 +124,21 @@ class CategoriesActivity : BaseActivity(), viewModel.createCategory(name) } + private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) { + val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return + for ((i, item) in SORT_ORDERS.withIndex()) { + val menuItem = submenu.add( + R.id.group_order, + Menu.NONE, + i, + item.titleRes + ) + menuItem.isCheckable = true + menuItem.isChecked = item == category.order + } + submenu.setGroupCheckable(R.id.group_order, true, true) + } + private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback( ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0 ) { @@ -144,6 +167,12 @@ class CategoriesActivity : BaseActivity(), companion object { + val SORT_ORDERS = arrayOf( + SortOrder.ALPHABETICAL, + SortOrder.NEWEST, + SortOrder.RATING, + ) + fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt index 5bc88aa05..adf19ca9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt @@ -6,7 +6,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.FavouriteCategory class CategoriesAdapter( - onItemClickListener: OnListItemClickListener + onItemClickListener: OnListItemClickListener, ) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { @@ -20,12 +20,27 @@ class CategoriesAdapter( private class DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean { + override fun areItemsTheSame( + oldItem: FavouriteCategory, + newItem: FavouriteCategory, + ): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean { + override fun areContentsTheSame( + oldItem: FavouriteCategory, + newItem: FavouriteCategory, + ): Boolean { return oldItem.id == newItem.id && oldItem.title == newItem.title + && oldItem.order == newItem.order + } + + override fun getChangePayload( + oldItem: FavouriteCategory, + newItem: FavouriteCategory, + ): Any? = when { + oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order + else -> super.getChangePayload(oldItem, newItem) } } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt index a9a80ce59..7c93f3d25 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories import android.content.Context import android.text.InputType +import android.widget.Toast import androidx.appcompat.app.AlertDialog import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog @@ -32,7 +33,12 @@ class CategoriesEditDelegate( .setNegativeButton(android.R.string.cancel) .setMaxLength(MAX_TITLE_LENGTH, false) .setPositiveButton(R.string.rename) { _, name -> - callback.onRenameCategory(category, name) + val trimmed = name.trim() + if (trimmed.isEmpty()) { + Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show() + } else { + callback.onRenameCategory(category, name) + } }.create() .show() } @@ -45,7 +51,12 @@ class CategoriesEditDelegate( .setNegativeButton(android.R.string.cancel) .setMaxLength(MAX_TITLE_LENGTH, false) .setPositiveButton(R.string.add) { _, name -> - callback.onCreateCategory(name) + val trimmed = name.trim() + if (trimmed.isEmpty()) { + Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show() + } else { + callback.onCreateCategory(trimmed) + } }.create() .show() } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt index 8c648a47b..a9202d749 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt @@ -4,10 +4,10 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import java.util.* -import kotlin.collections.ArrayList class FavouritesCategoriesViewModel( private val repository: FavouritesRepository @@ -19,23 +19,29 @@ class FavouritesCategoriesViewModel( .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) fun createCategory(name: String) { - launchJob(Dispatchers.Default) { + launchJob { repository.addCategory(name) } } fun renameCategory(id: Long, name: String) { - launchJob(Dispatchers.Default) { + launchJob { repository.renameCategory(id, name) } } fun deleteCategory(id: Long) { - launchJob(Dispatchers.Default) { + launchJob { repository.removeCategory(id) } } + fun setCategoryOrder(id: Long, order: SortOrder) { + launchJob { + repository.setCategoryOrder(id, order) + } + } + fun reorderCategories(oldPos: Int, newPos: Int) { val prevJob = reorderJob reorderJob = launchJob(Dispatchers.Default) { diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt index 3736b959a..c1496aa4c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt @@ -25,7 +25,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet, CategoriesEditDelegate.CategoriesEditCallback, View.OnClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + private val viewModel by viewModel { parametersOf(requireNotNull(arguments?.getParcelable(MangaIntent.KEY_MANGA))) } @@ -36,7 +36,7 @@ class FavouriteCategoriesDialog : BaseBottomSheet(mode = LazyThreadSafetyMode.NONE) { + override val viewModel by viewModel { parametersOf(categoryId) } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index ec13864b9..344c3d5a1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.list.ui.MangaListViewModel @@ -22,7 +23,11 @@ class FavouritesListViewModel( ) : MangaListViewModel(settings) { override val content = combine( - if (categoryId == 0L) repository.observeAll() else repository.observeAll(categoryId), + if (categoryId == 0L) { + repository.observeAll(SortOrder.NEWEST) + } else { + repository.observeAll(categoryId) + }, createListModeFlow() ) { list, mode -> when { diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt index 6ca026947..bf6ea6304 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt @@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel val historyModule get() = module { - single { HistoryRepository(get(), get()) } + single { HistoryRepository(get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt index 34a75b6a5..770181595 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt @@ -18,14 +18,14 @@ import java.util.* ) ] ) -data class HistoryEntity( +class HistoryEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val mangaId: Long, @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), @ColumnInfo(name = "updated_at") val updatedAt: Long, @ColumnInfo(name = "chapter_id") val chapterId: Long, @ColumnInfo(name = "page") val page: Int, - @ColumnInfo(name = "scroll") val scroll: Float + @ColumnInfo(name = "scroll") val scroll: Float, ) { fun toMangaHistory() = MangaHistory( diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt index d1c8ee2b0..55f41adc6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryWithManga.kt @@ -7,7 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.TagEntity -data class HistoryWithManga( +class HistoryWithManga( @Embedded val history: HistoryEntity, @Relation( parentColumn = "manga_id", diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index cece71003..220f06dca 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaHistory +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.utils.ext.mapItems @@ -16,6 +17,7 @@ import org.koitharu.kotatsu.utils.ext.mapToSet class HistoryRepository( private val db: MangaDatabase, private val trackingRepository: TrackingRepository, + private val settings: AppSettings, ) { suspend fun getList(offset: Int, limit: Int = 20): List { @@ -45,6 +47,9 @@ class HistoryRepository( } suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int) { + if (manga.isNsfw && settings.isHistoryExcludeNsfw) { + return + } val tags = manga.tags.map(TagEntity.Companion::fromMangaTag) db.withTransaction { db.tagsDao.upsert(tags) diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index 311adb720..76dc36ee8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -15,7 +15,7 @@ import org.koitharu.kotatsu.utils.ext.ellipsize class HistoryListFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + override val viewModel by viewModel() override val isSwipeRefreshEnabled = false override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 539d68d77..8422232bd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -20,7 +20,6 @@ import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.onFirst import java.util.* import java.util.concurrent.TimeUnit -import kotlin.collections.ArrayList class HistoryListViewModel( private val repository: HistoryRepository, @@ -81,8 +80,11 @@ class HistoryListViewModel( grouped: Boolean, mode: ListMode ): List { - val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size) + val result = ArrayList(if (grouped) (list.size * 1.4).toInt() else list.size + 1) var prevDate: DateTimeAgo? = null + if (!grouped) { + result += ListHeader(null, R.string.history) + } for ((manga, history) in list) { if (grouped) { val date = timeAgo(history.updatedAt) diff --git a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt new file mode 100644 index 000000000..ca14d2f4d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt @@ -0,0 +1,92 @@ +package org.koitharu.kotatsu.image.ui + +import android.content.Context +import android.content.Intent +import android.graphics.drawable.Drawable +import android.net.Uri +import android.os.Bundle +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.graphics.Insets +import androidx.core.graphics.drawable.toBitmap +import androidx.core.view.updatePadding +import coil.ImageLoader +import coil.request.CachePolicy +import coil.request.ImageRequest +import coil.target.PoolableViewTarget +import com.davemorrissey.labs.subscaleview.ImageSource +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView +import org.koin.android.ext.android.inject +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.databinding.ActivityImageBinding +import org.koitharu.kotatsu.utils.ext.enqueueWith +import org.koitharu.kotatsu.utils.ext.indicator + +class ImageActivity : BaseActivity() { + + private val coil: ImageLoader by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityImageBinding.inflate(layoutInflater)) + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setDisplayShowTitleEnabled(false) + } + loadImage(intent.data) + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.toolbar.updatePadding( + left = insets.left, + right = insets.right, + top = insets.top, + ) + } + + private fun loadImage(url: Uri?) { + ImageRequest.Builder(this) + .data(url) + .memoryCachePolicy(CachePolicy.DISABLED) + .lifecycle(this) + .target(SsivTarget(binding.ssiv)) + .indicator(binding.progressBar) + .enqueueWith(coil) + } + + private class SsivTarget( + override val view: SubsamplingScaleImageView, + ) : PoolableViewTarget { + + 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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt b/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt new file mode 100644 index 000000000..414a219db --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/domain/AvailableFilters.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.list.domain + +import org.koitharu.kotatsu.core.model.MangaTag +import org.koitharu.kotatsu.core.model.SortOrder + +class AvailableFilters( + val sortOrders: Set, + val tags: Set, +) { + + val size: Int + get() = sortOrders.size + tags.size + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as AvailableFilters + if (sortOrders != other.sortOrders) return false + if (tags != other.tags) return false + return true + } + + override fun hashCode(): Int { + var result = sortOrders.hashCode() + result = 31 * result + tags.hashCode() + return result + } + + fun isEmpty(): Boolean = sortOrders.isEmpty() && tags.isEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt deleted file mode 100644 index e2147aa97..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaFilterConfig.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.koitharu.kotatsu.list.ui - -import org.koitharu.kotatsu.core.model.MangaFilter -import org.koitharu.kotatsu.core.model.MangaTag -import org.koitharu.kotatsu.core.model.SortOrder - -data class MangaFilterConfig( - val sortOrders: List, - val tags: List, - val currentFilter: MangaFilter? -) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index d75a627df..11b29eaa4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -22,31 +22,29 @@ import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener import org.koitharu.kotatsu.base.ui.list.decor.ItemTypeDividerDecoration -import org.koitharu.kotatsu.base.ui.list.decor.SectionItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter -import org.koitharu.kotatsu.list.ui.filter.FilterAdapter -import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener +import org.koitharu.kotatsu.list.ui.filter.FilterAdapter2 +import org.koitharu.kotatsu.list.ui.filter.FilterItem import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.main.ui.AppBarOwner +import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.utils.RecycledViewPoolHolder -import org.koitharu.kotatsu.utils.ext.clearItemDecorations -import org.koitharu.kotatsu.utils.ext.getDisplayMessage -import org.koitharu.kotatsu.utils.ext.toggleDrawer -import org.koitharu.kotatsu.utils.ext.viewLifecycleScope +import org.koitharu.kotatsu.utils.ext.* abstract class MangaListFragment : BaseFragment(), - PaginationScrollListener.Callback, OnListItemClickListener, OnFilterChangedListener, - SectionItemDecoration.Callback, SwipeRefreshLayout.OnRefreshListener { + PaginationScrollListener.Callback, OnListItemClickListener, + SwipeRefreshLayout.OnRefreshListener { private var listAdapter: MangaListAdapter? = null + private var filterAdapter: FilterAdapter2? = null private var paginationListener: PaginationScrollListener? = null private val spanResolver = MangaListSpanResolver() private val spanSizeLookup = SpanSizeLookup() @@ -72,7 +70,14 @@ abstract class MangaListFragment : BaseFragment(), super.onViewCreated(view, savedInstanceState) drawer = binding.root as? DrawerLayout drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - listAdapter = MangaListAdapter(get(), viewLifecycleOwner, this, ::resolveException) + listAdapter = MangaListAdapter( + coil = get(), + lifecycleOwner = viewLifecycleOwner, + clickListener = this, + onRetryClick = ::resolveException, + onTagRemoveClick = viewModel::onRemoveFilterTag + ) + filterAdapter = FilterAdapter2(viewModel) paginationListener = PaginationScrollListener(4, this) with(binding.recyclerView) { setHasFixedSize(true) @@ -89,8 +94,7 @@ abstract class MangaListFragment : BaseFragment(), } with(binding.recyclerViewFilter) { setHasFixedSize(true) - addItemDecoration(ItemTypeDividerDecoration(view.context)) - addItemDecoration(SectionItemDecoration(false, this@MangaListFragment)) + adapter = filterAdapter } (parentFragment as? RecycledViewPoolHolder)?.let { @@ -108,6 +112,7 @@ abstract class MangaListFragment : BaseFragment(), override fun onDestroyView() { drawer = null listAdapter = null + filterAdapter = null paginationListener = null spanSizeLookup.invalidateCache() super.onDestroyView() @@ -198,42 +203,42 @@ abstract class MangaListFragment : BaseFragment(), } } - protected fun onInitFilter(config: MangaFilterConfig) { - binding.recyclerViewFilter.adapter = FilterAdapter( - sortOrders = config.sortOrders, - tags = config.tags, - state = config.currentFilter, - listener = this - ) + protected fun onInitFilter(filter: List) { + filterAdapter?.items = filter drawer?.setDrawerLockMode( - if (config.sortOrders.isEmpty() && config.tags.isEmpty()) { + if (filter.isEmpty()) { DrawerLayout.LOCK_MODE_LOCKED_CLOSED } else { DrawerLayout.LOCK_MODE_UNLOCKED } ) ?: binding.dividerFilter?.let { - it.isGone = config.sortOrders.isEmpty() && config.tags.isEmpty() + it.isGone = filter.isEmpty() binding.recyclerViewFilter.isVisible = it.isVisible } activity?.invalidateOptionsMenu() } - @CallSuper - override fun onFilterChanged(filter: MangaFilter) { - drawer?.closeDrawers() - } - override fun onWindowInsetsChanged(insets: Insets) { - binding.recyclerView.updatePadding( - bottom = insets.bottom - ) + val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top binding.recyclerViewFilter.updatePadding( + top = headerHeight, bottom = insets.bottom ) binding.root.updatePadding( left = insets.left, right = insets.right ) + if (activity is MainActivity) { + binding.recyclerView.updatePadding( + top = headerHeight, + bottom = insets.bottom + ) + binding.swipeRefreshLayout.setProgressViewOffset( + true, + headerHeight + resources.resolveDp(-72), + headerHeight + resources.resolveDp(10) + ) + } } private fun onGridScaleChanged(scale: Float) { @@ -272,20 +277,6 @@ abstract class MangaListFragment : BaseFragment(), } } - final override fun isSection(position: Int): Boolean { - return position == 0 || binding.recyclerViewFilter.adapter?.run { - getItemViewType(position) != getItemViewType(position - 1) - } ?: false - } - - final override fun getSectionTitle(position: Int): CharSequence? { - return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) { - FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order) - FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genre) - else -> null - } - } - protected open fun onCreatePopupMenu(inflater: MenuInflater, menu: Menu, data: Manga) = Unit protected open fun onPopupMenuItemSelected(item: MenuItem, data: Manga) = false diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index 0fa365629..e2a463f4e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -1,22 +1,32 @@ package org.koitharu.kotatsu.list.ui +import androidx.annotation.CallSuper import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.* +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.MangaFilter +import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode +import org.koitharu.kotatsu.list.domain.AvailableFilters +import org.koitharu.kotatsu.list.ui.filter.FilterItem +import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct abstract class MangaListViewModel( - private val settings: AppSettings -) : BaseViewModel() { + private val settings: AppSettings, +) : BaseViewModel(), OnFilterChangedListener { abstract val content: LiveData> - val filter = MutableLiveData() + val filter = MutableLiveData>() val listMode = MutableLiveData() val gridScale = settings.observe() .filter { it == AppSettings.KEY_GRID_SIZE } @@ -36,6 +46,63 @@ abstract class MangaListViewModel( } } + protected var currentFilter: MangaFilter = MangaFilter(null, emptySet()) + private set(value) { + field = value + onFilterChanged() + } + protected var availableFilters: AvailableFilters? = null + private var filterJob: Job? = null + + final override fun onSortItemClick(item: FilterItem.Sort) { + currentFilter = currentFilter.copy(sortOrder = item.order) + } + + final override fun onTagItemClick(item: FilterItem.Tag) { + val tags = if (item.isChecked) { + currentFilter.tags - item.tag + } else { + currentFilter.tags + item.tag + } + currentFilter = currentFilter.copy(tags = tags) + } + + fun onRemoveFilterTag(tag: MangaTag) { + val tags = currentFilter.tags + if (tag !in tags) { + return + } + currentFilter = currentFilter.copy(tags = tags - tag) + } + + @CallSuper + open fun onFilterChanged() { + val previousJob = filterJob + filterJob = launchJob(Dispatchers.Default) { + previousJob?.cancelAndJoin() + filter.postValue( + availableFilters?.run { + val list = ArrayList(size + 2) + if (sortOrders.isNotEmpty()) { + val selectedSort = currentFilter.sortOrder ?: sortOrders.first() + list += FilterItem.Header(R.string.sort_order) + sortOrders.sortedBy { it.ordinal }.mapTo(list) { + FilterItem.Sort(it, isSelected = it == selectedSort) + } + } + if (tags.isNotEmpty()) { + list += FilterItem.Header(R.string.genres) + tags.sortedBy { it.title }.mapTo(list) { + FilterItem.Tag(it, isChecked = it in currentFilter.tags) + } + } + ensureActive() + list + }.orEmpty() + ) + } + } + abstract fun onRefresh() abstract fun onRetry() diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt new file mode 100644 index 000000000..4848a27b8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/CurrentFilterAD.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.base.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.model.MangaTag +import org.koitharu.kotatsu.databinding.ItemCurrentFilterBinding +import org.koitharu.kotatsu.list.ui.model.CurrentFilterModel +import org.koitharu.kotatsu.list.ui.model.ListModel + +fun currentFilterAD( + onTagRemoveClick: (MangaTag) -> Unit, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemCurrentFilterBinding.inflate(inflater, parent, false) } +) { + + binding.chipsTags.onChipCloseClickListener = ChipsView.OnChipCloseClickListener { chip, data -> + onTagRemoveClick(data as? MangaTag ?: return@OnChipCloseClickListener) + } + + bind { + binding.chipsTags.setChips(item.chips) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt new file mode 100644 index 000000000..4d25060ac --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/ListHeaderAD.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import android.widget.TextView +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.list.ui.model.ListHeader +import org.koitharu.kotatsu.list.ui.model.ListModel + +fun listHeaderAD() = adapterDelegate(R.layout.item_header) { + + bind { + val textView = (itemView as TextView) + if (item.text != null) { + textView.text = item.text + } else { + textView.setText(item.textRes) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index 0ca8ecabc..61cd60c03 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -6,6 +6,7 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.ui.DateTimeAgo import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel @@ -17,7 +18,8 @@ class MangaListAdapter( coil: ImageLoader, lifecycleOwner: LifecycleOwner, clickListener: OnListItemClickListener, - onRetryClick: (Throwable) -> Unit + onRetryClick: (Throwable) -> Unit, + onTagRemoveClick: (MangaTag) -> Unit, ) : AsyncListDifferDelegationAdapter(DiffCallback()) { init { @@ -37,10 +39,8 @@ class MangaListAdapter( .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(onRetryClick)) .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(onRetryClick)) .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD()) - } - - fun setItems(list: List, commitCallback: Runnable) { - differ.submitList(list, commitCallback) + .addDelegate(ITEM_TYPE_HEADER, listHeaderAD()) + .addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick)) } private class DiffCallback : DiffUtil.ItemCallback() { @@ -77,5 +77,7 @@ class MangaListAdapter( const val ITEM_TYPE_ERROR_STATE = 6 const val ITEM_TYPE_ERROR_FOOTER = 7 const val ITEM_TYPE_EMPTY = 8 + const val ITEM_TYPE_HEADER = 9 + const val ITEM_TYPE_FILTER = 10 } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt deleted file mode 100644 index b0b32096a..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.koitharu.kotatsu.list.ui.filter - -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.base.ui.list.BaseViewHolder -import org.koitharu.kotatsu.core.model.MangaFilter -import org.koitharu.kotatsu.core.model.MangaTag -import org.koitharu.kotatsu.core.model.SortOrder -import java.util.* -import kotlin.collections.ArrayList - -class FilterAdapter( - sortOrders: List = emptyList(), - tags: List = emptyList(), - state: MangaFilter?, - private val listener: OnFilterChangedListener -) : RecyclerView.Adapter>() { - - private val sortOrders = ArrayList(sortOrders) - private val tags = ArrayList(Collections.singletonList(null) + tags) - - private var currentState = state ?: MangaFilter(sortOrders.first(), null) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { - VIEW_TYPE_SORT -> FilterSortHolder(parent).apply { - itemView.setOnClickListener { - setCheckedSort(requireData()) - } - } - VIEW_TYPE_TAG -> FilterTagHolder(parent).apply { - itemView.setOnClickListener { - setCheckedTag(boundData) - } - } - else -> throw IllegalArgumentException("Unknown viewType $viewType") - } - - override fun getItemCount() = sortOrders.size + tags.size - - override fun onBindViewHolder(holder: BaseViewHolder<*, Boolean, *>, position: Int) { - when (holder) { - is FilterSortHolder -> { - val item = sortOrders[position] - holder.bind(item, item == currentState.sortOrder) - } - is FilterTagHolder -> { - val item = tags[position - sortOrders.size] - holder.bind(item, item == currentState.tag) - } - } - } - - override fun getItemViewType(position: Int) = when (position) { - in sortOrders.indices -> VIEW_TYPE_SORT - else -> VIEW_TYPE_TAG - } - - fun setCheckedTag(tag: MangaTag?) { - if (tag != currentState.tag) { - val oldItemPos = tags.indexOf(currentState.tag) - val newItemPos = tags.indexOf(tag) - currentState = currentState.copy(tag = tag) - if (oldItemPos in tags.indices) { - notifyItemChanged(sortOrders.size + oldItemPos) - } - if (newItemPos in tags.indices) { - notifyItemChanged(sortOrders.size + newItemPos) - } - listener.onFilterChanged(currentState) - } - } - - fun setCheckedSort(sort: SortOrder) { - if (sort != currentState.sortOrder) { - val oldItemPos = sortOrders.indexOf(currentState.sortOrder) - val newItemPos = sortOrders.indexOf(sort) - currentState = currentState.copy(sortOrder = sort) - if (oldItemPos in sortOrders.indices) { - notifyItemChanged(oldItemPos) - } - if (newItemPos in sortOrders.indices) { - notifyItemChanged(newItemPos) - } - listener.onFilterChanged(currentState) - } - } - - companion object { - - const val VIEW_TYPE_SORT = 0 - const val VIEW_TYPE_TAG = 1 - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt new file mode 100644 index 000000000..67b4d3585 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapter2.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.list.ui.filter + +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter + +class FilterAdapter2( + listener: OnFilterChangedListener, +) : AsyncListDifferDelegationAdapter( + FilterDiffCallback(), + filterSortDelegate(listener), + filterTagDelegate(listener), + filterHeaderDelegate(), +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt new file mode 100644 index 000000000..8b926d768 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterAdapterDelegates.kt @@ -0,0 +1,47 @@ +package org.koitharu.kotatsu.list.ui.filter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding +import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding +import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding + +fun filterSortDelegate( + listener: OnFilterChangedListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) } +) { + + itemView.setOnClickListener { + listener.onSortItemClick(item) + } + + bind { + binding.root.setText(item.order.titleRes) + binding.root.isChecked = item.isSelected + } +} + +fun filterTagDelegate( + listener: OnFilterChangedListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) } +) { + + itemView.setOnClickListener { + listener.onTagItemClick(item) + } + + bind { + binding.root.text = item.tag.title + binding.root.isChecked = item.isChecked + } +} + +fun filterHeaderDelegate() = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) } +) { + + bind { + binding.root.setText(item.titleResId) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt new file mode 100644 index 000000000..1ccd4e813 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterDiffCallback.kt @@ -0,0 +1,48 @@ +package org.koitharu.kotatsu.list.ui.filter + +import androidx.recyclerview.widget.DiffUtil + +class FilterDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { + return when { + oldItem.javaClass != newItem.javaClass -> false + oldItem is FilterItem.Header && newItem is FilterItem.Header -> { + oldItem.titleResId == newItem.titleResId + } + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.tag == newItem.tag + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.order == newItem.order + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: FilterItem, newItem: FilterItem): Boolean { + return when { + oldItem is FilterItem.Header && newItem is FilterItem.Header -> true + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.isChecked == newItem.isChecked + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.isSelected == newItem.isSelected + } + else -> false + } + } + + override fun getChangePayload(oldItem: FilterItem, newItem: FilterItem): Any? { + val isCheckedChanged = when { + oldItem is FilterItem.Tag && newItem is FilterItem.Tag -> { + oldItem.isChecked != newItem.isChecked + } + oldItem is FilterItem.Sort && newItem is FilterItem.Sort -> { + oldItem.isSelected != newItem.isSelected + } + else -> false + } + return if (isCheckedChanged) Unit else super.getChangePayload(oldItem, newItem) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt new file mode 100644 index 000000000..a74d93b1d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterItem.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.list.ui.filter + +import androidx.annotation.StringRes +import org.koitharu.kotatsu.core.model.MangaTag +import org.koitharu.kotatsu.core.model.SortOrder + +sealed interface FilterItem { + + class Header( + @StringRes val titleResId: Int, + ) : FilterItem + + class Sort( + val order: SortOrder, + val isSelected: Boolean, + ) : FilterItem + + class Tag( + val tag: MangaTag, + val isChecked: Boolean, + ) : FilterItem +} diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt deleted file mode 100644 index a182131b6..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterSortHolder.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.list.ui.filter - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.koitharu.kotatsu.base.ui.list.BaseViewHolder -import org.koitharu.kotatsu.core.model.SortOrder -import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding - -class FilterSortHolder(parent: ViewGroup) : - BaseViewHolder( - ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) { - - override fun onBind(data: SortOrder, extra: Boolean) { - binding.radio.setText(data.titleRes) - binding.radio.isChecked = extra - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt deleted file mode 100644 index f3bf89635..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/FilterTagHolder.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.koitharu.kotatsu.list.ui.filter - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.base.ui.list.BaseViewHolder -import org.koitharu.kotatsu.core.model.MangaTag -import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding - -class FilterTagHolder(parent: ViewGroup) : - BaseViewHolder( - ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) { - - override fun onBind(data: MangaTag?, extra: Boolean) { - binding.radio.text = data?.title ?: context.getString(R.string.all) - binding.radio.isChecked = extra - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt index 93a1b7db5..a28596c9f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/filter/OnFilterChangedListener.kt @@ -1,8 +1,8 @@ package org.koitharu.kotatsu.list.ui.filter -import org.koitharu.kotatsu.core.model.MangaFilter +interface OnFilterChangedListener { -fun interface OnFilterChangedListener { + fun onSortItemClick(item: FilterItem.Sort) - fun onFilterChanged(filter: MangaFilter) + fun onTagItemClick(item: FilterItem.Tag) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt new file mode 100644 index 000000000..32cebb25c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/CurrentFilterModel.kt @@ -0,0 +1,7 @@ +package org.koitharu.kotatsu.list.ui.model + +import org.koitharu.kotatsu.base.ui.widgets.ChipsView + +data class CurrentFilterModel( + val chips: Collection, +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt new file mode 100644 index 000000000..209c7227f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/model/ListHeader.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.list.ui.model + +import androidx.annotation.StringRes + +data class ListHeader( + val text: CharSequence?, + @StringRes val textRes: Int, +) : ListModel \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt index b2a756cc1..dbe2d43e1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt @@ -15,5 +15,5 @@ val localModule single { LocalMangaRepository(androidContext()) } factory(named(MangaSource.LOCAL)) { get() } - viewModel { LocalListViewModel(get(), get(), get(), get(), androidContext()) } + viewModel { LocalListViewModel(get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt index f74c023e1..cd4f5ea83 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt @@ -20,19 +20,22 @@ class CbzFetcher : Fetcher { pool: BitmapPool, data: Uri, size: Size, - options: Options + options: Options, ): FetchResult { val zip = ZipFile(data.schemeSpecificPart) val entry = zip.getEntry(data.fragment) val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name) return SourceResult( - source = zip.getInputStream(entry).source().buffer(), + source = ExtraCloseableBufferedSource( + zip.getInputStream(entry).source().buffer(), + zip, + ), mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext), dataSource = DataSource.DISK ) } - override fun key(data: Uri): String? = data.toString() + override fun key(data: Uri) = data.toString() override fun handles(data: Uri) = data.scheme == "cbz" } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt index 98f4e73fc..106cbaacd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt @@ -7,7 +7,7 @@ import java.util.* class CbzFilter : FilenameFilter { override fun accept(dir: File, name: String): Boolean { - val ext = name.substringAfterLast('.', "").toLowerCase(Locale.ROOT) + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) return ext == "cbz" || ext == "zip" } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt new file mode 100644 index 000000000..69ba2565a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/ExtraCloseableBufferedSource.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.local.data + +import okhttp3.internal.closeQuietly +import okio.BufferedSource +import okio.Closeable + +class ExtraCloseableBufferedSource( + private val delegate: BufferedSource, + vararg closeable: Closeable, +) : BufferedSource by delegate { + + private val extraCloseable = closeable + + override fun close() { + delegate.close() + extraCloseable.forEach { x -> x.closeQuietly() } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt index 45df69340..f678b83b7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -7,6 +7,8 @@ import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaTag +import org.koitharu.kotatsu.utils.ext.getBooleanOrDefault +import org.koitharu.kotatsu.utils.ext.getLongOrDefault import org.koitharu.kotatsu.utils.ext.getStringOrNull import org.koitharu.kotatsu.utils.ext.mapToSet @@ -20,9 +22,11 @@ class MangaIndex(source: String?) { json.put("title_alt", manga.altTitle) json.put("url", manga.url) json.put("public_url", manga.publicUrl) + json.put("author", manga.author) json.put("cover", manga.coverUrl) json.put("description", manga.description) json.put("rating", manga.rating) + json.put("nsfw", manga.isNsfw) json.put("source", manga.source.name) json.put("cover_large", manga.largeCoverUrl) json.put("tags", JSONArray().also { a -> @@ -48,8 +52,11 @@ class MangaIndex(source: String?) { altTitle = json.getStringOrNull("title_alt"), url = json.getString("url"), publicUrl = json.getStringOrNull("public_url").orEmpty(), + author = json.getStringOrNull("author"), + largeCoverUrl = json.getStringOrNull("cover_large"), source = source, rating = json.getDouble("rating").toFloat(), + isNsfw = json.getBooleanOrDefault("nsfw", false), coverUrl = json.getString("cover"), description = json.getStringOrNull("description"), tags = json.getJSONArray("tags").mapToSet { x -> @@ -59,7 +66,7 @@ class MangaIndex(source: String?) { source = source ) }, - chapters = getChapters(json.getJSONObject("chapters"), source) + chapters = getChapters(json.getJSONObject("chapters"), source), ) }.getOrNull() @@ -72,6 +79,8 @@ class MangaIndex(source: String?) { jo.put("number", chapter.number) jo.put("url", chapter.url) jo.put("name", chapter.name) + jo.put("uploadDate", chapter.uploadDate) + jo.put("scanlator", chapter.scanlator) jo.put("branch", chapter.branch) jo.put("entries", "%03d\\d{3}".format(chapter.number)) chapters.put(chapter.id.toString(), jo) @@ -98,8 +107,10 @@ class MangaIndex(source: String?) { name = v.getString("name"), url = v.getString("url"), number = v.getInt("number"), + uploadDate = v.getLongOrDefault("uploadDate", 0L), + scanlator = v.getStringOrNull("scanlator"), branch = v.getStringOrNull("branch"), - source = source + source = source, ) ) } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt index 2f82366af..3742f007e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt @@ -15,9 +15,7 @@ import org.koitharu.kotatsu.local.data.CbzFilter import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaZip import org.koitharu.kotatsu.utils.AlphanumComparator -import org.koitharu.kotatsu.utils.ext.longHashCode -import org.koitharu.kotatsu.utils.ext.readText -import org.koitharu.kotatsu.utils.ext.sub +import org.koitharu.kotatsu.utils.ext.* import java.io.File import java.util.* import java.util.zip.ZipEntry @@ -27,17 +25,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { private val filenameFilter = CbzFilter() - override suspend fun getList( + override suspend fun getList2( offset: Int, query: String?, - sortOrder: SortOrder?, - tag: MangaTag? + tags: Set?, + sortOrder: SortOrder? ): List { require(offset == 0) { "LocalMangaRepository does not support pagination" } - val files = getAvailableStorageDirs(context) - .flatMap { x -> x.listFiles(filenameFilter)?.toList().orEmpty() } + val files = getAllFiles() return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() } } @@ -72,15 +69,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { MangaPage( id = entryUri.longHashCode(), url = entryUri, + preview = null, referer = chapter.url, - source = MangaSource.LOCAL + source = MangaSource.LOCAL, ) } } - fun delete(manga: Manga): Boolean { + suspend fun delete(manga: Manga): Boolean { val file = Uri.parse(manga.url).toFile() - return file.delete() + return file.deleteAwait() } @SuppressLint("DefaultLocale") @@ -98,11 +96,14 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { entryName = index.getCoverEntry() ?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty() ), - chapters = info.chapters?.map { c -> c.copy(url = fileUri) } + chapters = info.chapters?.map { c -> + c.copy(url = fileUri, + source = MangaSource.LOCAL) + } ) } // fallback - val title = file.nameWithoutExtension.replace("_", " ").capitalize() + val title = file.nameWithoutExtension.replace("_", " ").toCamelCase() val chapters = ArraySet() for (x in zip.entries()) { if (!x.isDirectory) { @@ -120,10 +121,13 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s -> MangaChapter( id = "$i$s".longHashCode(), - name = if (s.isEmpty()) title else s, + name = s.ifEmpty { title }, number = i + 1, source = MangaSource.LOCAL, - url = uriBuilder.fragment(s).build().toString() + uploadDate = 0L, + url = uriBuilder.fragment(s).build().toString(), + scanlator = null, + branch = null, ) } ) @@ -134,11 +138,34 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { Uri.parse(localManga.url).toFile() }.getOrNull() ?: return null return withContext(Dispatchers.IO) { - val zip = ZipFile(file) - val entry = zip.getEntry(MangaZip.INDEX_ENTRY) - val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null - index.getMangaInfo() + @Suppress("BlockingMethodInNonBlockingContext") + ZipFile(file).use { zip -> + val entry = zip.getEntry(MangaZip.INDEX_ENTRY) + val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null + index.getMangaInfo() + } + } + } + + suspend fun findSavedManga(remoteManga: Manga): Manga? = withContext(Dispatchers.IO) { + val files = getAllFiles() + for (file in files) { + @Suppress("BlockingMethodInNonBlockingContext") + val index = ZipFile(file).use { zip -> + val entry = zip.getEntry(MangaZip.INDEX_ENTRY) + entry?.let(zip::readText)?.let(::MangaIndex) + } ?: continue + val info = index.getMangaInfo() ?: continue + if (info.id == remoteManga.id) { + val fileUri = file.toUri().toString() + return@withContext info.copy( + source = MangaSource.LOCAL, + url = fileUri, + chapters = info.chapters?.map { c -> c.copy(url = fileUri) } + ) + } } + null } private fun zipUri(file: File, entryName: String) = @@ -165,12 +192,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository { override suspend fun getTags() = emptySet() + private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir -> + dir.listFiles(filenameFilter)?.toList().orEmpty() + } + companion object { private const val DIR_NAME = "manga" fun isFileSupported(name: String): Boolean { - val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT) + val ext = name.substringAfterLast('.').lowercase(Locale.ROOT) return ext == "cbz" || ext == "zip" } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index ba700ecff..63a0e6c66 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -1,6 +1,6 @@ package org.koitharu.kotatsu.local.ui -import android.content.ActivityNotFoundException +import android.content.* import android.net.Uri import android.os.Bundle import android.view.Menu @@ -15,22 +15,43 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.Manga +import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.utils.ext.ellipsize -class LocalListFragment : MangaListFragment(), ActivityResultCallback { +class LocalListFragment : MangaListFragment(), ActivityResultCallback { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + override val viewModel by viewModel() private val importCall = registerForActivityResult( ActivityResultContracts.OpenDocument(), this ) + private val downloadReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == DownloadService.ACTION_DOWNLOAD_COMPLETE) { + viewModel.onRefresh() + } + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + context.registerReceiver( + downloadReceiver, + IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE) + ) + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved) } + override fun onDetach() { + requireContext().unregisterReceiver(downloadReceiver) + super.onDetach() + } + override fun onScrolledToEnd() = Unit override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { @@ -65,7 +86,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback { override fun onActivityResult(result: Uri?) { if (result != null) { - viewModel.importFile(result) + viewModel.importFile(context?.applicationContext ?: return, result) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 2ab348b6c..8e40277c9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -14,15 +14,12 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.list.ui.MangaListViewModel -import org.koitharu.kotatsu.list.ui.model.EmptyState -import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.list.ui.model.toErrorState -import org.koitharu.kotatsu.list.ui.model.toUi +import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.local.domain.LocalMangaRepository -import org.koitharu.kotatsu.utils.MediaStoreCompat import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -import org.koitharu.kotatsu.utils.ext.sub +import org.koitharu.kotatsu.utils.ext.resolveName +import java.io.File import java.io.IOException class LocalListViewModel( @@ -30,12 +27,12 @@ class LocalListViewModel( private val historyRepository: HistoryRepository, private val settings: AppSettings, private val shortcutsRepository: ShortcutsRepository, - private val context: Context ) : MangaListViewModel(settings) { val onMangaRemoved = SingleLiveEvent() private val listError = MutableStateFlow(null) private val mangaList = MutableStateFlow?>(null) + private val headerModel = ListHeader(null, R.string.local_storage) override val content = combine( mangaList, @@ -46,7 +43,10 @@ class LocalListViewModel( error != null -> listOf(error.toErrorState(canRetry = true)) list == null -> listOf(LoadingState) list.isEmpty() -> listOf(EmptyState(R.drawable.ic_storage, R.string.text_local_holder_primary, R.string.text_local_holder_secondary)) - else -> list.toUi(mode) + else -> ArrayList(list.size + 1).apply { + add(headerModel) + list.toUi(this, mode) + } } }.asLiveDataDistinct( viewModelScope.coroutineContext + Dispatchers.Default, @@ -61,7 +61,7 @@ class LocalListViewModel( launchLoadingJob(Dispatchers.Default) { try { listError.value = null - mangaList.value = repository.getList(0) + mangaList.value = repository.getList2(0) } catch (e: Throwable) { listError.value = e } @@ -70,17 +70,18 @@ class LocalListViewModel( override fun onRetry() = onRefresh() - fun importFile(uri: Uri) { + fun importFile(context: Context, uri: Uri) { launchLoadingJob { val contentResolver = context.contentResolver withContext(Dispatchers.IO) { - val name = MediaStoreCompat(contentResolver).getName(uri) + val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri") if (!LocalMangaRepository.isFileSupported(name)) { throw UnsupportedFileException("Unsupported file on $uri") } - val dest = settings.getStorageDir(context)?.sub(name) + val dest = settings.getStorageDir(context)?.let { File(it, name) } ?: throw IOException("External files dir unavailable") + @Suppress("BlockingMethodInNonBlockingContext") contentResolver.openInputStream(uri)?.use { source -> dest.outputStream().use { output -> source.copyTo(output) diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt new file mode 100644 index 000000000..d5a2de5bc --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/AppBarOwner.kt @@ -0,0 +1,8 @@ +package org.koitharu.kotatsu.main.ui + +import com.google.android.material.appbar.AppBarLayout + +interface AppBarOwner { + + val appBar: AppBarLayout +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 109009f3a..9dbbcba28 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -6,16 +6,23 @@ import android.content.res.Configuration import android.graphics.Color import android.os.Build import android.os.Bundle -import android.view.* +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup.MarginLayoutParams import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat import androidx.core.graphics.Insets import androidx.core.view.* +import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.CircularProgressDrawable +import androidx.transition.TransitionManager +import com.google.android.material.appbar.AppBarLayout import com.google.android.material.navigation.NavigationView import com.google.android.material.snackbar.Snackbar import org.koin.android.ext.android.get @@ -25,6 +32,7 @@ import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.prefs.AppSection +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.databinding.NavigationHeaderBinding import org.koitharu.kotatsu.details.ui.DetailsActivity @@ -38,7 +46,6 @@ import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel -import org.koitharu.kotatsu.search.ui.suggestion.SearchUI import org.koitharu.kotatsu.settings.AppUpdateChecker import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment @@ -47,22 +54,23 @@ import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.utils.ext.* class MainActivity : BaseActivity(), - NavigationView.OnNavigationItemSelectedListener, - View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener, - MenuItem.OnActionExpandListener { + NavigationView.OnNavigationItemSelectedListener, AppBarOwner, + View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) - private val searchSuggestionViewModel by viewModel( - mode = LazyThreadSafetyMode.NONE - ) + private val viewModel by viewModel() + private val searchSuggestionViewModel by viewModel() private lateinit var navHeaderBinding: NavigationHeaderBinding private lateinit var drawerToggle: ActionBarDrawerToggle - private var searchUi: SearchUI? = null + private var searchViewElevation = 0f + + override val appBar: AppBarLayout + get() = binding.appbar override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMainBinding.inflate(layoutInflater)) + searchViewElevation = binding.toolbarCard.cardElevation navHeaderBinding = NavigationHeaderBinding.inflate(layoutInflater) drawerToggle = ActionBarDrawerToggle( this, @@ -71,20 +79,34 @@ class MainActivity : BaseActivity(), R.string.open_menu, R.string.close_menu ) + drawerToggle.setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_arrow_back)) + drawerToggle.setToolbarNavigationClickListener { + binding.searchView.hideKeyboard() + onBackPressed() + } binding.drawer.addDrawerListener(drawerToggle) supportActionBar?.setDisplayHomeAsUpEnabled(true) - binding.searchView.apply { - setOnQueryTextFocusChangeListener(this@MainActivity) - searchUi = SearchUI.from(this, this@MainActivity) + if (get().isAmoledTheme && get().theme == AppCompatDelegate.MODE_NIGHT_YES) { + binding.appbar.setBackgroundColor(Color.BLACK) + binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background)) + } else { + binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface)) + } + + with(binding.searchView) { + onFocusChangeListener = this@MainActivity + searchSuggestionListener = this@MainActivity } - binding.navigationView.apply { - val menuView = findViewById(com.google.android.material.R.id.design_navigation_view) - navHeaderBinding.root.setOnApplyWindowInsetsListener { v, insets -> - v.updatePadding(top = insets.systemWindowInsetTop) + with(binding.navigationView) { + val menuView = + findViewById(com.google.android.material.R.id.design_navigation_view) + ViewCompat.setOnApplyWindowInsetsListener(navHeaderBinding.root) { v, insets -> + val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.updatePadding(top = systemWindowInsets.top) // NavigationView doesn't dispatch insets to the menu view, so pad the bottom here. - menuView.updatePadding(bottom = insets.systemWindowInsetBottom) + menuView.updatePadding(bottom = systemWindowInsets.bottom) insets } addHeaderView(navHeaderBinding.root) @@ -103,9 +125,7 @@ class MainActivity : BaseActivity(), openDefaultSection() } if (savedInstanceState == null) { - TrackWorker.setup(applicationContext) - AppUpdateChecker(this).launchIfNeeded() - OnboardDialogFragment.showWelcome(get(), supportFragmentManager) + onFirstStart() } viewModel.onOpenReader.observe(this, this::onOpenReader) @@ -114,6 +134,12 @@ class MainActivity : BaseActivity(), viewModel.remoteSources.observe(this, this::updateSideMenu) } + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + drawerToggle.isDrawerIndicatorEnabled = + binding.drawer.getDrawerLockMode(GravityCompat.START) == DrawerLayout.LOCK_MODE_UNLOCKED + } + override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) drawerToggle.syncState() @@ -126,13 +152,14 @@ class MainActivity : BaseActivity(), override fun onBackPressed() { val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH) - binding.searchView.setQuery(resources.getString(R.string._empty), false) + binding.searchView.clearFocus() when { binding.drawer.isDrawerOpen(binding.navigationView) -> binding.drawer.closeDrawer( binding.navigationView) fragment != null -> supportFragmentManager.commit { remove(fragment) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + runOnCommit { onSearchClosed() } } else -> super.onBackPressed() } @@ -186,16 +213,17 @@ class MainActivity : BaseActivity(), } override fun onWindowInsetsChanged(insets: Insets) { - binding.toolbarCard.updateLayoutParams { - topMargin = insets.top + 16 - leftMargin = insets.left + 32 - rightMargin = insets.right + 32 + binding.toolbarCard.updateLayoutParams { + topMargin = insets.top + resources.resolveDp(8) } - binding.fab.updateLayoutParams { + binding.fab.updateLayoutParams { bottomMargin = insets.bottom + topMargin leftMargin = insets.left + topMargin rightMargin = insets.right + topMargin } + binding.container.updateLayoutParams { + topMargin = -(binding.appbar.measureHeight()) + } } override fun onFocusChange(v: View?, hasFocus: Boolean) { @@ -205,6 +233,7 @@ class MainActivity : BaseActivity(), supportFragmentManager.commit { add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH) setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + runOnCommit { onSearchOpened() } } } } @@ -215,6 +244,7 @@ class MainActivity : BaseActivity(), } override fun onQueryClick(query: String, submit: Boolean) { + binding.searchView.query = query if (submit) { if (query.isNotEmpty()) { val source = searchSuggestionViewModel.getLocalSearchSource() @@ -225,8 +255,6 @@ class MainActivity : BaseActivity(), } searchSuggestionViewModel.saveQuery(query) } - } else { - searchUi?.query = query } } @@ -244,28 +272,6 @@ class MainActivity : BaseActivity(), }.show() } - override fun onMenuItemActionExpand(item: MenuItem?): Boolean { - val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH) - if (fragment == null) { - supportFragmentManager.commit { - add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH) - setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - } - } - return true - } - - override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { - val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH) - if (fragment != null) { - supportFragmentManager.commit { - remove(fragment) - setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) - } - } - return true - } - private fun onOpenReader(manga: Manga) { val options = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ActivityOptions.makeClipRevealAnimation( @@ -334,6 +340,53 @@ class MainActivity : BaseActivity(), binding.fab.isVisible = fragment is HistoryListFragment } + private fun onSearchOpened() { + binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + drawerToggle.isDrawerIndicatorEnabled = false + TransitionManager.beginDelayedTransition(binding.appbar) + // Avoiding shadows on the sides if the color is transparent, so we make the AppBarLayout white/grey/black + if (isDarkAmoledTheme()) { + binding.toolbar.setBackgroundColor(Color.BLACK) + } else { + binding.appbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_surface)) + } + binding.toolbarCard.apply { + cardElevation = 0f + // Remove margin + updateLayoutParams { + leftMargin = 0 + rightMargin = 0 + } + + } + binding.appbar.elevation = searchViewElevation + } + + private fun onSearchClosed() { + binding.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) + drawerToggle.isDrawerIndicatorEnabled = true + if (isDarkAmoledTheme()) { + binding.toolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.color_background)) + } + TransitionManager.beginDelayedTransition(binding.appbar) + // Returning transparent color + binding.appbar.setBackgroundColor(Color.TRANSPARENT) + binding.appbar.elevation = 0f + binding.toolbarCard.apply { + cardElevation = searchViewElevation + updateLayoutParams { + leftMargin = resources.resolveDp(16) + rightMargin = resources.resolveDp(16) + } + } + } + + private fun onFirstStart() { + TrackWorker.setup(applicationContext) + AppUpdateChecker(this@MainActivity).launchIfNeeded() + OnboardDialogFragment.showWelcome(get(), supportFragmentManager) + } + private companion object { const val TAG_PRIMARY = "primary" diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt index 19ad00c4b..97ca1df3a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt @@ -19,7 +19,7 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage class ProtectActivity : BaseActivity(), TextView.OnEditorActionListener, TextWatcher, View.OnClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt index 74ea5b618..a7a806fef 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt @@ -13,6 +13,6 @@ val readerModule single { PagesCache(get()) } viewModel { params -> - ReaderViewModel(params[0], params[1], get(), get(), get(), get()) + ReaderViewModel(params[0], params[1], get(), get(), get(), get(), get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt index 667d7113a..37fb8f8fd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ChaptersDialog.kt @@ -9,22 +9,25 @@ import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import org.koin.android.ext.android.get import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.model.MangaChapter +import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.databinding.DialogChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter +import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.history.domain.ChapterExtra import org.koitharu.kotatsu.utils.ext.withArgs class ChaptersDialog : AlertDialogFragment(), - OnListItemClickListener { + OnListItemClickListener { override fun onInflateView( inflater: LayoutInflater, - container: ViewGroup? + container: ViewGroup?, ) = DialogChaptersBinding.inflate(inflater, container, false) override fun onBuildDialog(builder: AlertDialog.Builder) { @@ -44,6 +47,7 @@ class ChaptersDialog : AlertDialogFragment(), } val currentId = arguments?.getLong(ARG_CURRENT_ID, 0L) ?: 0L val currentPosition = chapters.indexOfFirst { it.id == currentId } + val dateFormat = get().dateFormat() binding.recyclerViewChapters.adapter = ChaptersAdapter(this).apply { setItems(chapters.mapIndexed { index, chapter -> chapter.toListItem( @@ -51,7 +55,9 @@ class ChaptersDialog : AlertDialogFragment(), index < currentPosition -> ChapterExtra.READ index == currentPosition -> ChapterExtra.CURRENT else -> ChapterExtra.UNREAD - } + }, + isMissing = false, + dateFormat = dateFormat, ) }) { if (currentPosition >= 0) { @@ -66,11 +72,11 @@ class ChaptersDialog : AlertDialogFragment(), } } - override fun onItemClick(item: MangaChapter, view: View) { + override fun onItemClick(item: ChapterListItem, view: View) { ((parentFragment as? OnChapterChangeListener) ?: (activity as? OnChapterChangeListener))?.let { dismiss() - it.onChapterChanged(item) + it.onChapterChanged(item.chapter) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index b8b7bcb19..c745d2071 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -13,7 +13,10 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.graphics.Insets -import androidx.core.view.* +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.postDelayed +import androidx.core.view.updatePadding import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar @@ -52,7 +55,7 @@ class ReaderActivity : BaseFullscreenActivity(), GridTouchHelper.OnGridTouchListener, OnPageSelectListener, ReaderConfigDialog.Callback, ActivityResultCallback, ReaderControlDelegate.OnInteractionListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + private val viewModel by viewModel { parametersOf(MangaIntent.from(intent), intent?.getParcelableExtra(EXTRA_STATE)) } @@ -192,7 +195,8 @@ class ReaderActivity : BaseFullscreenActivity(), override fun onActivityResult(result: Boolean) { if (result) { - viewModel.saveCurrentPage(contentResolver) + viewModel.saveCurrentState(reader?.getCurrentState()) + viewModel.saveCurrentPage() } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index adb4a5914..cb4a0e38e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -3,7 +3,6 @@ package org.koitharu.kotatsu.reader.ui import android.content.ContentResolver import android.net.Uri import android.util.LongSparseArray -import android.webkit.URLUtil import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.* @@ -19,15 +18,17 @@ import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.os.ShortcutsRepository +import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.history.domain.HistoryRepository -import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState -import org.koitharu.kotatsu.utils.MediaStoreCompat +import org.koitharu.kotatsu.utils.DownloadManagerHelper import org.koitharu.kotatsu.utils.SingleLiveEvent -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.IgnoreErrors +import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct +import org.koitharu.kotatsu.utils.ext.processLifecycleScope class ReaderViewModel( intent: MangaIntent, @@ -35,7 +36,8 @@ class ReaderViewModel( private val dataRepository: MangaDataRepository, private val historyRepository: HistoryRepository, private val shortcutsRepository: ShortcutsRepository, - private val settings: AppSettings + private val settings: AppSettings, + private val downloadManagerHelper: DownloadManagerHelper, ) : BaseViewModel() { private var loadingJob: Job? = null @@ -147,22 +149,17 @@ class ReaderViewModel( return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() } } - fun saveCurrentPage(resolver: ContentResolver) { + fun saveCurrentPage() { launchJob(Dispatchers.Default) { try { val state = currentState.value ?: error("Undefined state") val page = content.value?.pages?.find { it.chapterId == state.chapterId && it.index == state.page }?.toMangaPage() ?: error("Page not found") - val repo = page.source.repository + val repo = MangaRepository(page.source) val pageUrl = repo.getPageUrl(page) - val file = get()[pageUrl] ?: error("Page not found in cache") - val uri = file.inputStream().use { input -> - val fileName = URLUtil.guessFileName(pageUrl, null, null) - MediaStoreCompat(resolver).insertImage(fileName) { - input.copyTo(it) - } - } + val downloadId = downloadManagerHelper.downloadPage(page, pageUrl) + val uri = downloadManagerHelper.awaitDownload(downloadId) onPageSaved.postCall(uri) } catch (e: CancellationException) { } catch (e: Exception) { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt index 1c62b2cce..0113bb475 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/SimpleSettingsActivity.kt @@ -3,16 +3,19 @@ package org.koitharu.kotatsu.reader.ui import android.content.Context import android.content.Intent import android.os.Bundle +import android.os.Parcelable import androidx.core.graphics.Insets import androidx.core.view.updatePadding import androidx.fragment.app.commit import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.databinding.ActivitySettingsSimpleBinding import org.koitharu.kotatsu.settings.MainSettingsFragment import org.koitharu.kotatsu.settings.NetworkSettingsFragment import org.koitharu.kotatsu.settings.ReaderSettingsFragment +import org.koitharu.kotatsu.settings.SourceSettingsFragment class SimpleSettingsActivity : BaseActivity() { @@ -25,6 +28,9 @@ class SimpleSettingsActivity : BaseActivity() { R.id.container, when (intent?.action) { Intent.ACTION_MANAGE_NETWORK_USAGE -> NetworkSettingsFragment() ACTION_READER -> ReaderSettingsFragment() + ACTION_SOURCE -> SourceSettingsFragment.newInstance( + intent.getParcelableExtra(EXTRA_SOURCE) ?: MangaSource.LOCAL + ) else -> MainSettingsFragment() } ) @@ -43,9 +49,17 @@ class SimpleSettingsActivity : BaseActivity() { private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" + private const val ACTION_SOURCE = + "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" + private const val EXTRA_SOURCE = "source" fun newReaderSettingsIntent(context: Context) = Intent(context, SimpleSettingsActivity::class.java) .setAction(ACTION_READER) + + fun newSourceSettingsIntent(context: Context, source: MangaSource) = + Intent(context, SimpleSettingsActivity::class.java) + .setAction(ACTION_SOURCE) + .putExtra(EXTRA_SOURCE, source as Parcelable) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt index c44a5a1c0..b6adc87b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/BaseReaderAdapter.kt @@ -47,10 +47,6 @@ abstract class BaseReaderAdapter>( viewType: Int ): H = onCreateViewHolder(parent, loader, settings, exceptionResolver) - fun setItems(items: List, callback: Runnable) { - differ.submitList(items, callback) - } - suspend fun setItems(items: List) = suspendCoroutine { cont -> differ.submitList(items) { cont.resume(Unit) diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt index 1da4a9d39..87ea32b7f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageAnimTransformer.kt @@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2 class ReversedPageAnimTransformer : ViewPager2.PageTransformer { - override fun transformPage(page: View, position: Float) { - with(page) { - val pageWidth = width - when { - position > 1 -> alpha = 0f - position >= 0 -> { - alpha = 1f - translationX = 0f - translationZ = 0f - scaleX = 1 + FACTOR * position - scaleY = 1f - } - position >= -1 -> { - alpha = 1f - translationX = pageWidth * -position - translationZ = -1f - scaleX = 1f - scaleY = 1f - } - else -> alpha = 0f + override fun transformPage(page: View, position: Float) = with(page) { + translationX = -position * width + pivotX = width.toFloat() + pivotY = height / 2f + cameraDistance = 20000f + when { + position < -1f || position > 1f -> { + alpha = 0f + rotationY = 0f + translationZ = -1f + } + position <= 0f -> { + alpha = 1f + rotationY = 0f + translationZ = 0f + } + position > 0f -> { + alpha = 1f + rotationY = 120 * position + translationZ = 2f } } } - - private companion object { - - const val FACTOR = 0.1f - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt index edf5b205b..33920e631 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed import android.graphics.PointF +import android.view.Gravity +import android.widget.FrameLayout import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.ZoomMode @@ -16,6 +18,11 @@ class ReversedPageHolder( exceptionResolver: ExceptionResolver ) : PageHolder(binding, loader, settings, exceptionResolver) { + init { + (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) + .gravity = Gravity.START or Gravity.BOTTOM + } + override fun onImageShowing(zoom: ZoomMode) { with(binding.ssiv) { maxScale = 2f * maxOf( diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt index cd03ce8eb..1aad3c8b0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageAnimTransformer.kt @@ -5,32 +5,27 @@ import androidx.viewpager2.widget.ViewPager2 class PageAnimTransformer : ViewPager2.PageTransformer { - override fun transformPage(page: View, position: Float) { - page.apply { - val pageWidth = width - when { - position < -1 -> alpha = 0f - position <= 0 -> { // [-1,0] - alpha = 1f - translationX = 0f - translationZ = 0f - scaleX = 1 + FACTOR * position - scaleY = 1f - } - position <= 1 -> { // (0,1] - alpha = 1f - translationX = pageWidth * -position - translationZ = -1f - scaleX = 1f - scaleY = 1f - } - else -> alpha = 0f + override fun transformPage(page: View, position: Float) = with(page) { + translationX = -position * width + pivotX = 0f + pivotY = height / 2f + cameraDistance = 20000f + when { + position < -1f || position > 1f -> { + alpha = 0f + rotationY = 0f + translationZ = -1f + } + position > 0f -> { + alpha = 1f + rotationY = 0f + translationZ = 0f + } + position <= 0f -> { + alpha = 1f + rotationY = 120 * position + translationZ = 2f } } } - - private companion object { - - const val FACTOR = 0.1f - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index 369bccf48..bd630b8d0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -20,17 +20,20 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage open class PageHolder( binding: ItemPageBinding, loader: PageLoader, - settings: AppSettings, exceptionResolver: ExceptionResolver + settings: AppSettings, + exceptionResolver: ExceptionResolver, ) : BasePageHolder(binding, loader, settings, exceptionResolver), View.OnClickListener { init { binding.ssiv.setOnImageEventListener(delegate) binding.buttonRetry.setOnClickListener(this) + binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled } override fun onBind(data: ReaderPage) { delegate.onBind(data.toMangaPage()) + binding.textViewNumber.text = (data.index + 1).toString() } override fun onRecycled() { diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt index c93a27ef0..d2d2a6b50 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/standard/PagerReaderFragment.kt @@ -12,7 +12,10 @@ import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter import org.koitharu.kotatsu.reader.ui.pager.ReaderPage -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.doOnPageChanged +import org.koitharu.kotatsu.utils.ext.recyclerView +import org.koitharu.kotatsu.utils.ext.resetTransformations +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import kotlin.math.absoluteValue class PagerReaderFragment : BaseReader() { @@ -37,8 +40,8 @@ class PagerReaderFragment : BaseReader() { val transformer = if (it) PageAnimTransformer() else null binding.pager.setPageTransformer(transformer) if (transformer == null) { - binding.pager.recyclerView?.children?.forEach { - it.resetTransformations() + binding.pager.recyclerView?.children?.forEach { view -> + view.resetTransformations() } } } diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt index 06083d02e..0a2f4f2cb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt +++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt @@ -6,6 +6,7 @@ import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import org.koin.android.ext.android.get @@ -53,6 +54,21 @@ class PagesThumbnailsSheet : BaseBottomSheet(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + + val title = arguments?.getString(ARG_TITLE) + binding.toolbar.title = title + binding.toolbar.setNavigationOnClickListener { dismiss() } + binding.toolbar.subtitle = null + + if (!resources.getBoolean(R.bool.is_tablet)) { + binding.toolbar.navigationIcon = null + } else { + binding.toolbar.subtitle = + resources.getQuantityString(R.plurals.pages, thumbnails.size, thumbnails.size) + } + + val initialTopPosition = binding.recyclerView.top + with(binding.recyclerView) { addItemDecoration( SpacingItemDecoration(view.resources.getDimensionPixelOffset(R.dimen.grid_spacing)) @@ -64,40 +80,36 @@ class PagesThumbnailsSheet : BaseBottomSheet(), get(), this@PagesThumbnailsSheet ) + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) = Unit + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + binding.appbar.isLifted = getChildAt(0).top < initialTopPosition + } + }) addOnLayoutChangeListener(spanResolver) spanResolver.setGridSize(get().gridSize / 100f, this) } - - val title = arguments?.getString(ARG_TITLE) - binding.toolbar.title = title - binding.toolbar.setNavigationOnClickListener { dismiss() } - binding.toolbar.subtitle = - resources.getQuantityString(R.plurals.pages, thumbnails.size, thumbnails.size) - binding.textViewTitle.text = title - if (dialog !is BottomSheetDialog) { - binding.toolbar.isVisible = true - binding.textViewTitle.isVisible = false - binding.appbar.elevation = resources.getDimension(R.dimen.elevation_large) - } } override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).also { val behavior = (it as? BottomSheetDialog)?.behavior ?: return@also behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - private val elevation = resources.getDimension(R.dimen.elevation_large) override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit override fun onStateChanged(bottomSheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_EXPANDED) { - binding.toolbar.isVisible = true - binding.textViewTitle.isVisible = false - binding.appbar.elevation = elevation + binding.toolbar.setNavigationIcon(R.drawable.ic_cross) + binding.toolbar.subtitle = + resources.getQuantityString(R.plurals.pages, + thumbnails.size, + thumbnails.size) } else { - binding.toolbar.isVisible = false - binding.textViewTitle.isVisible = true - binding.appbar.elevation = 0f + binding.toolbar.navigationIcon = null + binding.toolbar.subtitle = null } } }) diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 1c5004eb4..5ae3a92da 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -1,16 +1,20 @@ package org.koitharu.kotatsu.remotelist.ui +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.koitharu.kotatsu.core.model.MangaFilter +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.list.ui.MangaListFragment +import org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity import org.koitharu.kotatsu.utils.ext.parcelableArgument import org.koitharu.kotatsu.utils.ext.withArgs class RemoteListFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + override val viewModel by viewModel { parametersOf(source) } @@ -24,9 +28,24 @@ class RemoteListFragment : MangaListFragment() { return source.title } - override fun onFilterChanged(filter: MangaFilter) { - viewModel.applyFilter(filter) - super.onFilterChanged(filter) + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.opt_list_remote, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_source_settings -> { + startActivity( + SimpleSettingsActivity.newSourceSettingsIntent( + context ?: return false, + source, + ) + ) + true + } + else -> super.onOptionsItemSelected(item) + } } companion object { diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 9df7ed507..b9b1f3d3e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -7,15 +7,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.core.model.Manga -import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.list.ui.MangaFilterConfig +import org.koitharu.kotatsu.list.domain.AvailableFilters import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -import java.util.* class RemoteListViewModel( private val repository: MangaRepository, @@ -25,8 +25,8 @@ class RemoteListViewModel( private val mangaList = MutableStateFlow?>(null) private val hasNextPage = MutableStateFlow(false) private val listError = MutableStateFlow(null) - private var appliedFilter: MangaFilter? = null private var loadingJob: Job? = null + private val headerModel = ListHeader((repository as RemoteMangaRepository).title, 0) override val content = combine( mangaList, @@ -39,7 +39,9 @@ class RemoteListViewModel( list == null -> listOf(LoadingState) list.isEmpty() -> listOf(EmptyState(R.drawable.ic_book_cross, R.string.nothing_found, R.string._empty)) else -> { - val result = ArrayList(list.size + 1) + val result = ArrayList(list.size + 3) + result += headerModel + createFilterModel()?.let { result.add(it) } list.toUi(result, mode) when { error != null -> result += error.toErrorFooter() @@ -76,10 +78,10 @@ class RemoteListViewModel( loadingJob = launchLoadingJob(Dispatchers.Default) { try { listError.value = null - val list = repository.getList( + val list = repository.getList2( offset = if (append) mangaList.value?.size ?: 0 else 0, - sortOrder = appliedFilter?.sortOrder, - tag = appliedFilter?.tag + sortOrder = currentFilter.sortOrder, + tags = currentFilter.tags, ) if (!append) { mangaList.value = list @@ -88,27 +90,37 @@ class RemoteListViewModel( } hasNextPage.value = list.isNotEmpty() } catch (e: Throwable) { + if (BuildConfig.DEBUG) { + e.printStackTrace() + } listError.value = e } } } - fun applyFilter(newFilter: MangaFilter) { - appliedFilter = newFilter + override fun onFilterChanged() { + super.onFilterChanged() mangaList.value = null hasNextPage.value = false loadList(false) - filter.value?.run { - filter.value = copy(currentFilter = newFilter) + } + + private fun createFilterModel(): CurrentFilterModel? { + val tags = currentFilter.tags + return if (tags.isEmpty()) { + null + } else { + CurrentFilterModel(tags.map { ChipsView.ChipModel(0, it.title, it) }) } } private fun loadFilter() { launchJob(Dispatchers.Default) { try { - val sorts = repository.sortOrders.sortedBy { it.ordinal } - val tags = repository.getTags().sortedBy { it.title } - filter.postValue(MangaFilterConfig(sorts, tags, appliedFilter)) + val sorts = repository.sortOrders + val tags = repository.getTags() + availableFilters = AvailableFilters(sorts, tags) + onFilterChanged() } catch (e: Exception) { if (BuildConfig.DEBUG) { e.printStackTrace() diff --git a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt index a58e5098c..d099f54c5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/domain/MangaSearchRepository.kt @@ -29,7 +29,11 @@ class MangaSearchRepository( MangaProviderFactory.getSources(settings, includeHidden = false).asFlow() .flatMapMerge(concurrency) { source -> runCatching { - source.repository.getList(0, query, SortOrder.POPULARITY) + source.repository.getList2( + offset = 0, + query = query, + sortOrder = SortOrder.POPULARITY + ) }.getOrElse { emptyList() }.asFlow() diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt index 7221f6d11..2484dcb76 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchActivity.kt @@ -18,9 +18,7 @@ import org.koitharu.kotatsu.utils.ext.showKeyboard class SearchActivity : BaseActivity(), SearchView.OnQueryTextListener { - private val searchSuggestionViewModel by viewModel( - mode = LazyThreadSafetyMode.NONE - ) + private val searchSuggestionViewModel by viewModel() private lateinit var source: MangaSource override fun onCreate(savedInstanceState: Bundle?) { @@ -51,6 +49,9 @@ class SearchActivity : BaseActivity(), SearchView.OnQuery left = insets.left, right = insets.right ) + binding.container.updatePadding( + bottom = insets.bottom + ) } override fun onQueryTextSubmit(query: String?): Boolean { diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt index 3ebafa633..6f2621475 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchFragment.kt @@ -10,7 +10,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs class SearchFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + override val viewModel by viewModel { parametersOf(source, query) } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt index 193817c33..659e12082 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/SearchViewModel.kt @@ -12,7 +12,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.* import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct -import java.util.* class SearchViewModel( private val repository: MangaRepository, @@ -72,9 +71,9 @@ class SearchViewModel( loadingJob = launchLoadingJob(Dispatchers.Default) { try { listError.value = null - val list = repository.getList( + val list = repository.getList2( offset = if (append) mangaList.value?.size ?: 0 else 0, - query = query + query = query, ) if (!append) { mangaList.value = list diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt index 4680fa8b1..2f6ca1ae3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/global/GlobalSearchFragment.kt @@ -9,7 +9,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs class GlobalSearchFragment : MangaListFragment() { - override val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + override val viewModel by viewModel { parametersOf(query) } diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index e053df0fc..f48e9b0da 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -11,7 +11,9 @@ import org.koin.android.ext.android.get import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding +import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter +import org.koitharu.kotatsu.utils.ext.measureHeight class SearchSuggestionFragment : BaseFragment(), SearchSuggestionItemCallback.SuggestionItemListener { @@ -39,7 +41,9 @@ class SearchSuggestionFragment : BaseFragment() } override fun onWindowInsetsChanged(insets: Insets) { + val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top binding.root.updatePadding( + top = headerHeight, left = insets.left, right = insets.right, bottom = insets.bottom, diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchUI.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchUI.kt deleted file mode 100644 index 6ed23e736..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchUI.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.koitharu.kotatsu.search.ui.suggestion - -import android.view.MenuItem -import androidx.appcompat.widget.SearchView -import org.koitharu.kotatsu.R - -class SearchUI( - private val searchView: SearchView, - listener: SearchSuggestionListener, - hint: String? = null, -) { - - init { - val context = searchView.context - searchView.queryHint = hint ?: context.getString(R.string.search_manga) - searchView.setOnQueryTextListener(QueryListener(listener)) - } - - var query: String - get() = searchView.query.toString() - set(value) { - searchView.setQuery(value, false) - } - - private class QueryListener( - private val listener: SearchSuggestionListener, - ) : SearchView.OnQueryTextListener { - - override fun onQueryTextSubmit(query: String?): Boolean { - return if (!query.isNullOrBlank()) { - listener.onQueryClick(query.trim(), submit = true) - true - } else false - } - - override fun onQueryTextChange(newText: String?): Boolean { - listener.onQueryChanged(newText?.trim().orEmpty()) - return true - } - } - - companion object { - - fun from( - searchView: SearchView, - listener: SearchSuggestionListener, - ): SearchUI = SearchUI(searchView, listener) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt index 677fac24e..350b82aab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt +++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt @@ -3,9 +3,11 @@ package org.koitharu.kotatsu.search.ui.widget import android.content.Context import android.util.AttributeSet import android.view.KeyEvent +import android.view.inputmethod.EditorInfo import androidx.annotation.AttrRes import androidx.appcompat.widget.AppCompatEditText import com.google.android.material.R +import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener class SearchEditText @JvmOverloads constructor( context: Context, @@ -13,16 +15,44 @@ class SearchEditText @JvmOverloads constructor( @AttrRes defStyleAttr: Int = R.attr.editTextStyle, ) : AppCompatEditText(context, attrs, defStyleAttr) { + var searchSuggestionListener: SearchSuggestionListener? = null + + var query: String + get() = text?.trim()?.toString().orEmpty() + set(value) { + if (value != text?.toString()) { + setText(value) + setSelection(value.length) + } + } + override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean { if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { if (hasFocus()) { clearFocus() - return true + // return true } } return super.onKeyPreIme(keyCode, event) } + override fun onEditorAction(actionCode: Int) { + super.onEditorAction(actionCode) + if (actionCode == EditorInfo.IME_ACTION_SEARCH) { + searchSuggestionListener?.onQueryClick(query, submit = true) + } + } + + override fun onTextChanged( + text: CharSequence?, + start: Int, + lengthBefore: Int, + lengthAfter: Int, + ) { + super.onTextChanged(text, start, lengthBefore, lengthAfter) + searchSuggestionListener?.onQueryChanged(query) + } + override fun clearFocus() { super.clearFocus() text?.clear() diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt index 8380e414f..99ec51e9c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt @@ -76,15 +76,7 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach true } AppSettings.KEY_COOKIES_CLEAR -> { - viewLifecycleScope.launch { - val cookieJar = get() - cookieJar.clear() - Snackbar.make( - listView ?: return@launch, - R.string.cookies_cleared, - Snackbar.LENGTH_SHORT - ).show() - } + clearCookies() true } AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { @@ -144,4 +136,22 @@ class HistorySettingsFragment : BasePreferenceFragment(R.string.history_and_cach } }.show() } + + private fun clearCookies() { + AlertDialog.Builder(context ?: return) + .setTitle(R.string.clear_cookies) + .setMessage(R.string.text_clear_cookies_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + viewLifecycleScope.launch { + val cookieJar = get() + cookieJar.clear() + Snackbar.make( + listView ?: return@launch, + R.string.cookies_cleared, + Snackbar.LENGTH_SHORT + ).show() + } + }.show() + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt index 388059e99..17ecdf9ab 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/MainSettingsFragment.kt @@ -1,26 +1,23 @@ package org.koitharu.kotatsu.settings -import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences import android.os.Bundle -import android.text.InputType import android.view.View import androidx.appcompat.app.AppCompatDelegate import androidx.preference.* -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.launch -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog -import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity -import org.koitharu.kotatsu.utils.ext.* +import org.koitharu.kotatsu.utils.ext.getStorageName +import org.koitharu.kotatsu.utils.ext.names +import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat import java.io.File +import java.util.* class MainSettingsFragment : BasePreferenceFragment(R.string.settings), @@ -40,23 +37,30 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), entryValues = ListMode.values().names() setDefaultValueCompat(ListMode.GRID.name) } + findPreference(AppSettings.KEY_DATE_FORMAT)?.run { + entryValues = arrayOf("", "MM/dd/yy", "dd/MM/yy", "yyyy-MM-dd", "dd MMM yyyy", "MMM dd, yyyy") + val now = Date().time + entries = entryValues.map { value -> + val formattedDate = settings.dateFormat(value.toString()).format(now) + if (value == "") { + "${context.getString(R.string.system_default)} ($formattedDate)" + } else { + "$value ($formattedDate)" + } + }.toTypedArray() + setDefaultValueCompat("") + summary = "%s" + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - findPreference(AppSettings.KEY_APP_UPDATE_AUTO)?.run { - isVisible = AppUpdateChecker.isUpdateSupported(context) - } findPreference(AppSettings.KEY_LOCAL_STORAGE)?.run { summary = settings.getStorageDir(context)?.getStorageName(context) ?: getString(R.string.not_available) } findPreference(AppSettings.KEY_PROTECT_APP)?.isChecked = !settings.appPassword.isNullOrEmpty() - findPreference(AppSettings.KEY_APP_VERSION)?.run { - title = getString(R.string.app_version, BuildConfig.VERSION_NAME) - isEnabled = AppUpdateChecker.isUpdateSupported(context) - } settings.subscribe(this) } @@ -120,10 +124,6 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), } true } - AppSettings.KEY_APP_VERSION -> { - checkForUpdates() - true - } else -> super.onPreferenceTreeClick(preference) } } @@ -132,73 +132,4 @@ class MainSettingsFragment : BasePreferenceFragment(R.string.settings), settings.setStorageDir(context ?: return, file) } - private fun enableAppProtection(preference: SwitchPreference) { - val ctx = preference.context ?: return - val cancelListener = - object : DialogInterface.OnCancelListener, DialogInterface.OnClickListener { - - override fun onCancel(dialog: DialogInterface?) { - settings.appPassword = null - preference.isChecked = false - preference.isEnabled = true - } - - override fun onClick(dialog: DialogInterface?, which: Int) = onCancel(dialog) - } - preference.isEnabled = false - TextInputDialog.Builder(ctx) - .setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) - .setHint(R.string.enter_password) - .setNegativeButton(android.R.string.cancel, cancelListener) - .setOnCancelListener(cancelListener) - .setPositiveButton(android.R.string.ok) { d, password -> - if (password.isBlank()) { - cancelListener.onCancel(d) - return@setPositiveButton - } - TextInputDialog.Builder(ctx) - .setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD) - .setHint(R.string.repeat_password) - .setNegativeButton(android.R.string.cancel, cancelListener) - .setOnCancelListener(cancelListener) - .setPositiveButton(android.R.string.ok) { d2, password2 -> - if (password == password2) { - settings.appPassword = password.md5() - preference.isChecked = true - preference.isEnabled = true - } else { - cancelListener.onCancel(d2) - Snackbar.make( - listView, - R.string.passwords_mismatch, - Snackbar.LENGTH_SHORT - ).show() - } - }.setTitle(preference.title) - .create() - .show() - }.setTitle(preference.title) - .create() - .show() - } - - private fun checkForUpdates() { - viewLifecycleScope.launch { - findPreference(AppSettings.KEY_APP_VERSION)?.run { - setSummary(R.string.checking_for_updates) - isSelectable = false - } - val result = AppUpdateChecker(activity ?: return@launch).checkNow() - findPreference(AppSettings.KEY_APP_VERSION)?.run { - setSummary( - when (result) { - true -> R.string.check_for_updates - false -> R.string.no_update_available - null -> R.string.update_check_failed - } - ) - isSelectable = true - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt index 5382630a7..c7cdef0d4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -30,7 +30,6 @@ class SettingsActivity : BaseActivity(), } } - @Suppress("DEPRECATION") override fun onPreferenceStartFragment( caller: PreferenceFragmentCompat, pref: Preference diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt index 24fe705d3..6d9f543c4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt @@ -11,6 +11,7 @@ import org.koitharu.kotatsu.settings.backup.BackupViewModel import org.koitharu.kotatsu.settings.backup.RestoreViewModel import org.koitharu.kotatsu.settings.onboard.OnboardViewModel import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel +import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel val settingsModule get() = module { @@ -25,4 +26,5 @@ val settingsModule } viewModel { ProtectSetupViewModel(get()) } viewModel { OnboardViewModel(get()) } + viewModel { SourcesSettingsViewModel(get()) } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt index 4bccaf8e2..da9115e80 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsFragment.kt @@ -9,8 +9,10 @@ import androidx.preference.PreferenceFragmentCompat import androidx.preference.TwoStatePreference import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.prefs.SourceSettings +import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.utils.EditTextBindListener import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf @@ -20,6 +22,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs class SourceSettingsFragment : PreferenceFragmentCompat() { private val source by parcelableArgument(EXTRA_SOURCE) + private var repository: RemoteMangaRepository? = null override fun onResume() { super.onResume() @@ -29,6 +32,7 @@ class SourceSettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.sharedPreferencesName = source.name val repo = mangaRepositoryOf(source) as? RemoteMangaRepository ?: return + repository = repo addPreferencesFromResource(R.xml.pref_source) val screen = preferenceScreen val prefsMap = ArrayMap(screen.preferenceCount) @@ -41,13 +45,32 @@ class SourceSettingsFragment : PreferenceFragmentCompat() { initPreferenceWithDefaultValue(pref, defValue) } } + findPreference(SourceSettings.KEY_AUTH)?.run { + isVisible = repo is MangaRepositoryAuthProvider + isEnabled = (repo as? MangaRepositoryAuthProvider)?.isAuthorized() == false + } + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + return when (preference?.key) { + SourceSettings.KEY_AUTH -> { + startActivity( + SourceAuthActivity.newIntent( + context ?: return false, + source, + ) + ) + true + } + else -> super.onPreferenceTreeClick(preference) + } } private fun initPreferenceWithDefaultValue(preference: Preference, defaultValue: Any) { - when(preference) { + when (preference) { is EditTextPreference -> { preference.summaryProvider = EditTextDefaultSummaryProvider(defaultValue.toString()) - when(preference.key) { + when (preference.key) { SourceSettings.KEY_DOMAIN -> preference.setOnBindEditTextListener( EditTextBindListener( EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt new file mode 100644 index 000000000..19107be54 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -0,0 +1,92 @@ +package org.koitharu.kotatsu.settings.about + +import android.os.Bundle +import android.view.View +import androidx.preference.Preference +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BasePreferenceFragment +import org.koitharu.kotatsu.browser.BrowserActivity +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.settings.AppUpdateChecker +import org.koitharu.kotatsu.utils.ext.viewLifecycleScope + +class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_about) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + findPreference(AppSettings.KEY_APP_UPDATE_AUTO)?.run { + isVisible = AppUpdateChecker.isUpdateSupported(context) + } + findPreference(AppSettings.KEY_APP_VERSION)?.run { + title = getString(R.string.app_version, BuildConfig.VERSION_NAME) + isEnabled = AppUpdateChecker.isUpdateSupported(context) + } + } + + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + return when (preference?.key) { + AppSettings.KEY_APP_VERSION -> { + checkForUpdates() + true + } + AppSettings.KEY_APP_TRANSLATION -> { + startActivity(context?.let { BrowserActivity.newIntent(it, + "https://hosted.weblate.org/engage/kotatsu", + resources.getString(R.string.about_app_translation)) }) + true + } + AppSettings.KEY_FEEDBACK_4PDA -> { + startActivity(context?.let { BrowserActivity.newIntent(it, + "https://4pda.to/forum/index.php?showtopic=697669", + resources.getString(R.string.about_feedback_4pda)) }) + true + } + AppSettings.KEY_FEEDBACK_GITHUB -> { + startActivity(context?.let { BrowserActivity.newIntent(it, + "https://github.com/nv95/Kotatsu/issues", + "GitHub") }) + true + } + AppSettings.KEY_SUPPORT_DEVELOPER -> { + startActivity(context?.let { BrowserActivity.newIntent(it, + "https://yoomoney.ru/to/410012543938752", + resources.getString(R.string.about_support_developer)) }) + true + } + AppSettings.KEY_APP_GRATITUDES -> { + startActivity(context?.let { BrowserActivity.newIntent(it, + "https://github.com/nv95/Kotatsu/graphs/contributors", + resources.getString(R.string.about_gratitudes)) }) + true + } + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun checkForUpdates() { + viewLifecycleScope.launch { + findPreference(AppSettings.KEY_APP_VERSION)?.run { + setSummary(R.string.checking_for_updates) + isSelectable = false + } + val result = AppUpdateChecker(activity ?: return@launch).checkNow() + findPreference(AppSettings.KEY_APP_VERSION)?.run { + setSummary( + when (result) { + true -> R.string.check_for_updates + false -> R.string.no_update_available + null -> R.string.update_check_failed + } + ) + isSelectable = true + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/about/LicenseFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/about/LicenseFragment.kt new file mode 100644 index 000000000..8d25e3ca9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/about/LicenseFragment.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.settings.about + +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.graphics.Insets +import androidx.core.text.HtmlCompat +import androidx.core.text.parseAsHtml +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseFragment +import org.koitharu.kotatsu.databinding.FragmentCopyrightBinding + +class LicenseFragment : BaseFragment() { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.textView.apply { + text = + SpannableStringBuilder(resources.openRawResource(R.raw.copyright).bufferedReader() + .readText() + .parseAsHtml(HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST)) + movementMethod = LinkMovementMethod.getInstance() + } + } + + override fun onInflateView( + inflater: LayoutInflater, + container: ViewGroup? + ) = FragmentCopyrightBinding.inflate(inflater, container, false) + + override fun onResume() { + super.onResume() + activity?.setTitle(R.string.about_license) + } + + override fun onWindowInsetsChanged(insets: Insets) = Unit + +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt index 4d9003f3b..f7181bb20 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt @@ -14,13 +14,14 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat import org.koitharu.kotatsu.utils.progress.Progress import java.io.File import java.io.FileOutputStream class BackupDialogFragment : AlertDialogFragment() { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() private var backup: File? = null private val saveFileContract = @@ -64,8 +65,8 @@ class BackupDialogFragment : AlertDialogFragment() { private fun onProgressChanged(progress: Progress?) { with(binding.progressBar) { + setIndeterminateCompat(progress == null) isVisible = true - isIndeterminate = progress == null if (progress != null) { this.max = progress.total this.progress = progress.value diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt index 93bde0e64..b41ebb205 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupSettingsFragment.kt @@ -13,7 +13,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.prefs.AppSettings class BackupSettingsFragment : BasePreferenceFragment(R.string.backup_restore), - ActivityResultCallback { + ActivityResultCallback { private val backupSelectCall = registerForActivityResult( ActivityResultContracts.OpenDocument(), diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt index 5353cdefc..97701f82b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -22,10 +22,10 @@ class RestoreDialogFragment : AlertDialogFragment() { override fun onInflateView( inflater: LayoutInflater, - container: ViewGroup? + container: ViewGroup?, ) = DialogProgressBinding.inflate(inflater, container, false) - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) { + private val viewModel by viewModel { parametersOf(arguments?.getString(ARG_FILE)?.toUriOrNull()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt index b6945a7e4..d732abaef 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt @@ -21,7 +21,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs class OnboardDialogFragment : AlertDialogFragment(), OnListItemClickListener, DialogInterface.OnClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() private var isWelcome: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt index b0f2a4ee4..44ddad5bc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt @@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.settings.onboard.model.SourceLocale import org.koitharu.kotatsu.utils.ext.map import org.koitharu.kotatsu.utils.ext.mapToSet +import org.koitharu.kotatsu.utils.ext.toTitleCase import java.util.* class OnboardViewModel( @@ -27,9 +28,9 @@ class OnboardViewModel( init { if (settings.isSourcesSelected) { - selectedLocales.removeAll(settings.hiddenSources.map { x -> MangaSource.valueOf(x).locale }) + selectedLocales.removeAll(settings.hiddenSources.mapToSet { x -> MangaSource.valueOf(x).locale }) } else { - val deviceLocales = LocaleListCompat.getDefault().map { x -> + val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> x.language } selectedLocales.retainAll(deviceLocales) @@ -64,7 +65,7 @@ class OnboardViewModel( } else null SourceLocale( key = key, - title = locale?.getDisplayLanguage(locale)?.capitalize(locale), + title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale), isChecked = key in selectedLocales ) }.sortedWith(SourceLocaleComparator()) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt index f57e5990a..5ab950d69 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt @@ -17,7 +17,7 @@ import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding class ProtectSetupActivity : BaseActivity(), TextWatcher, View.OnClickListener, TextView.OnEditorActionListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourceViewHolder.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourceViewHolder.kt deleted file mode 100644 index 1660f9bd7..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourceViewHolder.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.koitharu.kotatsu.base.ui.list.BaseViewHolder -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding - -class SourceViewHolder(parent: ViewGroup) : - BaseViewHolder( - ItemSourceConfigBinding.inflate(LayoutInflater.from(parent.context), parent, false) - ) { - - override fun onBind(data: MangaSource, extra: Boolean) { - binding.textViewTitle.text = data.title - binding.switchToggle.isChecked = extra - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesAdapter.kt deleted file mode 100644 index 739f35032..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesAdapter.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import android.annotation.SuppressLint -import android.view.MotionEvent -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import org.koitharu.kotatsu.base.domain.MangaProviderFactory -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.utils.ext.mapToSet - -class SourcesAdapter( - private val settings: AppSettings, - private val onItemClickListener: OnListItemClickListener, -) : RecyclerView.Adapter() { - - private val dataSet = - MangaProviderFactory.getSources(settings, includeHidden = true).toMutableList() - private val hiddenItems = settings.hiddenSources.mapNotNull { - runCatching { - MangaSource.valueOf(it) - }.getOrNull() - }.toMutableSet() - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ) = SourceViewHolder(parent).also(::onViewHolderCreated) - - override fun getItemCount() = dataSet.size - - override fun onBindViewHolder(holder: SourceViewHolder, position: Int) { - val item = dataSet[position] - holder.bind(item, !hiddenItems.contains(item)) - } - - @SuppressLint("ClickableViewAccessibility") - private fun onViewHolderCreated(holder: SourceViewHolder) { - holder.binding.switchToggle.setOnCheckedChangeListener { _, it -> - if (it) { - hiddenItems.remove(holder.requireData()) - } else { - hiddenItems.add(holder.requireData()) - } - settings.hiddenSources = hiddenItems.mapToSet { x -> x.name } - } - holder.binding.imageViewConfig.setOnClickListener { v -> - onItemClickListener.onItemClick(holder.requireData(), v) - } - holder.binding.imageViewHandle.setOnTouchListener { v, event -> - if (event.actionMasked == MotionEvent.ACTION_DOWN) { - onItemClickListener.onItemLongClick( - holder.requireData(), - holder.itemView - ) - } else { - false - } - } - } - - fun moveItem(oldPos: Int, newPos: Int) { - val item = dataSet.removeAt(oldPos) - dataSet.add(newPos, item) - notifyItemMoved(oldPos, newPos) - settings.sourcesOrder = dataSet.map { it.ordinal } - } -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesReorderCallback.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesReorderCallback.kt deleted file mode 100644 index 066a43a6f..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesReorderCallback.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.koitharu.kotatsu.settings.sources - -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.RecyclerView - -class SourcesReorderCallback : - ItemTouchHelper.SimpleCallback(ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0) { - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - val adapter = recyclerView.adapter as? SourcesAdapter ?: return false - val oldPos = viewHolder.bindingAdapterPosition - val newPos = target.bindingAdapterPosition - adapter.moveItem(oldPos, newPos) - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit - - override fun isLongPressDragEnabled() = false -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt index ac2d96c79..25893f670 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt @@ -1,25 +1,28 @@ package org.koitharu.kotatsu.settings.sources import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import androidx.core.graphics.Insets import androidx.core.view.updatePadding -import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView -import org.koin.android.ext.android.get +import org.koin.androidx.viewmodel.ext.android.viewModel import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment -import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.settings.SettingsActivity -import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigItemDecoration +import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem class SourcesSettingsFragment : BaseFragment(), - OnListItemClickListener { + SourceConfigListener { private lateinit var reorderHelper: ItemTouchHelper + private val viewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -39,11 +42,16 @@ class SourcesSettingsFragment : BaseFragment(), override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val sourcesAdapter = SourceConfigAdapter(this) with(binding.recyclerView) { - addItemDecoration(DividerItemDecoration(view.context, RecyclerView.VERTICAL)) - adapter = SourcesAdapter(get(), this@SourcesSettingsFragment) + setHasFixedSize(true) + addItemDecoration(SourceConfigItemDecoration(view.context)) + adapter = sourcesAdapter reorderHelper.attachToRecyclerView(this) } + viewModel.items.observe(viewLifecycleOwner) { + sourcesAdapter.items = it + } } override fun onDestroyView() { @@ -51,22 +59,6 @@ class SourcesSettingsFragment : BaseFragment(), super.onDestroyView() } - override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - super.onCreateOptionsMenu(menu, inflater) - // TODO handle changes in dialog - // inflater.inflate(R.menu.opt_sources, menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when(item.itemId) { - R.id.action_languages -> { - OnboardDialogFragment.show(parentFragmentManager) - true - } - else -> super.onOptionsItemSelected(item) - } - } - override fun onWindowInsetsChanged(insets: Insets) { binding.recyclerView.updatePadding( bottom = insets.bottom, @@ -75,14 +67,47 @@ class SourcesSettingsFragment : BaseFragment(), ) } - override fun onItemClick(item: MangaSource, view: View) { - (activity as? SettingsActivity)?.openMangaSourceSettings(item) + override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { + (activity as? SettingsActivity)?.openMangaSourceSettings(item.source) + } + + override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { + viewModel.setEnabled(item.source, isEnabled) } - override fun onItemLongClick(item: MangaSource, view: View): Boolean { - reorderHelper.startDrag( - binding.recyclerView.findContainingViewHolder(view) ?: return false + override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) { + reorderHelper.startDrag(holder) + } + + override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) { + viewModel.expandOrCollapse(header.localeId) + } + + private inner class SourcesReorderCallback : ItemTouchHelper.SimpleCallback( + ItemTouchHelper.DOWN or ItemTouchHelper.UP, + 0, + ) { + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSources( + viewHolder.bindingAdapterPosition, + target.bindingAdapterPosition, ) - return true + + override fun canDropOver( + recyclerView: RecyclerView, + current: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder, + ): Boolean = current.itemViewType == target.itemViewType && viewModel.canReorder( + current.bindingAdapterPosition, + target.bindingAdapterPosition, + ) + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit + + override fun isLongPressDragEnabled() = false } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt new file mode 100644 index 000000000..52125df63 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt @@ -0,0 +1,134 @@ +package org.koitharu.kotatsu.settings.sources + +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.MutableLiveData +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.domain.MangaProviderFactory +import org.koitharu.kotatsu.base.ui.BaseViewModel +import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem +import org.koitharu.kotatsu.utils.ext.map +import org.koitharu.kotatsu.utils.ext.move +import org.koitharu.kotatsu.utils.ext.toTitleCase +import java.util.* + +private const val KEY_ENABLED = "!" + +class SourcesSettingsViewModel( + private val settings: AppSettings, +) : BaseViewModel() { + + val items = MutableLiveData>(emptyList()) + private val expandedGroups = HashSet() + + init { + buildList() + } + + fun reorderSources(oldPos: Int, newPos: Int): Boolean { + val snapshot = items.value?.toMutableList() ?: return false + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + snapshot.move(oldPos, newPos) + settings.sourcesOrder = snapshot.mapNotNull { + (it as? SourceConfigItem.SourceItem)?.source?.ordinal + } + buildList() + return true + } + + fun canReorder(oldPos: Int, newPos: Int): Boolean { + val snapshot = items.value?.toMutableList() ?: return false + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false + return true + } + + fun setEnabled(source: MangaSource, isEnabled: Boolean) { + settings.hiddenSources = if (isEnabled) { + settings.hiddenSources - source.name + } else { + settings.hiddenSources + source.name + } + buildList() + } + + fun expandOrCollapse(headerId: String?) { + if (headerId in expandedGroups) { + expandedGroups.remove(headerId) + } else { + expandedGroups.add(headerId) + } + buildList() + } + + private fun buildList() { + val sources = MangaProviderFactory.getSources(settings, includeHidden = true) + val hiddenSources = settings.hiddenSources + val map = sources.groupByTo(TreeMap(LocaleKeyComparator())) { + if (it.name !in hiddenSources) { + KEY_ENABLED + } else { + it.locale + } + } + val result = ArrayList(sources.size + map.size + 1) + val enabledSources = map.remove(KEY_ENABLED) + if (!enabledSources.isNullOrEmpty()) { + result += SourceConfigItem.Header(R.string.enabled_sources) + enabledSources.mapTo(result) { + SourceConfigItem.SourceItem( + source = it, + isEnabled = true, + ) + } + } + if (enabledSources?.size != sources.size) { + result += SourceConfigItem.Header(R.string.available_sources) + for ((key, list) in map) { + val locale = if (key != null) { + Locale(key) + } else null + list.sortBy { it.ordinal } + val isExpanded = key in expandedGroups + result += SourceConfigItem.LocaleGroup( + localeId = key, + title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale), + isExpanded = isExpanded, + ) + if (isExpanded) { + list.mapTo(result) { + SourceConfigItem.SourceItem( + source = it, + isEnabled = false, + ) + } + } + } + } + items.value = result + } + + private class LocaleKeyComparator : Comparator { + + private val deviceLocales = LocaleListCompat.getAdjustedDefault() + .map { it.language } + + override fun compare(a: String?, b: String?): Int { + when { + a == b -> return 0 + a == null -> return 1 + b == null -> return -1 + } + val ai = deviceLocales.indexOf(a!!) + val bi = deviceLocales.indexOf(b!!) + return when { + ai < 0 && bi < 0 -> a.compareTo(b) + ai < 0 -> 1 + bi < 0 -> -1 + else -> ai.compareTo(bi) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt new file mode 100644 index 000000000..d04d22fcc --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem + +class SourceConfigAdapter( + listener: SourceConfigListener, +) : AsyncListDifferDelegationAdapter( + SourceConfigDiffCallback(), + sourceConfigHeaderDelegate(), + sourceConfigGroupDelegate(listener), + sourceConfigItemDelegate(listener), +) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt new file mode 100644 index 000000000..df7435bac --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -0,0 +1,80 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import android.annotation.SuppressLint +import android.view.MotionEvent +import android.view.View +import android.widget.CompoundButton +import androidx.core.view.isVisible +import androidx.core.view.updatePaddingRelative +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.databinding.ItemExpandableBinding +import org.koitharu.kotatsu.databinding.ItemFilterHeaderBinding +import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem + +fun sourceConfigHeaderDelegate() = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) } +) { + + bind { + binding.root.setText(item.titleResId) + } +} + +fun sourceConfigGroupDelegate( + listener: SourceConfigListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) } +) { + + binding.root.setOnClickListener { + listener.onHeaderClick(item) + } + + bind { + binding.root.text = item.title ?: getString(R.string.other) + binding.root.isChecked = item.isExpanded + } +} + +@SuppressLint("ClickableViewAccessibility") +fun sourceConfigItemDelegate( + listener: SourceConfigListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) } +) { + + val eventListener = object : View.OnClickListener, View.OnTouchListener, + CompoundButton.OnCheckedChangeListener { + override fun onClick(v: View?) = listener.onItemSettingsClick(item) + + override fun onTouch(v: View?, event: MotionEvent): Boolean { + return if (event.actionMasked == MotionEvent.ACTION_DOWN) { + listener.onDragHandleTouch(this@adapterDelegateViewBinding) + true + } else { + false + } + } + + override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { + listener.onItemEnabledChanged(item, isChecked) + } + } + + binding.imageViewConfig.setOnClickListener(eventListener) + binding.switchToggle.setOnCheckedChangeListener(eventListener) + binding.imageViewHandle.setOnTouchListener(eventListener) + + bind { + binding.textViewTitle.text = item.source.title + binding.switchToggle.isChecked = item.isEnabled + binding.imageViewHandle.isVisible = item.isEnabled + binding.imageViewConfig.isVisible = item.isEnabled + binding.root.updatePaddingRelative( + start = if (item.isEnabled) 0 else binding.imageViewHandle.paddingStart * 2, + end = if (item.isEnabled) 0 else binding.imageViewConfig.paddingEnd, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt new file mode 100644 index 000000000..370cca88d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigDiffCallback.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import androidx.recyclerview.widget.DiffUtil +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem + +class SourceConfigDiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean { + return when { + oldItem.javaClass != newItem.javaClass -> false + oldItem is SourceConfigItem.LocaleGroup && newItem is SourceConfigItem.LocaleGroup -> { + oldItem.localeId == newItem.localeId + } + oldItem is SourceConfigItem.SourceItem && newItem is SourceConfigItem.SourceItem -> { + oldItem.source == newItem.source + } + oldItem is SourceConfigItem.Header && newItem is SourceConfigItem.Header -> { + oldItem.titleResId == newItem.titleResId + } + else -> false + } + } + + override fun areContentsTheSame(oldItem: SourceConfigItem, newItem: SourceConfigItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: SourceConfigItem, newItem: SourceConfigItem) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt new file mode 100644 index 000000000..0171b9dcf --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigItemDecoration.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import android.content.Context +import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.base.ui.list.decor.AbstractDividerItemDecoration + +class SourceConfigItemDecoration(context: Context) : AbstractDividerItemDecoration(context) { + + override fun shouldDrawDivider( + above: RecyclerView.ViewHolder, + below: RecyclerView.ViewHolder, + ): Boolean { + return above.itemViewType != 0 && below.itemViewType != 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt new file mode 100644 index 000000000..8bc03a213 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigListener.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.settings.sources.adapter + +import androidx.recyclerview.widget.RecyclerView +import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem + +interface SourceConfigListener { + + fun onItemSettingsClick(item: SourceConfigItem.SourceItem) + + fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) + + fun onDragHandleTouch(holder: RecyclerView.ViewHolder) + + fun onHeaderClick(header: SourceConfigItem.LocaleGroup) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt new file mode 100644 index 000000000..896c92780 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt @@ -0,0 +1,114 @@ +package org.koitharu.kotatsu.settings.sources.auth + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import android.view.MenuItem +import android.widget.Toast +import androidx.core.graphics.Insets +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.base.ui.BaseActivity +import org.koitharu.kotatsu.browser.BrowserCallback +import org.koitharu.kotatsu.browser.BrowserClient +import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.parser.MangaRepositoryAuthProvider +import org.koitharu.kotatsu.databinding.ActivityBrowserBinding +import org.koitharu.kotatsu.utils.ext.mangaRepositoryOf + +class SourceAuthActivity : BaseActivity(), BrowserCallback { + + private lateinit var repository: MangaRepositoryAuthProvider + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityBrowserBinding.inflate(layoutInflater)) + val source = intent?.getParcelableExtra(EXTRA_SOURCE) + if (source == null) { + finish() + return + } + repository = mangaRepositoryOf(source) as? MangaRepositoryAuthProvider ?: run { + Toast.makeText( + this, + getString(R.string.auth_not_supported_by, source.title), + Toast.LENGTH_SHORT + ).show() + finishAfterTransition() + return + } + supportActionBar?.run { + setDisplayHomeAsUpEnabled(true) + setHomeAsUpIndicator(R.drawable.ic_cross) + } + with(binding.webView.settings) { + javaScriptEnabled = true + } + binding.webView.webViewClient = BrowserClient(this) + val url = repository.authUrl + onTitleChanged( + source.title, + getString(R.string.loading_) + ) + binding.webView.loadUrl(url) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + binding.webView.stopLoading() + finishAfterTransition() + true + } + else -> super.onOptionsItemSelected(item) + } + + override fun onBackPressed() { + if (binding.webView.canGoBack()) { + binding.webView.goBack() + } else { + super.onBackPressed() + } + } + + override fun onPause() { + binding.webView.onPause() + super.onPause() + } + + override fun onResume() { + super.onResume() + binding.webView.onResume() + } + + override fun onLoadingStateChanged(isLoading: Boolean) { + binding.progressBar.isVisible = isLoading + if (!isLoading && repository.isAuthorized()) { + Toast.makeText(this, R.string.auth_complete, Toast.LENGTH_SHORT).show() + finishAfterTransition() + } + } + + override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { + this.title = title + supportActionBar?.subtitle = subtitle + } + + override fun onWindowInsetsChanged(insets: Insets) { + binding.appbar.updatePadding(top = insets.top) + binding.webView.updatePadding(bottom = insets.bottom) + } + + companion object { + + private const val EXTRA_SOURCE = "source" + + fun newIntent(context: Context, source: MangaSource): Intent { + return Intent(context, SourceAuthActivity::class.java) + .putExtra(EXTRA_SOURCE, source as Parcelable) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt new file mode 100644 index 000000000..965ea1171 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt @@ -0,0 +1,72 @@ +package org.koitharu.kotatsu.settings.sources.model + +import androidx.annotation.StringRes +import org.koitharu.kotatsu.core.model.MangaSource + +sealed interface SourceConfigItem { + + class Header( + @StringRes val titleResId: Int, + ) : SourceConfigItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Header + return titleResId == other.titleResId + } + + override fun hashCode(): Int = titleResId + } + + class LocaleGroup( + val localeId: String?, + val title: String?, + val isExpanded: Boolean, + ) : SourceConfigItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocaleGroup + + if (localeId != other.localeId) return false + if (title != other.title) return false + if (isExpanded != other.isExpanded) return false + + return true + } + + override fun hashCode(): Int { + var result = localeId?.hashCode() ?: 0 + result = 31 * result + (title?.hashCode() ?: 0) + result = 31 * result + isExpanded.hashCode() + return result + } + } + + class SourceItem( + val source: MangaSource, + val isEnabled: Boolean, + ) : SourceConfigItem { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SourceItem + + if (source != other.source) return false + if (isEnabled != other.isEnabled) return false + + return true + } + + override fun hashCode(): Int { + var result = source.hashCode() + result = 31 * result + isEnabled.hashCode() + return result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt index 99fd2bcd1..57605b4d4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt @@ -12,20 +12,21 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener -import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.databinding.FragmentFeedBinding import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.utils.ext.getDisplayMessage +import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.progress.Progress class FeedFragment : BaseFragment(), PaginationScrollListener.Callback, OnListItemClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() private var feedAdapter: FeedAdapter? = null private var updateStatusSnackbar: Snackbar? = null @@ -97,7 +98,9 @@ class FeedFragment : BaseFragment(), PaginationScrollListen } override fun onWindowInsetsChanged(insets: Insets) { + val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top binding.recyclerView.updatePadding( + top = headerHeight, left = insets.left, right = insets.right, bottom = insets.bottom diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt new file mode 100644 index 000000000..5cb69e049 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/DeferredStateFlow.kt @@ -0,0 +1,22 @@ +package org.koitharu.kotatsu.utils + +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn + +class DeferredStateFlow( + private val stateFlow: StateFlow, + private val deferred: Deferred, +) : StateFlow by stateFlow, Deferred by deferred { + + suspend fun collectAndAwait(): R { + return coroutineScope { + val collectJob = launchIn(this) + val result = await() + collectJob.cancelAndJoin() + result + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt new file mode 100644 index 000000000..87f42f742 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/DownloadManagerHelper.kt @@ -0,0 +1,87 @@ +package org.koitharu.kotatsu.utils + +import android.app.DownloadManager +import android.app.DownloadManager.Request.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Build +import android.os.Environment +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.MangaPage +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.utils.ext.toFileNameSafe +import java.io.File +import kotlin.coroutines.resume + +class DownloadManagerHelper( + private val context: Context, + private val cookieJar: CookieJar, +) { + + private val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + private val subDir = context.getString(R.string.app_name).toFileNameSafe() + + fun downloadPage(page: MangaPage, fullUrl: String): Long { + val uri = fullUrl.toUri() + val cookies = cookieJar.loadForRequest(fullUrl.toHttpUrl()) + val dest = subDir + File.separator + uri.lastPathSegment + val request = DownloadManager.Request(uri) + .addRequestHeader(CommonHeaders.REFERER, page.referer) + .addRequestHeader(CommonHeaders.COOKIE, cookieHeader(cookies)) + .setAllowedOverMetered(true) + .setAllowedNetworkTypes(NETWORK_WIFI or NETWORK_MOBILE) + .setNotificationVisibility(VISIBILITY_VISIBLE) + .setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, dest) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + @Suppress("DEPRECATION") + request.allowScanningByMediaScanner() + } + return manager.enqueue(request) + } + + suspend fun awaitDownload(id: Long): Uri { + getUriForDownloadedFile(id)?.let { return it } // fast path + suspendCancellableCoroutine { cont -> + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if ( + intent?.action == DownloadManager.ACTION_DOWNLOAD_COMPLETE && + intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L) == id + ) { + context.unregisterReceiver(this) + cont.resume(Unit) + } + } + } + context.registerReceiver( + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + ) + cont.invokeOnCancellation { + context.unregisterReceiver(receiver) + } + } + return checkNotNull(getUriForDownloadedFile(id)) + } + + private suspend fun getUriForDownloadedFile(id: Long) = withContext(Dispatchers.IO) { + manager.getUriForDownloadedFile(id) + } + + private fun cookieHeader(cookies: List): String = buildString { + cookies.forEachIndexed { index, cookie -> + if (index > 0) append("; ") + append(cookie.name).append('=').append(cookie.value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt b/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt new file mode 100644 index 000000000..05af51f10 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/JobStateFlow.kt @@ -0,0 +1,21 @@ +package org.koitharu.kotatsu.utils + +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn + +class JobStateFlow( + private val stateFlow: StateFlow, + private val job: Job, +) : StateFlow by stateFlow, Job by job { + + suspend fun collectAndJoin(): Unit { + coroutineScope { + val collectJob = launchIn(this) + join() + collectJob.cancelAndJoin() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt new file mode 100644 index 000000000..03dd423ea --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/LifecycleAwareServiceConnection.kt @@ -0,0 +1,49 @@ +package org.koitharu.kotatsu.utils + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class LifecycleAwareServiceConnection private constructor( + private val host: Activity, +) : ServiceConnection, DefaultLifecycleObserver { + + private val serviceStateFlow = MutableStateFlow(null) + + val service: StateFlow + get() = serviceStateFlow + + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + serviceStateFlow.value = service + } + + override fun onServiceDisconnected(name: ComponentName?) { + serviceStateFlow.value = null + } + + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + host.unbindService(this) + } + + companion object { + + fun bindService( + host: Activity, + lifecycleOwner: LifecycleOwner, + service: Intent, + flags: Int, + ): LifecycleAwareServiceConnection { + val connection = LifecycleAwareServiceConnection(host) + host.bindService(service, connection, flags) + lifecycleOwner.lifecycle.addObserver(connection) + return connection + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt deleted file mode 100644 index 7bf07f4c2..000000000 --- a/app/src/main/java/org/koitharu/kotatsu/utils/MediaStoreCompat.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.koitharu.kotatsu.utils - -import android.content.ContentResolver -import android.content.ContentValues -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import android.provider.OpenableColumns -import android.webkit.MimeTypeMap -import androidx.core.database.getStringOrNull -import org.koitharu.kotatsu.BuildConfig -import java.io.OutputStream - -class MediaStoreCompat(private val contentResolver: ContentResolver) { - - fun insertImage( - fileName: String, - block: (OutputStream) -> Unit - ): Uri? { - val name = fileName.substringBeforeLast('.') - val cv = ContentValues(7) - cv.put(MediaStore.Images.Media.DISPLAY_NAME, name) - cv.put(MediaStore.Images.Media.TITLE, name) - cv.put( - MediaStore.Images.Media.MIME_TYPE, - MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileName.substringAfterLast('.')) - ) - cv.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis()) - cv.put(MediaStore.Images.Media.DATE_MODIFIED, System.currentTimeMillis()) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - cv.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) - cv.put(MediaStore.Images.Media.IS_PENDING, 1) - } - var uri: Uri? = null - try { - uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cv) - contentResolver.openOutputStream(uri!!)?.use(block) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - cv.clear() - cv.put(MediaStore.Images.Media.IS_PENDING, 0) - contentResolver.update(uri, cv, null, null) - } - } catch (e: Exception) { - if (BuildConfig.DEBUG) { - e.printStackTrace() - } - uri?.let { - contentResolver.delete(it, null, null) - } - uri = null - } - return uri - } - - fun getName(uri: Uri): String? = - (if (uri.scheme == "content") { - contentResolver.query(uri, null, null, null, null)?.use { - if (it.moveToFirst()) { - it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) - } else { - null - } - } - } else { - null - }) ?: uri.path?.substringAfterLast('/') -} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt index 8e19bfc43..6c856f4d4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ScreenOrientationHelper.kt @@ -7,7 +7,7 @@ import android.database.ContentObserver import android.os.Handler import android.provider.Settings import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart @@ -38,7 +38,7 @@ class ScreenOrientationHelper(private val activity: Activity) { fun observeAutoOrientation() = callbackFlow { val observer = object : ContentObserver(Handler(activity.mainLooper)) { override fun onChange(selfChange: Boolean) { - sendBlocking(isAutoRotationEnabled) + trySendBlocking(isAutoRotationEnabled) } } activity.contentResolver.registerContentObserver( diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt b/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt new file mode 100644 index 000000000..214c934dd --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt @@ -0,0 +1,9 @@ +package org.koitharu.kotatsu.utils + +class WordSet(private vararg val words: String) { + + fun anyWordIn(dateString: String): Boolean = words.any { + dateString.contains(it, ignoreCase = true) + } + +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index b9cc851c4..87278b1d7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -1,8 +1,10 @@ package org.koitharu.kotatsu.utils.ext +import android.content.Context import android.net.ConnectivityManager import android.net.Network import android.net.NetworkRequest +import androidx.appcompat.app.AlertDialog import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -19,4 +21,8 @@ suspend fun ConnectivityManager.waitForNetwork(): Network { unregisterNetworkCallback(callback) } } +} + +inline fun buildAlertDialog(context: Context, block: AlertDialog.Builder.() -> Unit): AlertDialog { + return AlertDialog.Builder(context).apply(block).create() } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt index e31e3e20b..53cf3bdb2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoilExt.kt @@ -7,16 +7,16 @@ import coil.request.ErrorResult import coil.request.ImageRequest import coil.request.ImageResult import coil.request.SuccessResult +import com.google.android.material.progressindicator.BaseProgressIndicator import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.utils.progress.ImageRequestIndicatorListener -@Suppress("NOTHING_TO_INLINE") -inline fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context) +fun ImageView.newImageRequest(url: String) = ImageRequest.Builder(context) .data(url) .crossfade(true) .target(this) -@Suppress("NOTHING_TO_INLINE") -inline fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) +fun ImageRequest.Builder.enqueueWith(loader: ImageLoader) = loader.enqueue(build()) fun ImageResult.requireBitmap() = when (this) { is SuccessResult -> drawable.toBitmap() @@ -32,7 +32,10 @@ fun ImageResult.toBitmapOrNull() = when (this) { is ErrorResult -> null } -@Suppress("NOTHING_TO_INLINE") -inline fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder { +fun ImageRequest.Builder.referer(referer: String): ImageRequest.Builder { return setHeader(CommonHeaders.REFERER, referer) +} + +fun ImageRequest.Builder.indicator(indicator: BaseProgressIndicator<*>): ImageRequest.Builder { + return listener(ImageRequestIndicatorListener(indicator)) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 0b1f342d1..3aa96dc2a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext import androidx.collection.ArrayMap import androidx.collection.ArraySet import androidx.collection.LongSparseArray +import java.util.* fun MutableCollection.replaceWith(subject: Iterable) { clear() @@ -72,4 +73,12 @@ fun Collection.isDistinctBy(selector: (T) -> K): Boolean { } } return set.size == size +} + +fun MutableList.move(sourceIndex: Int, targetIndex: Int) { + if (sourceIndex <= targetIndex) { + Collections.rotate(subList(sourceIndex, targetIndex + 1), -1) + } else { + Collections.rotate(subList(targetIndex, sourceIndex + 1), 1) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CookieJarExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CookieJarExt.kt new file mode 100644 index 000000000..d188cadbe --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CookieJarExt.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.utils.ext + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +private const val SCHEME_HTTPS = "https" + +fun CookieJar.insertCookies(domain: String, vararg cookies: String) { + val url = HttpUrl.Builder() + .scheme(SCHEME_HTTPS) + .host(domain) + .build() + saveFromResponse(url, cookies.mapNotNull { + Cookie.parse(url, it) + }) +} + +fun CookieJar.getCookies(domain: String): List { + val url = HttpUrl.Builder() + .scheme(SCHEME_HTTPS) + .host(domain) + .build() + return loadForRequest(url) +} + +fun CookieJar.copyCookies(oldDomain: String, newDomain: String, names: Array? = null) { + val url = HttpUrl.Builder() + .scheme(SCHEME_HTTPS) + .host(oldDomain) + var cookies = loadForRequest(url.build()) + if (names != null) { + cookies = cookies.filter { c -> c.name in names } + } + url.host(newDomain) + saveFromResponse(url.build(), cookies) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt index 248f0f158..adc5f6f0b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FileExt.kt @@ -1,10 +1,13 @@ package org.koitharu.kotatsu.utils.ext +import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Build import android.os.Environment import android.os.storage.StorageManager +import android.provider.OpenableColumns +import androidx.core.database.getStringOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R @@ -60,4 +63,19 @@ fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null suspend fun File.deleteAwait() = withContext(Dispatchers.IO) { delete() +} + +fun ContentResolver.resolveName(uri: Uri): String? { + val fallback = uri.lastPathSegment + if (uri.scheme != "content") { + return fallback + } + query(uri, null, null, null, null)?.use { + if (it.moveToFirst()) { + it.getStringOrNull(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name -> + return name + } + } + } + return fallback } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt index a3de68094..ef2f7a422 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.utils.ext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transform fun Flow.onFirst(action: suspend (T) -> Unit): Flow { var isFirstCall = true @@ -16,4 +17,10 @@ fun Flow.onFirst(action: suspend (T) -> Unit): Flow { inline fun Flow>.mapItems(crossinline transform: (T) -> R): Flow> { return map { list -> list.map(transform) } +} + +inline fun Flow.filterNotNull( + crossinline predicate: suspend (T) -> Boolean, +): Flow = transform { value -> + if (value != null && predicate(value)) return@transform emit(value) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt index 92dab70d6..069d685ff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt @@ -1,9 +1,12 @@ package org.koitharu.kotatsu.utils.ext +import android.content.Intent import android.os.Bundle import android.os.Parcelable import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.coroutineScope +import org.koitharu.kotatsu.utils.LifecycleAwareServiceConnection inline fun T.withArgs(size: Int, block: Bundle.() -> Unit): T { val b = Bundle(size) @@ -27,4 +30,10 @@ inline fun Fragment.parcelableArgument(name: String): Lazy { @Suppress("NOTHING_TO_INLINE") inline fun Fragment.stringArgument(name: String) = lazy(LazyThreadSafetyMode.NONE) { arguments?.getString(name) -} \ No newline at end of file +} + +fun Fragment.bindService( + lifecycleOwner: LifecycleOwner, + service: Intent, + flags: Int, +) = LifecycleAwareServiceConnection.bindService(requireActivity(), lifecycleOwner, service, flags) \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/IteratorExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IteratorExt.kt new file mode 100644 index 000000000..660bd7b2f --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/IteratorExt.kt @@ -0,0 +1,21 @@ +package org.koitharu.kotatsu.utils.ext + +fun Iterator.nextOrNull(): T? = if (hasNext()) next() else null + +fun Iterator.toList(): List { + if (!hasNext()) { + return emptyList() + } + val list = ArrayList() + while (hasNext()) list += next() + return list +} + +fun Iterator.toSet(): Set { + if (!hasNext()) { + return emptySet() + } + val list = LinkedHashSet() + while (hasNext()) list += next() + return list +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/JsonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/JsonExt.kt index 66f96a6e2..0efa24264 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/JsonExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/JsonExt.kt @@ -3,6 +3,10 @@ package org.koitharu.kotatsu.utils.ext import androidx.collection.ArraySet import org.json.JSONArray import org.json.JSONObject +import org.koitharu.kotatsu.utils.json.JSONIterator +import org.koitharu.kotatsu.utils.json.JSONStringIterator +import org.koitharu.kotatsu.utils.json.JSONValuesIterator +import kotlin.contracts.contract inline fun > JSONArray.mapTo( destination: C, @@ -16,10 +20,26 @@ inline fun > JSONArray.mapTo( return destination } +inline fun > JSONArray.mapNotNullTo( + destination: C, + block: (JSONObject) -> R? +): C { + val len = length() + for (i in 0 until len) { + val jo = getJSONObject(i) + destination.add(block(jo) ?: continue) + } + return destination +} + inline fun JSONArray.map(block: (JSONObject) -> T): List { return mapTo(ArrayList(length()), block) } +inline fun JSONArray.mapNotNull(block: (JSONObject) -> T?): List { + return mapNotNullTo(ArrayList(length()), block) +} + fun JSONArray.mapIndexed(block: (Int, JSONObject) -> T): List { val len = length() val result = ArrayList(len) @@ -38,18 +58,13 @@ fun JSONObject.getBooleanOrDefault(name: String, defaultValue: Boolean): Boolean it === JSONObject.NULL } as? Boolean ?: defaultValue -operator fun JSONArray.iterator(): Iterator = JSONIterator(this) - -private class JSONIterator(private val array: JSONArray) : Iterator { - - private val total = array.length() - private var index = 0 - - override fun hasNext() = index < total - 1 +fun JSONObject.getLongOrDefault(name: String, defaultValue: Long): Long = opt(name)?.takeUnless { + it === JSONObject.NULL +} as? Long ?: defaultValue - override fun next(): JSONObject = array.getJSONObject(index++) +operator fun JSONArray.iterator(): Iterator = JSONIterator(this) -} +fun JSONArray.stringIterator(): Iterator = JSONStringIterator(this) fun JSONArray.mapToSet(block: (JSONObject) -> T): Set { val len = length() @@ -59,4 +74,24 @@ fun JSONArray.mapToSet(block: (JSONObject) -> T): Set { result.add(block(jo)) } return result +} + +fun JSONObject.values(): Iterator = JSONValuesIterator(this) + +fun JSONArray.associateByKey(key: String): Map { + val destination = LinkedHashMap(length()) + repeat(length()) { i -> + val item = getJSONObject(i) + val keyValue = item.getString(key) + destination[keyValue] = item + } + return destination +} + +fun JSONArray?.isNullOrEmpty(): Boolean { + contract { + returns(false) implies (this@isNullOrEmpty != null) + } + + return this == null || this.length() == 0 } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleExt.kt index 1ad4cc003..00ff31c91 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LocaleExt.kt @@ -2,7 +2,6 @@ package org.koitharu.kotatsu.utils.ext import androidx.core.os.LocaleListCompat import java.util.* -import kotlin.collections.ArrayList fun LocaleListCompat.toList(): List { val list = ArrayList(size()) @@ -26,4 +25,8 @@ inline fun > LocaleListCompat.mapTo( inline fun LocaleListCompat.map(block: (Locale) -> T): List { return mapTo(ArrayList(size()), block) +} + +inline fun LocaleListCompat.mapToSet(block: (Locale) -> T): Set { + return mapTo(LinkedHashSet(size()), block) } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ParseExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ParseExt.kt index c6e46b05e..4da5a2cff 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ParseExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ParseExt.kt @@ -10,6 +10,7 @@ import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.nodes.Node import org.jsoup.select.Elements +import java.text.DateFormat fun Response.parseHtml(): Document { try { @@ -91,4 +92,18 @@ fun Element.relUrl(attributeKey: String): String { return attr.removePrefix(baseUrl.dropLast(1)) } -private val REGEX_URL_BASE = Regex("^[^/]{2,6}://[^/]+/", RegexOption.IGNORE_CASE) \ No newline at end of file +private val REGEX_URL_BASE = Regex("^[^/]{2,6}://[^/]+/", RegexOption.IGNORE_CASE) + +fun Element.css(property: String): String? { + val regex = Regex("${Regex.escape(property)}\\s*:\\s*[^;]+") + val css = attr("style").find(regex) ?: return null + return css.substringAfter(':').removeSuffix(';').trim() +} + +fun DateFormat.tryParse(str: String?): Long = if (str.isNullOrEmpty()) { + 0L +} else { + runCatching { + parse(str)?.time ?: 0L + }.getOrDefault(0L) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt index eeeb12866..29c3a67f0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PrimitiveExt.kt @@ -35,7 +35,7 @@ fun Float.toIntUp(): Int { infix fun Int.upBy(step: Int): Int { val mod = this % step - return if (mod == this) { + return if (mod == 0) { this } else { this - mod + step diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index cc6a7d295..380019019 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -48,6 +48,14 @@ fun String.toCamelCase(): String { return result.toString() } +fun String.toTitleCase(): String { + return replaceFirstChar { x -> x.uppercase() } +} + +fun String.toTitleCase(locale: Locale): String { + return replaceFirstChar { x -> x.uppercase(locale) } +} + fun String.transliterate(skipMissing: Boolean): String { val cyr = charArrayOf( 'а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п', @@ -114,7 +122,7 @@ fun ByteArray.byte2HexFormatted(): String { if (l > 2) { h = h.substring(l - 2, l) } - str.append(h.toUpperCase(Locale.ROOT)) + str.append(h.uppercase(Locale.ROOT)) if (i < size - 1) { str.append(':') } @@ -157,6 +165,13 @@ fun String.substringBetweenLast(from: String, to: String, fallbackValue: String fun String.find(regex: Regex) = regex.find(this)?.value +fun String.removeSuffix(suffix: Char): String { + if (lastOrNull() == suffix) { + return substring(0, length - 1) + } + return this +} + fun String.levenshteinDistance(other: String): Int { if (this == other) { return 0 @@ -193,4 +208,20 @@ fun String.levenshteinDistance(other: String): Int { } return cost[lhsLength - 1] +} + +inline fun StringBuilder.appendAll( + items: Iterable, + separator: CharSequence, + transform: (T) -> CharSequence = { it.toString() }, +) { + var isFirst = true + for (item in items) { + if (isFirst) { + isFirst = false + } else { + append(separator) + } + append(transform(item)) + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt index c73cff93f..2bd9d2e49 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt @@ -5,6 +5,7 @@ import android.graphics.Rect import android.view.LayoutInflater import android.view.Menu import android.view.View +import android.view.View.MeasureSpec import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import androidx.annotation.LayoutRes @@ -16,6 +17,7 @@ import androidx.drawerlayout.widget.DrawerLayout import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.progressindicator.BaseProgressIndicator import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder fun View.hideKeyboard() { @@ -52,7 +54,7 @@ var RecyclerView.firstItem: Int inline fun View.showPopupMenu( @MenuRes menuRes: Int, onPrepare: (Menu) -> Unit = {}, - onItemClick: PopupMenu.OnMenuItemClickListener + onItemClick: PopupMenu.OnMenuItemClickListener, ) { val menu = PopupMenu(context, this) menu.inflate(menuRes) @@ -131,6 +133,8 @@ fun View.resetTransformations() { translationZ = 0f scaleX = 1f scaleY = 1f + rotationX = 0f + rotationY = 0f } inline fun RecyclerView.doOnCurrentItemChanged(crossinline callback: (Int) -> Unit) { @@ -158,4 +162,42 @@ fun RecyclerView.findCenterViewPosition(): Int { inline fun RecyclerView.ViewHolder.getItem(): T? { return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T) +} + +fun BaseProgressIndicator<*>.setIndeterminateCompat(indeterminate: Boolean) { + if (isIndeterminate != indeterminate) { + if (indeterminate && visibility == View.VISIBLE) { + visibility = View.INVISIBLE + isIndeterminate = indeterminate + visibility = View.VISIBLE + } else { + isIndeterminate = indeterminate + } + } +} + +fun resolveAdjustedSize( + desiredSize: Int, + maxSize: Int, + measureSpec: Int, +): Int { + val specMode = MeasureSpec.getMode(measureSpec) + val specSize = MeasureSpec.getSize(measureSpec) + return when (specMode) { + MeasureSpec.UNSPECIFIED -> + // Parent says we can be as big as we want. Just don't be larger + // than max size imposed on ourselves. + desiredSize.coerceAtMost(maxSize) + MeasureSpec.AT_MOST -> + // Parent says we can be as big as we want, up to specSize. + // Don't be larger than specSize, and don't be larger than + // the max size imposed on ourselves. + desiredSize.coerceAtMost(specSize).coerceAtMost(maxSize) + MeasureSpec.EXACTLY -> + // No choice. Do what we are told. + specSize + else -> + // This should not happen + desiredSize + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONIterator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONIterator.kt new file mode 100644 index 000000000..f99b47c79 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONIterator.kt @@ -0,0 +1,14 @@ +package org.koitharu.kotatsu.utils.json + +import org.json.JSONArray +import org.json.JSONObject + +class JSONIterator(private val array: JSONArray) : Iterator { + + private val total = array.length() + private var index = 0 + + override fun hasNext() = index < total - 1 + + override fun next(): JSONObject = array.getJSONObject(index++) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONStringIterator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONStringIterator.kt new file mode 100644 index 000000000..a13027da2 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/json/JSONStringIterator.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.utils.json + +import org.json.JSONArray + +class JSONStringIterator(private val array: JSONArray) : Iterator { + + private val total = array.length() + private var index = 0 + + override fun hasNext() = index < total - 1 + + override fun next(): String = array.getString(index++) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/json/JsonValuesIterator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/json/JsonValuesIterator.kt new file mode 100644 index 000000000..1bf833310 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/json/JsonValuesIterator.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.utils.json + +import org.json.JSONObject + +class JSONValuesIterator( + private val jo: JSONObject, +): Iterator { + + private val keyIterator = jo.keys() + + override fun hasNext(): Boolean = keyIterator.hasNext() + + override fun next(): Any { + val key = keyIterator.next() + return jo.get(key) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt new file mode 100644 index 000000000..eb38e1d32 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.utils.progress + +import coil.request.ImageRequest +import coil.request.ImageResult +import com.google.android.material.progressindicator.BaseProgressIndicator + +class ImageRequestIndicatorListener( + private val indicator: BaseProgressIndicator<*>, +) : ImageRequest.Listener { + + override fun onCancel(request: ImageRequest) = indicator.hide() + + override fun onError(request: ImageRequest, throwable: Throwable) = indicator.hide() + + override fun onStart(request: ImageRequest) = indicator.show() + + override fun onSuccess(request: ImageRequest, metadata: ImageResult.Metadata) = indicator.hide() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt b/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt index 793d0d7c3..16d12ebbd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.retry +import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.utils.ext.processLifecycleScope @@ -17,7 +18,7 @@ import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider class WidgetUpdater(private val context: Context) { fun subscribeToFavourites(repository: FavouritesRepository) { - repository.observeAll() + repository.observeAll(SortOrder.NEWEST) .onEach { updateWidget(ShelfWidgetProvider::class.java) } .retry { error -> error !is CancellationException } .launchIn(processLifecycleScope) diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt index 1946fb0dd..8acc69b8b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/shelf/ShelfConfigActivity.kt @@ -27,9 +27,10 @@ import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.widget.shelf.adapter.CategorySelectAdapter import org.koitharu.kotatsu.widget.shelf.model.CategoryItem -class ShelfConfigActivity : BaseActivity(), OnListItemClickListener { +class ShelfConfigActivity : BaseActivity(), + OnListItemClickListener { - private val viewModel by viewModel(mode = LazyThreadSafetyMode.NONE) + private val viewModel by viewModel() private lateinit var adapter: CategorySelectAdapter private lateinit var config: AppWidgetConfig diff --git a/app/src/main/res/drawable-hdpi/ic_totoro.webp b/app/src/main/res/drawable-hdpi/ic_totoro.webp deleted file mode 100644 index 0430efcfc..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_totoro.webp and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_totoro.webp b/app/src/main/res/drawable-mdpi/ic_totoro.webp deleted file mode 100644 index bfc0242b2..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_totoro.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_totoro.webp b/app/src/main/res/drawable-xhdpi/ic_totoro.webp deleted file mode 100644 index 2b0dde6ca..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_totoro.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_totoro.webp b/app/src/main/res/drawable-xxhdpi/ic_totoro.webp deleted file mode 100644 index 84698c144..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_totoro.webp and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_totoro.webp b/app/src/main/res/drawable-xxxhdpi/ic_totoro.webp deleted file mode 100644 index 4193ce397..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_totoro.webp and /dev/null differ diff --git a/app/src/main/res/drawable/fading_snackbar_background.xml b/app/src/main/res/drawable/fading_snackbar_background.xml new file mode 100644 index 000000000..b439322e0 --- /dev/null +++ b/app/src/main/res/drawable/fading_snackbar_background.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_copyleft.xml b/app/src/main/res/drawable/ic_copyleft.xml new file mode 100644 index 000000000..0227f2d77 --- /dev/null +++ b/app/src/main/res/drawable/ic_copyleft.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_expand_collapse.xml b/app/src/main/res/drawable/ic_expand_collapse.xml new file mode 100644 index 000000000..a5848a628 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_collapse.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_expand_less.xml b/app/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 000000000..8b5d44e5f --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 000000000..bc6e8295b --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_state_finished.xml b/app/src/main/res/drawable/ic_state_finished.xml new file mode 100644 index 000000000..84c2e8961 --- /dev/null +++ b/app/src/main/res/drawable/ic_state_finished.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_state_ongoing.xml b/app/src/main/res/drawable/ic_state_ongoing.xml new file mode 100644 index 000000000..4b642a0d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_state_ongoing.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_totoro.xml b/app/src/main/res/drawable/ic_totoro.xml new file mode 100644 index 000000000..988727dc6 --- /dev/null +++ b/app/src/main/res/drawable/ic_totoro.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/tabs_background.xml b/app/src/main/res/drawable/tabs_background.xml index d9df3de10..9743b040f 100644 --- a/app/src/main/res/drawable/tabs_background.xml +++ b/app/src/main/res/drawable/tabs_background.xml @@ -3,7 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android"> - + @@ -36,7 +37,7 @@ @@ -80,14 +81,31 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:background="@drawable/list_selector" android:requiresFadingEdge="horizontal" android:textColor="?colorAccent" android:textStyle="bold" app:layout_constraintEnd_toEndOf="@id/textView_title" + app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toStartOf="@id/textView_title" app:layout_constraintTop_toBottomOf="@id/textView_subtitle" + app:layout_constraintWidth_default="wrap" tools:text="@tools:sample/full_names" /> + + + app:layout_constraintTop_toBottomOf="@+id/textView_state"> - diff --git a/app/src/main/res/layout-w600dp-port/fragment_details.xml b/app/src/main/res/layout-w600dp-port/fragment_details.xml index 107c5c57f..25b0b326d 100644 --- a/app/src/main/res/layout-w600dp-port/fragment_details.xml +++ b/app/src/main/res/layout-w600dp-port/fragment_details.xml @@ -5,6 +5,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" android:scrollbars="vertical" app:layout_behavior="@string/appbar_scrolling_view_behavior"> @@ -36,7 +37,7 @@ @@ -81,14 +82,31 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="4dp" + android:background="@drawable/list_selector" android:requiresFadingEdge="horizontal" android:textColor="?colorAccent" android:textStyle="bold" app:layout_constraintEnd_toEndOf="@id/textView_title" + app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toStartOf="@id/textView_title" app:layout_constraintTop_toBottomOf="@id/textView_subtitle" + app:layout_constraintWidth_default="wrap" tools:text="@tools:sample/full_names" /> + + + app:layout_constraintTop_toBottomOf="@+id/textView_state"> - diff --git a/app/src/main/res/layout-w600dp/activity_details.xml b/app/src/main/res/layout-w600dp/activity_details.xml index af351d3aa..6e89585c7 100644 --- a/app/src/main/res/layout-w600dp/activity_details.xml +++ b/app/src/main/res/layout-w600dp/activity_details.xml @@ -3,6 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/coordinator" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".details.ui.DetailsActivity"> @@ -41,4 +42,11 @@ android:layout_height="match_parent" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/activity_details.xml index 0fa69b528..39f3f7d81 100644 --- a/app/src/main/res/layout/activity_details.xml +++ b/app/src/main/res/layout/activity_details.xml @@ -3,6 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/coordinator" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".details.ui.DetailsActivity"> @@ -38,4 +39,11 @@ android:layout_height="match_parent" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_downloads.xml b/app/src/main/res/layout/activity_downloads.xml new file mode 100644 index 000000000..65096b931 --- /dev/null +++ b/app/src/main/res/layout/activity_downloads.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_image.xml b/app/src/main/res/layout/activity_image.xml new file mode 100644 index 000000000..251ff1c27 --- /dev/null +++ b/app/src/main/res/layout/activity_image.xml @@ -0,0 +1,26 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 79a2eeb48..64e125d8d 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -12,11 +12,19 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + - + android:layout_height="match_parent" + android:background="@null" + android:gravity="center_vertical" + android:hint="@string/search_manga" + android:imeOptions="actionSearch" + android:importantForAutofill="no" + android:paddingBottom="1dp" + android:singleLine="true" /> @@ -57,12 +69,6 @@ - - diff --git a/app/src/main/res/layout/dialog_input.xml b/app/src/main/res/layout/dialog_input.xml index 895bacb61..812297a8f 100644 --- a/app/src/main/res/layout/dialog_input.xml +++ b/app/src/main/res/layout/dialog_input.xml @@ -11,7 +11,10 @@ @@ -21,7 +24,7 @@ android:layout_height="wrap_content" android:imeOptions="actionDone" android:singleLine="true" - tools:text="@tools:sample/lorem[2]" /> + tools:hint="@tools:sample/lorem[2]" /> diff --git a/app/src/main/res/layout/fading_snackbar_layout.xml b/app/src/main/res/layout/fading_snackbar_layout.xml new file mode 100644 index 000000000..4c75ce8b9 --- /dev/null +++ b/app/src/main/res/layout/fading_snackbar_layout.xml @@ -0,0 +1,61 @@ + + + + + + + + + +