Merge branch 'devel' into feature/suggestions

This commit is contained in:
Koitharu
2021-09-26 17:26:53 +03:00
314 changed files with 9169 additions and 2760 deletions

View File

@@ -23,6 +23,7 @@
</option> </option>
</AndroidXmlCodeStyleSettings> </AndroidXmlCodeStyleSettings>
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="CMake"> <codeStyleSettings language="CMake">

1
.idea/gradle.xml generated
View File

@@ -7,6 +7,7 @@
<option name="testRunner" value="GRADLE" /> <option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="Embedded JDK" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />

51
.idea/misc.xml generated
View File

@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="../../../../../../layout/custom_preview.xml" value="0.1" />
<entry key="../../../../../../opt/usr/android-sdk/platforms/android-30/data/res/drawable/list_divider_material.xml" value="0.28512820512820514" />
<entry key="../../../../../../opt/usr/android-sdk/platforms/android-30/data/res/layout/simple_dropdown_item_1line.xml" value="0.24739583333333334" />
<entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_alert_dialog_material.xml" value="0.25885416666666666" />
<entry key="../../../../.gradle/caches/transforms-3/0998d1b3fbd6b77213a827054a7dfcfd/transformed/appcompat-1.2.0/res/layout/abc_select_dialog_material.xml" value="0.25885416666666666" />
<entry key="../../../../.gradle/caches/transforms-3/688e95ad986d2d0286c79f787589b7cb/transformed/material-1.3.0/res/layout/mtrl_alert_dialog.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/drawable/ic_storage.xml" value="0.275" />
<entry key="app/src/main/res/drawable/ic_suggestion.xml" value="0.275" />
<entry key="app/src/main/res/drawable/tab_indicator.xml" value="0.28512820512820514" />
<entry key="app/src/main/res/drawable/tabs_background.xml" value="0.28512820512820514" />
<entry key="app/src/main/res/layout-w600dp/activity_details.xml" value="0.18072916666666666" />
<entry key="app/src/main/res/layout-w600dp/fragment_details.xml" value="0.14583333333333334" />
<entry key="app/src/main/res/layout-w600dp/fragment_list.xml" value="0.14635416666666667" />
<entry key="app/src/main/res/layout/activity_protect.xml" value="0.26927083333333335" />
<entry key="app/src/main/res/layout/activity_setup_protect.xml" value="0.26927083333333335" />
<entry key="app/src/main/res/layout/dialog_favorite_categories.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/dialog_list_mode.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/fragment_chapters.xml" value="0.24739583333333334" />
<entry key="app/src/main/res/layout/fragment_details.xml" value="0.26145833333333335" />
<entry key="app/src/main/res/layout/fragment_favourites.xml" value="0.26296296296296295" />
<entry key="app/src/main/res/layout/fragment_feed.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/fragment_list.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_branch.xml" value="0.24739583333333334" />
<entry key="app/src/main/res/layout/item_branch_dropdown.xml" value="0.25743589743589745" />
<entry key="app/src/main/res/layout/item_category_checkable.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_manga_grid.xml" value="0.26042632066728455" />
<entry key="app/src/main/res/layout/item_manga_list_details.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_page_thumb.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_page_webtoon.xml" value="0.13095238095238096" />
<entry key="app/src/main/res/layout/item_recent.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/layout/item_source_config.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/layout/sheet_pages.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/menu/nav_drawer.xml" value="0.25885416666666666" />
<entry key="app/src/main/res/menu/opt_protect.xml" value="0.26927083333333335" />
<entry key="app/src/main/res/menu/popup_category.xml" value="0.2601851851851852" />
<entry key="app/src/main/res/xml/pref_main.xml" value="0.26927083333333335" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@@ -2,7 +2,7 @@
Kotatsu is a free and open source manga reader for Android. 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 ### Download

View File

@@ -13,8 +13,10 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 30
versionCode 364 versionCode 369
versionName '1.0.1' versionName '2.0-b1'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
kapt { kapt {
arguments { arguments {
@@ -40,6 +42,9 @@ android {
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
lintOptions { lintOptions {
disable 'MissingTranslation' disable 'MissingTranslation'
abortOnError false abortOnError false
@@ -61,24 +66,24 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'androidx.core:core-ktx:1.5.0-rc01' implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.activity:activity-ktx:1.2.2' implementation 'androidx.activity:activity-ktx:1.3.1'
implementation 'androidx.fragment:fragment-ktx:1.3.3' implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-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-livedata-ktx:2.3.1'
implementation 'androidx.lifecycle:lifecycle-service:2.3.1' implementation 'androidx.lifecycle:lifecycle-service:2.3.1'
implementation 'androidx.lifecycle:lifecycle-process:2.3.1' implementation 'androidx.lifecycle:lifecycle-process:2.3.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.2.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.preference:preference-ktx:1.1.1'
implementation 'androidx.work:work-runtime-ktx:2.5.0' implementation 'androidx.work:work-runtime-ktx:2.6.0'
implementation 'com.google.android.material:material:1.3.0' implementation 'com.google.android.material:material:1.4.0'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.3.1'
@@ -88,20 +93,28 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.9.1' implementation 'com.squareup.okhttp3:okhttp:4.9.1'
implementation 'com.squareup.okio:okio:2.10.0' implementation 'com.squareup.okio:okio:2.10.0'
implementation 'org.jsoup:jsoup:1.13.1' implementation 'org.jsoup:jsoup:1.14.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.0' 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-viewbinding:4.3.0'
implementation 'io.insert-koin:koin-android:3.0.1' implementation 'io.insert-koin:koin-android:3.1.2'
implementation 'io.insert-koin:koin-android-ext:3.0.1' implementation 'io.coil-kt:coil-base:1.3.2'
implementation 'io.coil-kt:coil-base:1.1.1'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.2' implementation 'com.github.solkin:disk-lru-cache:1.3'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.7'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20201115' testImplementation 'com.google.truth:truth:1.1.3'
testImplementation 'io.insert-koin:koin-test-junit4:3.0.1' testImplementation 'org.json:json:20210307'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2'
testImplementation 'io.insert-koin:koin-test-junit4:3.1.2'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'androidx.room:room-testing:2.3.0'
androidTestImplementation 'com.google.truth:truth:1.1.3'
} }

View File

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

View File

@@ -23,7 +23,9 @@
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<activity android:name="org.koitharu.kotatsu.main.ui.MainActivity"> <activity
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -32,12 +34,16 @@
android:name="android.app.default_searchable" android:name="android.app.default_searchable"
android:value=".ui.search.SearchActivity" /> android:value=".ui.search.SearchActivity" />
</activity> </activity>
<activity android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"> <activity
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="${applicationId}.action.VIEW_MANGA" /> <action android:name="${applicationId}.action.VIEW_MANGA" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"> <activity
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="${applicationId}.action.READ_MANGA" /> <action android:name="${applicationId}.action.READ_MANGA" />
</intent-filter> </intent-filter>
@@ -50,13 +56,19 @@
android:label="@string/settings" /> android:label="@string/settings" />
<activity <activity
android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity" android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity"
android:exported="true"
android:label="@string/settings"> android:label="@string/settings">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" /> <action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name="org.koitharu.kotatsu.browser.BrowserActivity" /> <activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.core.ui.CrashActivity" android:name="org.koitharu.kotatsu.core.ui.CrashActivity"
android:label="@string/error_occurred" android:label="@string/error_occurred"
@@ -68,6 +80,7 @@
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity" android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true"
android:label="@string/manga_shelf"> android:label="@string/manga_shelf">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
@@ -83,9 +96,12 @@
<activity <activity
android:name=".settings.protect.ProtectSetupActivity" android:name=".settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads" />
<service <service
android:name="org.koitharu.kotatsu.download.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"

View File

@@ -9,13 +9,16 @@ import org.koitharu.kotatsu.utils.ext.await
open class MangaLoaderContext( open class MangaLoaderContext(
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
private val cookieJar: CookieJar val cookieJar: CookieJar
) : KoinComponent { ) : KoinComponent {
suspend fun httpGet(url: String): Response { suspend fun httpGet(url: String, headers: Headers? = null): Response {
val request = Request.Builder() val request = Request.Builder()
.get() .get()
.url(url) .url(url)
if (headers != null) {
request.headers(headers)
}
return okHttp.newCall(request.build()).await() return okHttp.newCall(request.build()).await()
} }
@@ -54,16 +57,6 @@ open class MangaLoaderContext(
open fun getSettings(source: MangaSource) = SourceSettings(get(), source) 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)
})
}
private companion object { private companion object {
private const val SCHEME_HTTP = "http" private const val SCHEME_HTTP = "http"

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.base.ui package org.koitharu.kotatsu.base.ui
import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
@@ -11,16 +12,16 @@ import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.* import androidx.core.view.*
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.getThemeColor
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindowInsetsListener { abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindowInsetsListener {
@@ -35,7 +36,7 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
if (get<AppSettings>().isAmoledTheme) { if (get<AppSettings>().isAmoledTheme) {
setTheme(R.style.AppTheme_Amoled) setTheme(R.style.AppTheme_AMOLED)
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -56,8 +57,19 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
protected fun setContentView(binding: B) { protected fun setContentView(binding: B) {
this.binding = binding this.binding = binding
super.setContentView(binding.root) super.setContentView(binding.root)
(binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar) val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
toolbar?.let(this::setSupportActionBar)
ViewCompat.setOnApplyWindowInsetsListener(binding.root, this) ViewCompat.setOnApplyWindowInsetsListener(binding.root, this)
val toolbarParams = (binding.root.findViewById<View>(R.id.toolbar_card) ?: toolbar)
?.layoutParams as? AppBarLayout.LayoutParams
if (toolbarParams != null) {
if (get<AppSettings>().isToolbarHideWhenScrolling) {
toolbarParams.scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP
} else {
toolbarParams.scrollFlags = SCROLL_FLAG_NO_SCROLL
}
}
} }
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
@@ -90,6 +102,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
(findViewById<View>(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar) (findViewById<View>(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<AppSettings>().isAmoledTheme
}
override fun onSupportActionModeStarted(mode: ActionMode) { override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode) super.onSupportActionModeStarted(mode)
val insets = ViewCompat.getRootWindowInsets(binding.root) val insets = ViewCompat.getRootWindowInsets(binding.root)
@@ -98,12 +116,6 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), OnApplyWindo
view?.updateLayoutParams<ViewGroup.MarginLayoutParams> { view?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top topMargin = insets.top
} }
window?.statusBarColor = ContextCompat.getColor(this, R.color.grey_dark)
}
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
window?.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
} }
override fun onBackPressed() { override fun onBackPressed() {

View File

@@ -38,6 +38,7 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(), OnApplyWindowInsetsLi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
lastInsets = Insets.NONE
ViewCompat.setOnApplyWindowInsetsListener(view, this) ViewCompat.setOnApplyWindowInsetsListener(view, this)
} }

View File

@@ -37,7 +37,7 @@ class SectionItemDecoration(
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state) super.onDrawOver(c, parent, state)
val textView = headerView ?: parent.inflate<TextView>(R.layout.item_header).also { val textView = headerView ?: parent.inflate<TextView>(R.layout.item_filter_header).also {
headerView = it headerView = it
} }
fixLayoutSize(textView, parent) fixLayoutSize(textView, parent)

View File

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

View File

@@ -4,16 +4,17 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View.OnClickListener import android.view.View.OnClickListener
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.R
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.R
class ChipsView @JvmOverloads constructor( class ChipsView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.chipGroupStyle defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle
) : ChipGroup(context, attrs, defStyleAttr) { ) : ChipGroup(context, attrs, defStyleAttr) {
private var isLayoutSuppressedCompat = false private var isLayoutSuppressedCompat = false
@@ -21,12 +22,21 @@ class ChipsView @JvmOverloads constructor(
private var chipOnClickListener = OnClickListener { private var chipOnClickListener = OnClickListener {
onChipClickListener?.onChipClick(it as Chip, it.tag) onChipClickListener?.onChipClick(it as Chip, it.tag)
} }
private var chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
}
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
set(value) { set(value) {
field = value field = value
val isChipClickable = value != null val isChipClickable = value != null
children.forEach { it.isClickable = isChipClickable } 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() { override fun requestLayout() {
if (isLayoutSuppressedCompat) { if (isLayoutSuppressedCompat) {
@@ -36,15 +46,15 @@ class ChipsView @JvmOverloads constructor(
} }
} }
fun setChips(items: List<ChipModel>) { fun setChips(items: Collection<ChipModel>) {
suppressLayoutCompat(true) suppressLayoutCompat(true)
try { try {
for ((i, model) in items.withIndex()) { for ((i, model) in items.withIndex()) {
val chip = getChildAt(i) as Chip? ?: addChip() val chip = getChildAt(i) as Chip? ?: addChip()
bindChip(chip, model) bindChip(chip, model)
} }
for (i in items.size until childCount) { if (childCount > items.size) {
removeViewAt(i) removeViews(items.size, childCount - items.size)
} }
} finally { } finally {
suppressLayoutCompat(false) suppressLayoutCompat(false)
@@ -59,16 +69,19 @@ class ChipsView @JvmOverloads constructor(
chip.isCheckedIconVisible = true chip.isCheckedIconVisible = true
chip.setChipIconResource(model.icon) chip.setChipIconResource(model.icon)
} }
chip.isClickable = onChipClickListener != null
chip.tag = model.data chip.tag = model.data
} }
private fun addChip(): Chip { private fun addChip(): Chip {
val chip = Chip(context) val chip = Chip(context)
chip.setTextColor(context.getThemeColor(android.R.attr.textColorPrimary)) val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.isCloseIconVisible = false chip.setChipDrawable(drawable)
chip.setTextColor(ContextCompat.getColor(context, R.color.color_primary))
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener) chip.setOnClickListener(chipOnClickListener)
chip.isClickable = onChipClickListener != null
addView(chip) addView(chip)
return chip return chip
} }
@@ -93,4 +106,9 @@ class ChipsView @JvmOverloads constructor(
fun onChipClick(chip: Chip, data: Any?) fun onChipClick(chip: Chip, data: Any?)
} }
fun interface OnChipCloseClickListener {
fun onChipCloseClick(chip: Chip, data: Any?)
}
} }

View File

@@ -41,7 +41,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_browser, menu) menuInflater.inflate(R.menu.opt_browser, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }

View File

@@ -1,10 +1,8 @@
package org.koitharu.kotatsu.browser package org.koitharu.kotatsu.browser
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebResourceResponse
import android.webkit.WebView import android.webkit.WebView
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import org.koitharu.kotatsu.core.network.WebViewClientCompat import org.koitharu.kotatsu.core.network.WebViewClientCompat
@@ -27,19 +25,4 @@ class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat
super.onPageCommitVisible(view, url) super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), 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()
}
} }

View File

@@ -3,11 +3,11 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.core.network.CookieJar import org.koitharu.kotatsu.core.network.AndroidCookieJar
import org.koitharu.kotatsu.core.network.WebViewClientCompat import org.koitharu.kotatsu.core.network.WebViewClientCompat
class CloudFlareClient( class CloudFlareClient(
private val cookieJar: CookieJar, private val cookieJar: AndroidCookieJar,
private val callback: CloudFlareCallback, private val callback: CloudFlareCallback,
private val targetUrl: String private val targetUrl: String
) : WebViewClientCompat() { ) : WebViewClientCompat() {

View File

@@ -118,6 +118,7 @@ class BackupRepository(private val db: MangaDatabase) {
jo.put("created_at", createdAt) jo.put("created_at", createdAt)
jo.put("sort_key", sortKey) jo.put("sort_key", sortKey)
jo.put("title", title) jo.put("title", title)
jo.put("order", order)
return jo return jo
} }

View File

@@ -5,6 +5,7 @@ import org.json.JSONObject
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
@@ -101,7 +102,8 @@ class RestoreRepository(private val db: MangaDatabase) {
categoryId = json.getInt("category_id"), categoryId = json.getInt("category_id"),
createdAt = json.getLong("created_at"), createdAt = json.getLong("created_at"),
sortKey = json.getInt("sort_key"), 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( private fun parseFavourite(json: JSONObject) = FavouriteEntity(

View File

@@ -20,6 +20,7 @@ val databaseModule
Migration5To6(), Migration5To6(),
Migration6To7(), Migration6To7(),
Migration7To8(), Migration7To8(),
Migration8To9(),
).addCallback( ).addCallback(
DatabasePrePopulateCallback(androidContext().resources) DatabasePrePopulateCallback(androidContext().resources)
).build() ).build()

View File

@@ -4,13 +4,14 @@ import android.content.res.Resources
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.SortOrder
class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() { class DatabasePrePopulateCallback(private val resources: Resources) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) { override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL( db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title) VALUES (?,?,?)", "INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later)) arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name)
) )
} }
} }

View File

@@ -18,7 +18,7 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class
], version = 8 ], version = 9
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {

View File

@@ -13,6 +13,14 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE manga_id = :id") @Query("SELECT * FROM manga WHERE manga_id = :id")
abstract suspend fun find(id: Long): MangaWithTags? abstract suspend fun find(id: Long): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE title LIKE :query OR alt_title LIKE :query LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source LIMIT :limit")
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(manga: MangaEntity): Long abstract suspend fun insert(manga: MangaEntity): Long

View File

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

View File

@@ -9,5 +9,6 @@ data class FavouriteCategory(
val id: Long, val id: Long,
val title: String, val title: String,
val sortKey: Int, val sortKey: Int,
val createdAt: Date val order: SortOrder,
val createdAt: Date,
) : Parcelable ) : Parcelable

View File

@@ -5,6 +5,6 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class MangaFilter( data class MangaFilter(
val sortOrder: SortOrder, val sortOrder: SortOrder?,
val tag: MangaTag? val tags: Set<MangaTag>,
) : Parcelable ) : Parcelable

View File

@@ -30,7 +30,17 @@ enum class MangaSource(
// NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java), // NUDEMOON("Nude-Moon", "ru", NudeMoonRepository::class.java),
MANGAREAD("MangaRead", "en", MangareadRepository::class.java), MANGAREAD("MangaRead", "en", MangareadRepository::class.java),
REMANGA("Remanga", "ru", RemangaRepository::class.java), REMANGA("Remanga", "ru", RemangaRepository::class.java),
HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java); HENTAILIB("HentaiLib", "ru", HentaiLibRepository::class.java),
ANIBEL("Anibel", "be", AnibelRepository::class.java),
NINEMANGA_EN("NineManga English", "en", NineMangaRepository.English::class.java),
NINEMANGA_ES("NineManga Español", "es", NineMangaRepository.Spanish::class.java),
NINEMANGA_RU("NineManga Русский", "ru", NineMangaRepository.Russian::class.java),
NINEMANGA_DE("NineManga Deutsch", "de", NineMangaRepository.Deutsch::class.java),
NINEMANGA_IT("NineManga Italiano", "it", NineMangaRepository.Italiano::class.java),
NINEMANGA_BR("NineManga Brasil", "pt", NineMangaRepository.Brazil::class.java),
NINEMANGA_FR("NineManga Français", "fr", NineMangaRepository.Francais::class.java),
EXHENTAI("ExHentai", null, ExHentaiRepository::class.java)
;
@get:Throws(NoBeanDefFoundException::class) @get:Throws(NoBeanDefFoundException::class)
@Deprecated("") @Deprecated("")

View File

@@ -7,7 +7,7 @@ import okhttp3.HttpUrl
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class CookieJar : CookieJar { class AndroidCookieJar : CookieJar {
private val cookieManager = CookieManager.getInstance() private val cookieManager = CookieManager.getInstance()
@@ -28,10 +28,6 @@ class CookieJar : CookieJar {
} }
} }
fun clearAsync() {
cookieManager.removeAllCookies(null)
}
suspend fun clear() = suspendCoroutine<Boolean> { continuation -> suspend fun clear() = suspendCoroutine<Boolean> { continuation ->
cookieManager.removeAllCookies(continuation::resume) cookieManager.removeAllCookies(continuation::resume)
} }

View File

@@ -0,0 +1,59 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.io.IOException
import java.nio.charset.StandardCharsets
class CurlLoggingInterceptor(
private val extraCurlOptions: String? = null,
) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request: Request = chain.request()
var compressed = false
val curlCmd = StringBuilder("curl")
if (extraCurlOptions != null) {
curlCmd.append(" ").append(extraCurlOptions)
}
curlCmd.append(" -X ").append(request.method)
val headers = request.headers
var i = 0
val count = headers.size
while (i < count) {
val name = headers.name(i)
val value = headers.value(i)
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
ignoreCase = true)
) {
compressed = true
}
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
i++
}
val requestBody = request.body
if (requestBody != null) {
val buffer = Buffer()
requestBody.writeTo(buffer)
val contentType = requestBody.contentType()
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
curlCmd.append(" --data $'")
.append(buffer.readString(charset).replace("\n", "\\n"))
.append("'")
}
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
Log.d(TAG, "╭--- cURL (" + request.url + ")")
Log.d(TAG, curlCmd.toString())
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
return chain.proceed(request)
}
private companion object {
const val TAG = "CURL"
}
}

View File

@@ -6,12 +6,13 @@ import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.bind import org.koin.dsl.bind
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.CacheUtils import org.koitharu.kotatsu.utils.CacheUtils
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
val networkModule val networkModule
get() = module { get() = module {
single { CookieJar() } bind CookieJar::class single { AndroidCookieJar() } bind CookieJar::class
single(named(CacheUtils.QUALIFIER_HTTP)) { CacheUtils.createHttpCache(androidContext()) } single(named(CacheUtils.QUALIFIER_HTTP)) { CacheUtils.createHttpCache(androidContext()) }
single { single {
OkHttpClient.Builder().apply { OkHttpClient.Builder().apply {
@@ -22,6 +23,9 @@ val networkModule
cache(get(named(CacheUtils.QUALIFIER_HTTP))) cache(get(named(CacheUtils.QUALIFIER_HTTP)))
addInterceptor(UserAgentInterceptor()) addInterceptor(UserAgentInterceptor())
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addNetworkInterceptor(CurlLoggingInterceptor())
}
}.build() }.build()
} }
} }

View File

@@ -35,6 +35,7 @@ class ShortcutsRepository(
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) return
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity) val shortcuts = historyRepository.getList(0, manager.maxShortcutCountPerActivity)
.filter { x -> x.title.isNotEmpty() }
.map { buildShortcutInfo(it).build().toShortcutInfo() } .map { buildShortcutInfo(it).build().toShortcutInfo() }
manager.dynamicShortcuts = shortcuts manager.dynamicShortcuts = shortcuts
} }

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.core.parser 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.* import org.koitharu.kotatsu.core.model.*
interface MangaRepository { interface MangaRepository {
val sortOrders: Set<SortOrder> val sortOrders: Set<SortOrder>
suspend fun getList( suspend fun getList2(
offset: Int, offset: Int,
query: String? = null, query: String? = null,
tags: Set<MangaTag>? = null,
sortOrder: SortOrder? = null, sortOrder: SortOrder? = null,
tag: MangaTag? = null
): List<Manga> ): List<Manga>
suspend fun getDetails(manga: Manga): Manga suspend fun getDetails(manga: Manga): Manga
@@ -20,4 +23,11 @@ interface MangaRepository {
suspend fun getPageUrl(page: MangaPage): String suspend fun getPageUrl(page: MangaPage): String
suspend fun getTags(): Set<MangaTag> suspend fun getTags(): Set<MangaTag>
companion object : KoinComponent {
operator fun invoke(source: MangaSource): MangaRepository {
return get(named(source))
}
}
} }

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.parser
interface MangaRepositoryAuthProvider {
val authUrl: String
fun isAuthorized(): Boolean
}

View File

@@ -24,4 +24,13 @@ val parserModule
factory<MangaRepository>(named(MangaSource.MANGAREAD)) { MangareadRepository(get()) } factory<MangaRepository>(named(MangaSource.MANGAREAD)) { MangareadRepository(get()) }
factory<MangaRepository>(named(MangaSource.REMANGA)) { RemangaRepository(get()) } factory<MangaRepository>(named(MangaSource.REMANGA)) { RemangaRepository(get()) }
factory<MangaRepository>(named(MangaSource.HENTAILIB)) { HentaiLibRepository(get()) } factory<MangaRepository>(named(MangaSource.HENTAILIB)) { HentaiLibRepository(get()) }
factory<MangaRepository>(named(MangaSource.ANIBEL)) { AnibelRepository(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_EN)) { NineMangaRepository.English(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_BR)) { NineMangaRepository.Brazil(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_DE)) { NineMangaRepository.Deutsch(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_ES)) { NineMangaRepository.Spanish(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_RU)) { NineMangaRepository.Russian(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_IT)) { NineMangaRepository.Italiano(get()) }
factory<MangaRepository>(named(MangaSource.NINEMANGA_FR)) { NineMangaRepository.Francais(get()) }
factory<MangaRepository>(named(MangaSource.EXHENTAI)) { ExHentaiRepository(get()) }
} }

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.MangaPage import org.koitharu.kotatsu.core.model.MangaPage
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
@@ -19,6 +20,9 @@ abstract class RemoteMangaRepository(
loaderContext.getSettings(source) loaderContext.getSettings(source)
} }
val title: String
get() = source.title
override val sortOrders: Set<SortOrder> get() = emptySet() override val sortOrders: Set<SortOrder> get() = emptySet()
override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain() override suspend fun getPageUrl(page: MangaPage): String = page.url.withDomain()
@@ -75,4 +79,8 @@ abstract class RemoteMangaRepository(
h = 31 * h + id h = 31 * h + id
return h return h
} }
protected fun parseFailed(message: String? = null): Nothing {
throw ParseException(message)
}
} }

View File

@@ -0,0 +1,178 @@
package org.koitharu.kotatsu.core.parser.site
import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.*
import java.util.*
class AnibelRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
override val source = MangaSource.ANIBEL
override val defaultDomain = "anibel.net"
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.NEWEST
)
override suspend fun getList2(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList()
}
val page = (offset / 12f).toIntUp().inc()
val link = when {
tags.isNullOrEmpty() -> "/manga?page=$page".withDomain()
else -> tags.joinToString(
prefix = "/manga?",
postfix = "&page=$page",
separator = "&",
) { tag -> "genre[]=${tag.key}" }.withDomain()
}
val doc = loaderContext.httpGet(link).parseHtml()
val root = doc.body().select("div.manga-block") ?: parseFailed("Cannot find root")
val items = root.select("div.anime-card")
return items.mapNotNull { card ->
val href = card.selectFirst("a")?.attr("href") ?: return@mapNotNull null
val status = card.select("tr")[2].text()
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
?.substringBeforeLast('[') ?: return@mapNotNull null
val titleParts = fullTitle.splitTwoParts('/')
Manga(
id = generateUid(href),
title = titleParts?.first?.trim() ?: fullTitle,
coverUrl = card.selectFirst("img")?.attr("data-src")
?.withDomain().orEmpty(),
altTitle = titleParts?.second?.trim(),
author = null,
rating = Manga.NO_RATING,
url = href,
publicUrl = href.withDomain(),
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
MangaTag(
title = x.text(),
key = x.attr("href").ifEmpty {
return@mapNotNull null
}.substringAfterLast("="),
source = source
)
},
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
else -> null
},
source = source
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(manga.publicUrl).parseHtml()
val root = doc.body().select("div.container") ?: parseFailed("Cannot find root")
return manga.copy(
description = root.select("div.manga-block.grid-12")[2].select("p").text(),
chapters = root.select("ul.series").flatMap { table ->
table.select("li")
}.map { it.selectFirst("a") }.mapIndexedNotNull { i, a ->
val href = a?.select("a")?.first()?.attr("href")
?.toRelativeUrl(getDomain()) ?: return@mapIndexedNotNull null
MangaChapter(
id = generateUid(href),
name = a.selectFirst("a")?.text().orEmpty(),
number = i + 1,
url = href,
source = source
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.withDomain()
val doc = loaderContext.httpGet(fullUrl).parseHtml()
val scripts = doc.select("script")
for (script in scripts) {
val data = script.html()
val pos = data.indexOf("dataSource")
if (pos == -1) {
continue
}
val 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
)
}
}
parseFailed("Pages list not found at ${chapter.url.withDomain()}")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/manga").parseHtml()
val root = doc.body().select("div#tabs-genres").select("ul#list.ul-three-colums")
return root.select("p.menu-tags.tupe").mapToSet { p ->
val a = p.selectFirst("a") ?: parseFailed("a is null")
MangaTag(
title = a.text().toCamelCase(),
key = a.attr("data-name"),
source = source
)
}
}
private suspend fun search(query: String): List<Manga> {
val domain = getDomain()
val doc = loaderContext.httpGet("https://$domain/search?q=$query").parseHtml()
val root = doc.body().select("div.manga-block").select("article.tab-2") ?: parseFailed("Cannot find root")
val items = root.select("div.anime-card")
return items.mapNotNull { card ->
val href = card.select("a").attr("href")
val status = card.select("tr")[2].text()
val fullTitle = card.selectFirst("h1.anime-card-title")?.text()
?.substringBeforeLast('[') ?: return@mapNotNull null
val titleParts = fullTitle.splitTwoParts('/')
Manga(
id = generateUid(href),
title = titleParts?.first?.trim() ?: fullTitle,
coverUrl = card.selectFirst("img")?.attr("src")
?.withDomain().orEmpty(),
altTitle = titleParts?.second?.trim(),
author = null,
rating = Manga.NO_RATING,
url = href,
publicUrl = href.withDomain(),
tags = card.select("p.tupe.tag").select("a").mapNotNullToSet tags@{ x ->
MangaTag(
title = x.text(),
key = x.attr("href").ifEmpty {
return@mapNotNull null
}.substringAfterLast("="),
source = source
)
},
state = when (status) {
"выпускаецца" -> MangaState.ONGOING
"завершанае" -> MangaState.FINISHED
else -> null
},
source = source
)
}
}
}

View File

@@ -17,11 +17,11 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
SortOrder.ALPHABETICAL SortOrder.ALPHABETICAL
) )
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
val domain = getDomain() val domain = getDomain()
val url = when { val url = when {
@@ -31,11 +31,15 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
} }
"https://$domain/?do=search&subaction=search&story=${query.urlEncoded()}" "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" else -> "https://$domain/${getSortKey(sortOrder)}?offset=$offset"
} }
val doc = loaderContext.httpGet(url).parseHtml() 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") ?: throw ParseException("Cannot find root")
return root.select("div.content_row").mapNotNull { row -> return root.select("div.content_row").mapNotNull { row ->
val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a") val a = row.selectFirst("div.manga_row1")?.selectFirst("h2")?.selectFirst("a")
@@ -78,7 +82,7 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
chapters = root.select("table.table_cha").flatMap { table -> chapters = root.select("table.table_cha").flatMap { table ->
table.select("div.manga2") table.select("div.manga2")
}.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a -> }.map { it.selectFirst("a") }.reversed().mapIndexedNotNull { i, a ->
val href = a.relUrl("href") val href = a?.relUrl("href") ?: return@mapIndexedNotNull null
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.text().trim(), name = a.text().trim(),
@@ -123,12 +127,12 @@ abstract class ChanRepository(loaderContext: MangaLoaderContext) : RemoteMangaRe
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val domain = getDomain() val domain = getDomain()
val doc = loaderContext.httpGet("https://$domain/catalog").parseHtml() val doc = loaderContext.httpGet("https://$domain/catalog").parseHtml()
val root = doc.body().selectFirst("div.main_fon").getElementById("side") val root = doc.body().selectFirst("div.main_fon")?.getElementById("side")
.select("ul").last() ?.select("ul")?.last() ?: throw ParseException("Cannot find root")
return root.select("li.sidetag").mapToSet { li -> return root.select("li.sidetag").mapToSet { li ->
val a = li.children().last() val a = li.children().last() ?: throw ParseException("a is null")
MangaTag( MangaTag(
title = a.text().capitalize(), title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source source = source
) )

View File

@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.util.* import java.util.*
import kotlin.collections.ArrayList
class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
@@ -21,11 +20,11 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
SortOrder.ALPHABETICAL SortOrder.ALPHABETICAL
) )
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
if (query != null && offset != 0) { if (query != null && offset != 0) {
return emptyList() return emptyList()
@@ -38,9 +37,9 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
append(getSortKey(sortOrder)) append(getSortKey(sortOrder))
append("&page=") append("&page=")
append((offset / 20) + 1) append((offset / 20) + 1)
if (tag != null) { if (!tags.isNullOrEmpty()) {
append("&genres=") append("&genres=")
append(tag.key) appendAll(tags, ",") { it.key }
} }
if (query != null) { if (query != null) {
append("&search=") append("&search=")
@@ -122,12 +121,13 @@ class DesuMeRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepositor
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml() 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 { return root.select("li").mapToSet {
MangaTag( MangaTag(
source = source, source = source,
key = it.selectFirst("input").attr("data-genre"), key = it.selectFirst("input")?.attr("data-genre") ?: parseFailed(),
title = it.selectFirst("label").text() title = it.selectFirst("label")?.text() ?: parseFailed()
) )
} }
} }

View File

@@ -0,0 +1,258 @@
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<MangaTag>?,
sortOrder: SortOrder?,
): List<Manga> {
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("<br>") { tr ->
val (tc, td) = tr.children()
val subtags = td.select("a").joinToString { it.html() }
"<b>${tc.html()}</b> $subtags"
},
chapters = tabs?.select("a")?.findLast { a ->
a.text().toIntOrNull() != null
}?.let { a ->
val count = a.text().toInt()
val chapters = ArrayList<MangaChapter>(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,
branch = null,
source = source,
)
}
chapters
},
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
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<MangaTag> {
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>): 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()
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser.site package org.koitharu.kotatsu.core.parser.site
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response
import org.koitharu.kotatsu.base.domain.MangaLoaderContext import org.koitharu.kotatsu.base.domain.MangaLoaderContext
import org.koitharu.kotatsu.core.exceptions.ParseException import org.koitharu.kotatsu.core.exceptions.ParseException
import org.koitharu.kotatsu.core.model.* import org.koitharu.kotatsu.core.model.*
@@ -18,11 +19,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
SortOrder.RATING SortOrder.RATING
) )
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
val domain = getDomain() val domain = getDomain()
val doc = when { val doc = when {
@@ -33,22 +34,24 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
"offset" to (offset upBy PAGE_SIZE_SEARCH).toString() "offset" to (offset upBy PAGE_SIZE_SEARCH).toString()
) )
) )
tag == null -> loaderContext.httpGet( tags.isNullOrEmpty() -> loaderContext.httpGet(
"https://$domain/list?sortType=${ "https://$domain/list?sortType=${
getSortKey( getSortKey(
sortOrder sortOrder
) )
}&offset=${offset upBy PAGE_SIZE}" }&offset=${offset upBy PAGE_SIZE}"
) )
else -> loaderContext.httpGet( tags.size == 1 -> loaderContext.httpGet(
"https://$domain/list/genre/${tag.key}?sortType=${ "https://$domain/list/genre/${tags.first().key}?sortType=${
getSortKey( getSortKey(
sortOrder sortOrder
) )
}&offset=${offset upBy PAGE_SIZE}" }&offset=${offset upBy PAGE_SIZE}"
) )
}.parseHtml() offset > 0 -> return emptyList()
val root = doc.body().getElementById("mangaBox") else -> advancedSearch(domain, tags)
}.parseHtml().body()
val root = (doc.getElementById("mangaBox") ?: doc.getElementById("mangaResults"))
?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root") ?.selectFirst("div.tiles.row") ?: throw ParseException("Cannot find root")
val baseHost = root.baseUri().toHttpUrl().host val baseHost = root.baseUri().toHttpUrl().host
return root.select("div.tile").mapNotNull { node -> return root.select("div.tile").mapNotNull { node ->
@@ -57,7 +60,7 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
if (descDiv.selectFirst("i.fa-user") != null) { if (descDiv.selectFirst("i.fa-user") != null) {
return@mapNotNull null //skip author 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) { if (href == null || href.toHttpUrl().host != baseHost) {
return@mapNotNull null // skip external links return@mapNotNull null // skip external links
} }
@@ -161,11 +164,11 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name").parseHtml() val doc = loaderContext.httpGet("https://${getDomain()}/list/genres/sort_name").parseHtml()
val root = doc.body().getElementById("mangaBox").selectFirst("div.leftContent") val root = doc.body().getElementById("mangaBox")?.selectFirst("div.leftContent")
.selectFirst("table.table") ?.selectFirst("table.table") ?: parseFailed("Cannot find root")
return root.select("a.element-link").mapToSet { a -> return root.select("a.element-link").mapToSet { a ->
MangaTag( MangaTag(
title = a.text().capitalize(), title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),
source = source source = source
) )
@@ -182,6 +185,43 @@ abstract class GroupleRepository(loaderContext: MangaLoaderContext) :
null -> "updated" null -> "updated"
} }
private suspend fun advancedSearch(domain: String, tags: Set<MangaTag>): Response {
val url = "https://$domain/search/advanced"
// Step 1: map catalog genres names to advanced-search genres ids
val tagsIndex = loaderContext.httpGet(url).parseHtml()
.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<String, String>()
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 companion object {
private const val PAGE_SIZE = 70 private const val PAGE_SIZE = 70

View File

@@ -8,16 +8,16 @@ import org.koitharu.kotatsu.utils.ext.parseHtml
class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) { class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(loaderContext) {
override val defaultDomain = "hentaichan.pro" override val defaultDomain = "hentaichan.live"
override val source = MangaSource.HENCHAN override val source = MangaSource.HENCHAN
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
return super.getList(offset, query, sortOrder, tag).map { return super.getList2(offset, query, tags, sortOrder).map {
val cover = it.coverUrl val cover = it.coverUrl
if (cover.contains("_blur")) { if (cover.contains("_blur")) {
it.copy(coverUrl = cover.replace("_blur", "")) it.copy(coverUrl = cover.replace("_blur", ""))
@@ -36,7 +36,7 @@ class HenChanRepository(loaderContext: MangaLoaderContext) : ChanRepository(load
description = root.getElementById("description")?.html()?.substringBeforeLast("<div"), description = root.getElementById("description")?.html()?.substringBeforeLast("<div"),
largeCoverUrl = root.getElementById("cover")?.absUrl("src"), largeCoverUrl = root.getElementById("cover")?.absUrl("src"),
tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet { tags = root.selectFirst("div.sidetags")?.select("li.sidetag")?.mapToSet {
val a = it.children().last() val a = it.children().last() ?: parseFailed("Invalid tag")
MangaTag( MangaTag(
title = a.text(), title = a.text(),
key = a.attr("href").substringAfterLast('/'), key = a.attr("href").substringAfterLast('/'),

View File

@@ -10,7 +10,6 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.util.* import java.util.*
import kotlin.collections.ArrayList
open class MangaLibRepository(loaderContext: MangaLoaderContext) : open class MangaLibRepository(loaderContext: MangaLoaderContext) :
RemoteMangaRepository(loaderContext) { RemoteMangaRepository(loaderContext) {
@@ -27,11 +26,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
SortOrder.NEWEST SortOrder.NEWEST
) )
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
if (!query.isNullOrEmpty()) { if (!query.isNullOrEmpty()) {
return if (offset == 0) search(query) else emptyList() return if (offset == 0) search(query) else emptyList()
@@ -44,20 +43,21 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
append(getSortKey(sortOrder)) append(getSortKey(sortOrder))
append("&page=") append("&page=")
append(page) append(page)
if (tag != null) { tags?.forEach { tag ->
append("&includeGenres[]=") append("&genres[include][]=")
append(tag.key) append(tag.key)
} }
} }
val doc = loaderContext.httpGet(url).parseHtml() val doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().getElementById("manga-list") ?: throw ParseException("Root not found") 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 -> return items.mapNotNull { card ->
val a = card.selectFirst("a.media-card") ?: return@mapNotNull null val a = card.selectFirst("a.media-card") ?: return@mapNotNull null
val href = a.relUrl("href") val href = a.relUrl("href")
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = card.selectFirst("h3").text(), title = card.selectFirst("h3")?.text().orEmpty(),
coverUrl = a.absUrl("data-src"), coverUrl = a.absUrl("data-src"),
altTitle = null, altTitle = null,
author = null, author = null,
@@ -98,10 +98,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
append(item.getInt("chapter_volume")) append(item.getInt("chapter_volume"))
append("/c") append("/c")
append(item.getString("chapter_number")) append(item.getString("chapter_number"))
@Suppress("BlockingMethodInNonBlockingContext") // lint issue
append('/') append('/')
append(item.optString("chapter_string")) append(item.optString("chapter_string"))
} }
var name = item.getString("chapter_name") var name = item.getStringOrNull("chapter_name")
if (name.isNullOrBlank() || name == "null") { if (name.isNullOrBlank() || name == "null") {
name = "Том " + item.getInt("chapter_volume") + name = "Том " + item.getInt("chapter_volume") +
" Глава " + item.getString("chapter_number") " Глава " + item.getString("chapter_number")
@@ -128,17 +129,17 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
rating = root.selectFirst("div.media-stats-item__score") rating = root.selectFirst("div.media-stats-item__score")
?.selectFirst("span") ?.selectFirst("span")
?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating, ?.text()?.toFloatOrNull()?.div(5f) ?: manga.rating,
author = info.getElementsMatchingOwnText("Автор").firstOrNull() author = info?.getElementsMatchingOwnText("Автор")?.firstOrNull()
?.nextElementSibling()?.text() ?: manga.author, ?.nextElementSibling()?.text() ?: manga.author,
tags = info.selectFirst("div.media-tags") tags = info?.selectFirst("div.media-tags")
?.select("a.media-tag-item")?.mapToSet { a -> ?.select("a.media-tag-item")?.mapToSet { a ->
MangaTag( MangaTag(
title = a.text().capitalize(), title = a.text().toCamelCase(),
key = a.attr("href").substringAfterLast('='), key = a.attr("href").substringAfterLast('='),
source = source source = source
) )
} ?: manga.tags, } ?: manga.tags,
description = info.selectFirst("div.media-description__text")?.html(), description = info?.selectFirst("div.media-description__text")?.html(),
chapters = chapters chapters = chapters
) )
} }
@@ -146,11 +147,11 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val fullUrl = chapter.url.withDomain() val fullUrl = chapter.url.withDomain()
val doc = loaderContext.httpGet(fullUrl).parseHtml() val doc = loaderContext.httpGet(fullUrl).parseHtml()
if (doc.location()?.endsWith("/register") == true) { if (doc.location().endsWith("/register")) {
throw AuthRequiredException("/login".inContextOf(doc)) throw AuthRequiredException("/login".inContextOf(doc))
} }
val scripts = doc.head().select("script") 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('=') .substringAfter('=')
.substringBeforeLast(';') .substringBeforeLast(';')
val pages = JSONArray(pg) val pages = JSONArray(pg)
@@ -196,7 +197,7 @@ open class MangaLibRepository(loaderContext: MangaLoaderContext) :
result += MangaTag( result += MangaTag(
source = source, source = source,
key = x.getInt("id").toString(), key = x.getInt("id").toString(),
title = x.getString("name").capitalize() title = x.getString("name").toCamelCase()
) )
} }
return result return result

View File

@@ -23,11 +23,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
SortOrder.UPDATED SortOrder.UPDATED
) )
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
val sortKey = when (sortOrder) { val sortKey = when (sortOrder) {
SortOrder.ALPHABETICAL -> "?name.az" SortOrder.ALPHABETICAL -> "?name.az"
@@ -43,22 +43,28 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
} }
"/search?name=${query.urlEncoded()}".withDomain() "/search?name=${query.urlEncoded()}".withDomain()
} }
tag != null -> "/directory/${tag.key}/$page.htm$sortKey".withDomain() tags.isNullOrEmpty() -> "/directory/$page.htm$sortKey".withDomain()
else -> "/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 doc = loaderContext.httpGet(url).parseHtml()
val root = doc.body().selectFirst("ul.manga_pic_list") val root = doc.body().selectFirst("ul.manga_pic_list")
?: throw ParseException("Root not found") ?: throw ParseException("Root not found")
return root.select("li").mapNotNull { li -> return root.select("li").mapNotNull { li ->
val a = li.selectFirst("a.manga_cover") 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 views = li.select("p.view")
val status = views.findOwnText { x -> x.startsWith("Status:") } val status = views.findOwnText { x -> x.startsWith("Status:") }
?.substringAfter(':')?.trim()?.toLowerCase(Locale.ROOT) ?.substringAfter(':')?.trim()?.lowercase(Locale.ROOT)
Manga( Manga(
id = generateUid(href), id = generateUid(href),
title = a.attr("title"), title = a.attr("title"),
coverUrl = a.selectFirst("img").absUrl("src"), coverUrl = a.selectFirst("img")?.absUrl("src").orEmpty(),
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
altTitle = null, altTitle = null,
rating = li.selectFirst("p.score")?.selectFirst("b") rating = li.selectFirst("p.score")?.selectFirst("b")
@@ -87,11 +93,11 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml() val doc = loaderContext.httpGet(manga.url.withDomain()).parseHtml()
val root = doc.body().selectFirst("section.main") val root = doc.body().selectFirst("section.main")
?.selectFirst("div.article_content") ?: throw ParseException("Cannot find root") ?.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") val chaptersList = root.selectFirst("div.chapter_content")
?.selectFirst("ul.chapter_list")?.select("li")?.asReversed() ?.selectFirst("ul.chapter_list")?.select("li")?.asReversed()
return manga.copy( return manga.copy(
tags = manga.tags + info.select("li").find { x -> tags = manga.tags + info?.select("li")?.find { x ->
x.selectFirst("b")?.ownText() == "Genre(s):" x.selectFirst("b")?.ownText() == "Genre(s):"
}?.select("a")?.mapNotNull { a -> }?.select("a")?.mapNotNull { a ->
MangaTag( MangaTag(
@@ -100,9 +106,10 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
source = MangaSource.MANGATOWN source = MangaSource.MANGATOWN
) )
}.orEmpty(), }.orEmpty(),
description = info.getElementById("show")?.ownText(), description = info?.getElementById("show")?.ownText(),
chapters = chaptersList?.mapIndexedNotNull { i, li -> 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() } val name = li.select("span").filter { it.className().isEmpty() }
.joinToString(" - ") { it.text() }.trim() .joinToString(" - ") { it.text() }.trim()
MangaChapter( MangaChapter(
@@ -110,7 +117,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
url = href, url = href,
source = MangaSource.MANGATOWN, source = MangaSource.MANGATOWN,
number = i + 1, number = i + 1,
name = if (name.isEmpty()) "${manga.title} - ${i + 1}" else name name = name.ifEmpty { "${manga.title} - ${i + 1}" }
) )
} }
) )
@@ -121,7 +128,7 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
val doc = loaderContext.httpGet(fullUrl).parseHtml() val doc = loaderContext.httpGet(fullUrl).parseHtml()
val root = doc.body().selectFirst("div.page_select") val root = doc.body().selectFirst("div.page_select")
?: throw ParseException("Cannot find root") ?: throw ParseException("Cannot find root")
return root.selectFirst("select").select("option").mapNotNull { return root.selectFirst("select")?.select("option")?.mapNotNull {
val href = it.relUrl("value") val href = it.relUrl("value")
if (href.endsWith("featured.html")) { if (href.endsWith("featured.html")) {
return@mapNotNull null return@mapNotNull null
@@ -132,20 +139,20 @@ class MangaTownRepository(loaderContext: MangaLoaderContext) :
referer = fullUrl, referer = fullUrl,
source = MangaSource.MANGATOWN source = MangaSource.MANGATOWN
) )
} } ?: parseFailed("Pages list not found")
} }
override suspend fun getPageUrl(page: MangaPage): String { override suspend fun getPageUrl(page: MangaPage): String {
val doc = loaderContext.httpGet(page.url.withDomain()).parseHtml() 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<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("/directory/".withDomain()).parseHtml() val doc = loaderContext.httpGet("/directory/".withDomain()).parseHtml()
val root = doc.body().selectFirst("aside.right") val root = doc.body().selectFirst("aside.right")
.getElementsContainingOwnText("Genres") ?.getElementsContainingOwnText("Genres")
.first() ?.first()
.nextElementSibling() ?.nextElementSibling() ?: parseFailed("Root not found")
return root.select("li").mapNotNullToSet { li -> return root.select("li").mapNotNullToSet { li ->
val a = li.selectFirst("a") ?: return@mapNotNullToSet null val a = li.selectFirst("a") ?: return@mapNotNullToSet null
val key = a.attr("href").parseTagKey() val key = a.attr("href").parseTagKey()

View File

@@ -20,17 +20,19 @@ class MangareadRepository(
SortOrder.POPULARITY SortOrder.POPULARITY
) )
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
if (offset % PAGE_SIZE != 0) { val tag = when {
return emptyList() tags.isNullOrEmpty() -> null
tags.size == 1 -> tags.first()
else -> throw NotImplementedError("Multiple genres are not supported by this source")
} }
val payload = createRequestTemplate() val payload = createRequestTemplate()
payload["page"] = (offset / PAGE_SIZE).toString() payload["page"] = (offset / PAGE_SIZE.toFloat()).toIntUp().toString()
payload["vars[meta_key]"] = when (sortOrder) { payload["vars[meta_key]"] = when (sortOrder) {
SortOrder.POPULARITY -> "_wp_manga_views" SortOrder.POPULARITY -> "_wp_manga_views"
SortOrder.UPDATED -> "_latest_update" SortOrder.UPDATED -> "_latest_update"
@@ -43,26 +45,26 @@ class MangareadRepository(
payload payload
).parseHtml() ).parseHtml()
return doc.select("div.row.c-tabs-item__content").map { div -> 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") val summary = div.selectFirst(".tab-summary")
Manga( Manga(
id = generateUid(href), id = generateUid(href),
url = href, url = href,
publicUrl = href.inContextOf(div), publicUrl = href.inContextOf(div),
coverUrl = div.selectFirst("img").attr("data-srcset") coverUrl = div.selectFirst("img")?.absUrl("src").orEmpty(),
.split(',').firstOrNull()?.substringBeforeLast(' ').orEmpty(), title = summary?.selectFirst("h3")?.text().orEmpty(),
title = summary.selectFirst("h3").text(),
rating = div.selectFirst("span.total_votes")?.ownText() rating = div.selectFirst("span.total_votes")?.ownText()
?.toFloatOrNull()?.div(5f) ?: -1f, ?.toFloatOrNull()?.div(5f) ?: -1f,
tags = summary.selectFirst(".mg_genres").select("a").mapToSet { a -> tags = summary?.selectFirst(".mg_genres")?.select("a")?.mapToSet { a ->
MangaTag( MangaTag(
key = a.attr("href").removeSuffix("/").substringAfterLast('/'), key = a.attr("href").removeSuffix("/").substringAfterLast('/'),
title = a.text(), title = a.text(),
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
) )
}, }.orEmpty(),
author = summary.selectFirst(".mg_author")?.selectFirst("a")?.ownText(), author = summary?.selectFirst(".mg_author")?.selectFirst("a")?.ownText(),
state = when (summary.selectFirst(".mg_status")?.selectFirst(".summary-content") state = when (summary?.selectFirst(".mg_status")?.selectFirst(".summary-content")
?.ownText()?.trim()) { ?.ownText()?.trim()) {
"OnGoing" -> MangaState.ONGOING "OnGoing" -> MangaState.ONGOING
"Completed" -> MangaState.FINISHED "Completed" -> MangaState.FINISHED
@@ -76,9 +78,9 @@ class MangareadRepository(
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml() val doc = loaderContext.httpGet("https://${getDomain()}/manga/").parseHtml()
val root = doc.body().selectFirst("header") val root = doc.body().selectFirst("header")
.selectFirst("ul.second-menu") ?.selectFirst("ul.second-menu") ?: parseFailed("Root not found")
return root.select("li").mapNotNullToSet { li -> 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("/") val href = a.attr("href").removeSuffix("/")
.substringAfterLast("genres/", "") .substringAfterLast("genres/", "")
if (href.isEmpty()) { if (href.isEmpty()) {
@@ -102,8 +104,8 @@ class MangareadRepository(
val root2 = doc.body().selectFirst("div.content-area") val root2 = doc.body().selectFirst("div.content-area")
?.selectFirst("div.c-page") ?.selectFirst("div.c-page")
?: throw ParseException("Root2 not found") ?: throw ParseException("Root2 not found")
val mangaId = doc.getElementsByAttribute("data-postid").firstOrNull() val mangaId = doc.getElementsByAttribute("data-post").firstOrNull()
?.attr("data-postid")?.toLongOrNull() ?.attr("data-post")?.toLongOrNull()
?: throw ParseException("Cannot obtain manga id") ?: throw ParseException("Cannot obtain manga id")
val doc2 = loaderContext.httpPost( val doc2 = loaderContext.httpPost(
"https://${getDomain()}/wp-admin/admin-ajax.php", "https://${getDomain()}/wp-admin/admin-ajax.php",
@@ -128,10 +130,12 @@ class MangareadRepository(
?.joinToString { it.html() }, ?.joinToString { it.html() },
chapters = doc2.select("li").asReversed().mapIndexed { i, li -> chapters = doc2.select("li").asReversed().mapIndexed { i, li ->
val a = li.selectFirst("a") val a = li.selectFirst("a")
val href = a.relUrl("href") val href = a?.relUrl("href").orEmpty().ifEmpty {
parseFailed("Link is missing")
}
MangaChapter( MangaChapter(
id = generateUid(href), id = generateUid(href),
name = a.ownText(), name = a!!.ownText(),
number = i + 1, number = i + 1,
url = href, url = href,
source = MangaSource.MANGAREAD source = MangaSource.MANGAREAD
@@ -148,7 +152,7 @@ class MangareadRepository(
?: throw ParseException("Root not found") ?: throw ParseException("Root not found")
return root.select("div.page-break").map { div -> return root.select("div.page-break").map { div ->
val img = div.selectFirst("img") val img = div.selectFirst("img")
val url = img.relUrl("data-src") val url = img?.relUrl("src") ?: parseFailed("Page image not found")
MangaPage( MangaPage(
id = generateUid(url), id = generateUid(url),
url = url, url = url,
@@ -170,4 +174,4 @@ class MangareadRepository(
it.substring(0, pos) to it.substring(pos + 1) it.substring(0, pos) to it.substring(pos + 1)
}.toMutableMap() }.toMutableMap()
} }
} }

View File

@@ -0,0 +1,206 @@
package org.koitharu.kotatsu.core.parser.site
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
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.util.*
abstract class NineMangaRepository(
loaderContext: MangaLoaderContext,
override val source: MangaSource,
override val defaultDomain: String,
) : RemoteMangaRepository(loaderContext) {
init {
loaderContext.cookieJar.insertCookies(getDomain(), "ninemanga_template_desk=yes")
}
override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.POPULARITY,
)
override suspend fun getList2(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
val page = (offset / PAGE_SIZE.toFloat()).toIntUp() + 1
val url = buildString {
append("https://")
append(getDomain())
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(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")
?: 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().orEmpty(),
altTitle = null,
coverUrl = node.selectFirst("img")?.absUrl("src").orEmpty(),
rating = Manga.NO_RATING,
author = null,
tags = emptySet(),
state = null,
source = source,
description = dd?.selectFirst("p")?.html(),
)
}
}
override suspend fun getDetails(manga: Manga): Manga {
val doc = loaderContext.httpGet(
manga.url.withDomain() + "?waring=1",
PREDEFINED_HEADERS
).parseHtml()
val root = doc.body().selectFirst("div.manga")
?: throw ParseException("Cannot find root")
val infoRoot = root.selectFirst("div.bookintro")
?: throw ParseException("Cannot find info")
return manga.copy(
tags = infoRoot.getElementsByAttributeValue("itemprop", "genre").first()
?.select("a")?.mapToSet { a ->
MangaTag(
title = a.text(),
key = a.attr("href").substringBetween("/", "."),
source = source,
)
}.orEmpty(),
author = infoRoot.getElementsByAttributeValue("itemprop", "author").first()?.text(),
description = infoRoot.getElementsByAttributeValue("itemprop", "description").first()
?.html()?.substringAfter("</b>"),
chapters = root.selectFirst("div.chapterbox")?.selectFirst("ul")
?.select("li")?.asReversed()?.mapIndexed { i, li ->
val a = li.selectFirst("a")
val href = a?.relUrl("href") ?: parseFailed("Link not found")
MangaChapter(
id = generateUid(href),
name = a.text(),
number = i + 1,
url = href,
branch = null,
source = source,
)
}
)
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
val doc = loaderContext.httpGet(chapter.url.withDomain(), PREDEFINED_HEADERS).parseHtml()
return doc.body().getElementById("page")?.select("option")?.map { option ->
val url = option.attr("value")
MangaPage(
id = generateUid(url),
url = url,
referer = chapter.url.withDomain(),
preview = null,
source = source,
)
} ?: throw ParseException("Pages list not found at ${chapter.url}")
}
override suspend fun getPageUrl(page: MangaPage): String {
val doc = loaderContext.httpGet(page.url.withDomain(), PREDEFINED_HEADERS).parseHtml()
val root = doc.body()
return root.selectFirst("a.pic_download")?.absUrl("href")
?: throw ParseException("Page image not found")
}
override suspend fun getTags(): Set<MangaTag> {
val doc = loaderContext.httpGet("https://${getDomain()}/search/?type=high", PREDEFINED_HEADERS)
.parseHtml()
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().toTitleCase(),
key = cateId,
source = source
)
} ?: parseFailed("Root not found")
}
class English(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_EN,
"www.ninemanga.com",
)
class Spanish(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_ES,
"es.ninemanga.com",
)
class Russian(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_RU,
"ru.ninemanga.com",
)
class Deutsch(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_DE,
"de.ninemanga.com",
)
class Brazil(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_BR,
"br.ninemanga.com",
)
class Italiano(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_IT,
"it.ninemanga.com",
)
class Francais(loaderContext: MangaLoaderContext) : NineMangaRepository(
loaderContext,
MangaSource.NINEMANGA_FR,
"fr.ninemanga.com",
)
private companion object {
const val PAGE_SIZE = 26
val PREDEFINED_HEADERS = Headers.Builder()
.add("Accept-Language", "en-US;q=0.7,en;q=0.3")
.build()
}
}

View File

@@ -9,7 +9,6 @@ import org.koitharu.kotatsu.core.model.*
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import java.util.* import java.util.*
import kotlin.collections.ArrayList
class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) { class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaRepository(loaderContext) {
@@ -18,18 +17,17 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
override val defaultDomain = "remanga.org" override val defaultDomain = "remanga.org"
override val sortOrders: Set<SortOrder> = EnumSet.of( override val sortOrders: Set<SortOrder> = EnumSet.of(
SortOrder.UPDATED,
SortOrder.POPULARITY, SortOrder.POPULARITY,
SortOrder.RATING, SortOrder.RATING,
SortOrder.ALPHABETICAL,
SortOrder.UPDATED,
SortOrder.NEWEST SortOrder.NEWEST
) )
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
val domain = getDomain() val domain = getDomain()
val urlBuilder = StringBuilder() val urlBuilder = StringBuilder()
@@ -41,8 +39,9 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
} else { } else {
urlBuilder.append("/api/search/catalog/?ordering=") urlBuilder.append("/api/search/catalog/?ordering=")
.append(getSortKey(sortOrder)) .append(getSortKey(sortOrder))
if (tag != null) { tags?.forEach { tag ->
urlBuilder.append("&genres=" + tag.key) urlBuilder.append("&genres=")
urlBuilder.append(tag.key)
} }
} }
urlBuilder urlBuilder
@@ -162,7 +161,7 @@ class RemangaRepository(loaderContext: MangaLoaderContext) : RemoteMangaReposito
SortOrder.POPULARITY -> "-rating" SortOrder.POPULARITY -> "-rating"
SortOrder.RATING -> "-votes" SortOrder.RATING -> "-votes"
SortOrder.NEWEST -> "-id" SortOrder.NEWEST -> "-id"
else -> "-rating" else -> "-chapter_date"
} }
private fun parsePage(jo: JSONObject, referer: String) = MangaPage( private fun parsePage(jo: JSONObject, referer: String) = MangaPage(

View File

@@ -41,6 +41,8 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false) val isAmoledTheme by BoolPreferenceDelegate(KEY_THEME_AMOLED, defaultValue = false)
val isToolbarHideWhenScrolling by BoolPreferenceDelegate(KEY_HIDE_TOOLBAR, defaultValue = true)
var gridSize by IntPreferenceDelegate(KEY_GRID_SIZE, defaultValue = 100) var gridSize by IntPreferenceDelegate(KEY_GRID_SIZE, defaultValue = 100)
val readerPageSwitch by StringSetPreferenceDelegate( val readerPageSwitch by StringSetPreferenceDelegate(
@@ -99,6 +101,9 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
var hiddenSources by StringSetPreferenceDelegate(KEY_SOURCES_HIDDEN) var hiddenSources by StringSetPreferenceDelegate(KEY_SOURCES_HIDDEN)
val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs
fun getStorageDir(context: Context): File? { fun getStorageDir(context: Context): File? {
val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let { val value = prefs.getString(KEY_LOCAL_STORAGE, null)?.let {
File(it) File(it)
@@ -147,6 +152,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_APP_SECTION = "app_section" const val KEY_APP_SECTION = "app_section"
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_HIDE_TOOLBAR = "hide_toolbar"
const val KEY_SOURCES_ORDER = "sources_order" const val KEY_SOURCES_ORDER = "sources_order"
const val KEY_SOURCES_HIDDEN = "sources_hidden" const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning" const val KEY_TRAFFIC_WARNING = "traffic_warning"
@@ -160,8 +166,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers" const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_SOURCES = "track_sources"
const val KEY_APP_UPDATE = "app_update" const val KEY_TRACK_WARNING = "track_warning"
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
@@ -177,5 +182,14 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
// 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"
} }
} }

View File

@@ -27,5 +27,6 @@ interface SourceSettings {
const val KEY_DOMAIN = "domain" const val KEY_DOMAIN = "domain"
const val KEY_USE_SSL = "ssl" const val KEY_USE_SSL = "ssl"
const val KEY_AUTH = "auth"
} }
} }

View File

@@ -2,13 +2,12 @@ package org.koitharu.kotatsu.details
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.DetailsViewModel
val detailsModule val detailsModule
get() = module { get() = module {
viewModel { (intent: MangaIntent) -> viewModel { intent ->
DetailsViewModel(intent, get(), get(), get(), get(), get(), get()) DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get())
} }
} }

View File

@@ -15,19 +15,20 @@ import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener 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.core.model.MangaSource
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem 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.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(), class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
OnListItemClickListener<MangaChapter>, ActionMode.Callback, AdapterView.OnItemSelectedListener { OnListItemClickListener<ChapterListItem>,
ActionMode.Callback,
AdapterView.OnItemSelectedListener {
private val viewModel by sharedViewModel<DetailsViewModel>() private val viewModel by sharedViewModel<DetailsViewModel>()
@@ -105,9 +106,9 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
override fun onItemClick(item: MangaChapter, view: View) { override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionDecoration?.checkedItemsCount != 0) { if (selectionDecoration?.checkedItemsCount != 0) {
selectionDecoration?.toggleItemChecked(item.id) selectionDecoration?.toggleItemChecked(item.chapter.id)
if (selectionDecoration?.checkedItemsCount == 0) { if (selectionDecoration?.checkedItemsCount == 0) {
actionMode?.finish() actionMode?.finish()
} else { } else {
@@ -116,6 +117,10 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
} }
return return
} }
if (item.isMissing) {
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
return
}
val options = ActivityOptions.makeScaleUpAnimation( val options = ActivityOptions.makeScaleUpAnimation(
view, view,
0, 0,
@@ -127,17 +132,17 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
ReaderActivity.newIntent( ReaderActivity.newIntent(
view.context, view.context,
viewModel.manga.value ?: return, viewModel.manga.value ?: return,
ReaderState(item.id, 0, 0) ReaderState(item.chapter.id, 0, 0)
), options.toBundle() ), options.toBundle()
) )
} }
override fun onItemLongClick(item: MangaChapter, view: View): Boolean { override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
if (actionMode == null) { if (actionMode == null) {
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this) actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
} }
return actionMode?.also { return actionMode?.also {
selectionDecoration?.setItemIsChecked(item.id, true) selectionDecoration?.setItemIsChecked(item.chapter.id, true)
binding.recyclerViewChapters.invalidateItemDecorations() binding.recyclerViewChapters.invalidateItemDecorations()
it.invalidate() it.invalidate()
} != null } != null
@@ -148,7 +153,7 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
R.id.action_save -> { R.id.action_save -> {
DownloadService.start( DownloadService.start(
context ?: return false, context ?: return false,
viewModel.manga.value ?: return false, viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds selectionDecoration?.checkedItemsIds
) )
mode.finish() mode.finish()
@@ -174,17 +179,20 @@ class ChaptersFragment : BaseFragment<FragmentChaptersBinding>(),
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
val manga = viewModel.manga.value val manga = viewModel.manga.value
mode.menuInflater.inflate(R.menu.mode_chapters, menu) mode.menuInflater.inflate(R.menu.mode_chapters, menu)
menu.findItem(R.id.action_save).isVisible = manga?.source != MangaSource.LOCAL
mode.title = manga?.title mode.title = manga?.title
return true return true
} }
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { 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( mode.subtitle = resources.getQuantityString(
R.plurals.chapters_from_x, R.plurals.chapters_from_x,
count, items.size,
count, items.size,
chaptersAdapter?.itemCount ?: 0 chaptersAdapter?.itemCount ?: 0
) )
return true return true

View File

@@ -33,9 +33,12 @@ import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.os.ShortcutsRepository import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding 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.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.buildAlertDialog
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
@@ -113,7 +116,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
} }
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_details, menu) menuInflater.inflate(R.menu.opt_details, menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@@ -228,6 +231,33 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(),
binding.pager.isUserInputEnabled = true 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 { companion object {
const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA" const val ACTION_MANGA_VIEW = "${BuildConfig.APPLICATION_ID}.action.VIEW_MANGA"

View File

@@ -13,7 +13,6 @@ import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import coil.util.CoilUtils import coil.util.CoilUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -23,20 +22,20 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.FileSizeUtils import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.*
import kotlin.math.roundToInt import kotlin.random.Random
class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener, class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickListener,
View.OnLongClickListener { View.OnLongClickListener {
private val viewModel by sharedViewModel<DetailsViewModel>() private val viewModel by sharedViewModel<DetailsViewModel>()
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE) private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
private var tagsJob: Job? = null
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -61,16 +60,43 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
.enqueueWith(coil) .enqueueWith(coil)
textViewTitle.text = manga.title textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author
sourceContainer.isVisible = manga.source != MangaSource.LOCAL
textViewSource.text = manga.source.title
textViewDescription.text = textViewDescription.text =
manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank) manga.description?.parseAsHtml()?.takeUnless(Spanned::isBlank)
?: getString(R.string.no_description) ?: getString(R.string.no_description)
if (manga.rating == Manga.NO_RATING) { if (manga.chapters?.isNotEmpty() == true) {
ratingBar.isVisible = false chaptersContainer.isVisible = true
textViewChapters.text = manga.chapters.let {
resources.getQuantityString(
R.plurals.chapters,
it.size,
manga.chapters.size
)
}
} else { } else {
ratingBar.progress = (ratingBar.max * manga.rating).roundToInt() chaptersContainer.isVisible = false
ratingBar.isVisible = true
} }
imageViewFavourite.setOnClickListener(this@DetailsFragment) if (manga.rating == Manga.NO_RATING) {
ratingContainer.isVisible = false
} else {
textViewRating.text = String.format("%.1f", manga.rating * 5)
ratingContainer.isVisible = true
}
val file = manga.url.toUri().toFileOrNull()
if (file != null) {
viewLifecycleScope.launch {
val size = withContext(Dispatchers.IO) {
file.length()
}
textViewSize.text = FileSizeUtils.formatBytes(requireContext(), size)
}
sizeContainer.isVisible = true
} else {
sizeContainer.isVisible = false
}
buttonFavorite.setOnClickListener(this@DetailsFragment)
buttonRead.setOnClickListener(this@DetailsFragment) buttonRead.setOnClickListener(this@DetailsFragment)
buttonRead.setOnLongClickListener(this@DetailsFragment) buttonRead.setOnLongClickListener(this@DetailsFragment)
buttonRead.isEnabled = !manga.chapters.isNullOrEmpty() buttonRead.isEnabled = !manga.chapters.isNullOrEmpty()
@@ -91,33 +117,42 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
} }
private fun onFavouriteChanged(isFavourite: Boolean) { private fun onFavouriteChanged(isFavourite: Boolean) {
binding.imageViewFavourite.setImageResource( with(binding.buttonFavorite) {
if (isFavourite) { if (isFavourite) {
R.drawable.ic_heart this.setIconResource(R.drawable.ic_heart)
} else { } else {
R.drawable.ic_heart_outline this.setIconResource(R.drawable.ic_heart_outline)
} }
) }
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
binding.progressBar.isVisible = isLoading if (isLoading) {
binding.progressBar.show()
} else {
binding.progressBar.hide()
}
} }
override fun onClick(v: View) { override fun onClick(v: View) {
val manga = viewModel.manga.value val manga = viewModel.manga.value ?: return
when (v.id) { when (v.id) {
R.id.imageView_favourite -> { R.id.button_favorite -> {
FavouriteCategoriesDialog.show(childFragmentManager, manga ?: return) FavouriteCategoriesDialog.show(childFragmentManager, manga)
} }
R.id.button_read -> { R.id.button_read -> {
startActivity( val chapterId = viewModel.readingHistory.value?.chapterId
ReaderActivity.newIntent( if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
context ?: return, (activity as? DetailsActivity)?.showChapterMissingDialog(chapterId)
manga ?: return, } else {
null startActivity(
ReaderActivity.newIntent(
context ?: return,
manga,
null
)
) )
) }
} }
} }
} }
@@ -160,37 +195,13 @@ class DetailsFragment : BaseFragment<FragmentDetailsBinding>(), View.OnClickList
} }
private fun bindTags(manga: Manga) { private fun bindTags(manga: Manga) {
tagsJob?.cancel() binding.chipsTags.setChips(
tagsJob = viewLifecycleScope.launch { manga.tags.map { tag ->
val tags = ArrayList<ChipsView.ChipModel>(manga.tags.size + 2) ChipsView.ChipModel(
if (manga.author != null) {
tags += ChipsView.ChipModel(
title = manga.author,
icon = R.drawable.ic_chip_user
)
}
for (tag in manga.tags) {
tags += ChipsView.ChipModel(
title = tag.title, title = tag.title,
icon = R.drawable.ic_chip_tag icon = 0
) )
} }
val file = manga.url.toUri().toFileOrNull() )
if (file != null) {
val size = withContext(Dispatchers.IO) {
file.length()
}
tags += ChipsView.ChipModel(
title = FileSizeUtils.formatBytes(requireContext(), size),
icon = R.drawable.ic_chip_storage
)
} else {
tags += ChipsView.ChipModel(
title = manga.source.title,
icon = R.drawable.ic_chip_web
)
}
binding.chipsTags.setChips(tags)
}
} }
} }

View File

@@ -11,7 +11,11 @@ import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
import org.koitharu.kotatsu.core.model.Manga 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.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.ChapterExtra import org.koitharu.kotatsu.history.domain.ChapterExtra
@@ -29,7 +33,7 @@ class DetailsViewModel(
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val settings: AppSettings private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
private val mangaData = MutableStateFlow<Manga?>(intent.manga) private val mangaData = MutableStateFlow<Manga?>(intent.manga)
@@ -53,6 +57,18 @@ class DetailsViewModel(
trackingRepository.getNewChaptersCount(mangaId) trackingRepository.getNewChaptersCount(mangaId)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
private val remoteManga = MutableStateFlow<Manga?>(null)
/*private val remoteManga = mangaData.mapLatest {
if (it?.source == MangaSource.LOCAL) {
runCatching {
val m = localMangaRepository.getRemoteManga(it) ?: return@mapLatest null
MangaRepository(m.source).getDetails(m)
}.getOrNull()
} else {
null
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)*/
private val chaptersReversed = settings.observe() private val chaptersReversed = settings.observe()
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS } .filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
.map { settings.chaptersReverse } .map { settings.chaptersReverse }
@@ -85,24 +101,19 @@ class DetailsViewModel(
val chapters = combine( val chapters = combine(
mangaData.map { it?.chapters.orEmpty() }, mangaData.map { it?.chapters.orEmpty() },
remoteManga,
history.map { it?.chapterId }, history.map { it?.chapterId },
newChapters, newChapters,
chaptersReversed,
selectedBranch selectedBranch
) { chapters, currentId, newCount, reversed, branch -> ) { chapters, sourceManga, currentId, newCount, branch ->
val currentIndex = chapters.indexOfFirst { it.id == currentId } val sourceChapters = sourceManga?.chapters
val firstNewIndex = chapters.size - newCount if (sourceChapters.isNullOrEmpty()) {
val res = chapters.mapIndexed { index, chapter -> mapChapters(chapters, currentId, newCount, branch)
chapter.toListItem( } else {
when { mapChaptersWithSource(chapters, sourceChapters, currentId, newCount, branch)
index >= firstNewIndex -> ChapterExtra.NEW }
index == currentIndex -> ChapterExtra.CURRENT }.combine(chaptersReversed) { list, reversed ->
index < currentIndex -> ChapterExtra.READ if (reversed) list.asReversed() else list
else -> ChapterExtra.UNREAD
}
)
}.filter { it.chapter.branch == branch }
if (reversed) res.asReversed() else res
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
init { init {
@@ -121,6 +132,12 @@ class DetailsViewModel(
?.maxByOrNull { it.value.size }?.key ?.maxByOrNull { it.value.size }?.key
} }
mangaData.value = manga 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,80 @@ class DetailsViewModel(
fun setSelectedBranch(branch: String?) { fun setSelectedBranch(branch: String?) {
selectedBranch.value = branch selectedBranch.value = branch
} }
fun getRemoteManga(): Manga? {
return remoteManga.value
}
private fun mapChapters(
chapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size)
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
)
}
return result
}
private fun mapChaptersWithSource(
chapters: List<MangaChapter>,
sourceChapters: List<MangaChapter>,
currentId: Long?,
newCount: Int,
branch: String?,
): List<ChapterListItem> {
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount
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
) ?: chapter.toListItem(
extra = when {
i >= firstNewIndex -> ChapterExtra.NEW
i == currentIndex -> ChapterExtra.CURRENT
i < currentIndex -> ChapterExtra.READ
else -> ChapterExtra.UNREAD
},
isMissing = true
)
}
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)
}
result.sortBy { it.chapter.number }
}
return result
}
} }

View File

@@ -3,23 +3,22 @@ package org.koitharu.kotatsu.details.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.databinding.ItemChapterBinding import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.history.domain.ChapterExtra import org.koitharu.kotatsu.history.domain.ChapterExtra
import org.koitharu.kotatsu.utils.ext.getThemeColor import org.koitharu.kotatsu.utils.ext.getThemeColor
fun chapterListItemAD( fun chapterListItemAD(
clickListener: OnListItemClickListener<MangaChapter> clickListener: OnListItemClickListener<ChapterListItem>,
) = adapterDelegateViewBinding<ChapterListItem, ChapterListItem, ItemChapterBinding>( ) = adapterDelegateViewBinding<ChapterListItem, ChapterListItem, ItemChapterBinding>(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
) { ) {
itemView.setOnClickListener { itemView.setOnClickListener {
clickListener.onItemClick(item.chapter, it) clickListener.onItemClick(item, it)
} }
itemView.setOnLongClickListener { itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.chapter, it) clickListener.onItemLongClick(item, it)
} }
bind { payload -> bind { payload ->
@@ -43,5 +42,7 @@ fun chapterListItemAD(
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse)) binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorPrimaryInverse))
} }
} }
binding.textViewTitle.alpha = if (item.isMissing) 0.3f else 1f
binding.textViewNumber.alpha = if (item.isMissing) 0.3f else 1f
} }
} }

View File

@@ -3,12 +3,11 @@ package org.koitharu.kotatsu.details.ui.adapter
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import kotlin.jvm.internal.Intrinsics import kotlin.jvm.internal.Intrinsics
class ChaptersAdapter( class ChaptersAdapter(
onItemClickListener: OnListItemClickListener<MangaChapter> onItemClickListener: OnListItemClickListener<ChapterListItem>,
) : AsyncListDifferDelegationAdapter<ChapterListItem>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ChapterListItem>(DiffCallback()) {
init { init {
@@ -38,7 +37,7 @@ class ChaptersAdapter(
} }
override fun getChangePayload(oldItem: ChapterListItem, newItem: ChapterListItem): Any? { 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 newItem.extra
} }
return null return null

View File

@@ -5,5 +5,6 @@ import org.koitharu.kotatsu.history.domain.ChapterExtra
data class ChapterListItem( data class ChapterListItem(
val chapter: MangaChapter, val chapter: MangaChapter,
val extra: ChapterExtra val extra: ChapterExtra,
val isMissing: Boolean,
) )

View File

@@ -3,7 +3,11 @@ package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaChapter import org.koitharu.kotatsu.core.model.MangaChapter
import org.koitharu.kotatsu.history.domain.ChapterExtra import org.koitharu.kotatsu.history.domain.ChapterExtra
fun MangaChapter.toListItem(extra: ChapterExtra) = ChapterListItem( fun MangaChapter.toListItem(
extra: ChapterExtra,
isMissing: Boolean,
) = ChapterListItem(
chapter = this, chapter = this,
extra = extra extra = extra,
isMissing = isMissing,
) )

View File

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

View File

@@ -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<OkHttpClient>()
private val cache by inject<PagesCache>()
private val settings by inject<AppSettings>()
private val imageLoader by inject<ImageLoader>()
private val jobs = HashMap<Int, Job>()
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<Manga>(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<Long>?, 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<LocalMangaRepository>().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<Long>? = 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<AppSettings>()
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()
}
}
}
}

View File

@@ -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<Long>?, startId: Int) = flow<State> {
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"
}
}

View File

@@ -0,0 +1,101 @@
package org.koitharu.kotatsu.download.ui
import androidx.core.view.isVisible
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.format
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.setIndeterminateCompat
fun downloadItemAD(
scope: CoroutineScope,
) = adapterDelegateViewBinding<JobStateFlow<DownloadManager.State>, JobStateFlow<DownloadManager.State>, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) }
) {
var job: Job? = null
bind {
job?.cancel()
job = item.onEach { state ->
binding.textViewTitle.text = state.manga.title
binding.imageViewCover.setImageDrawable(
state.cover ?: getDrawable(R.drawable.ic_placeholder)
)
when (state) {
is DownloadManager.State.Cancelling -> {
binding.textViewStatus.setText(R.string.cancelling_)
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
}
}

View File

@@ -0,0 +1,58 @@
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.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<ActivityDownloadsBinding>() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = DownloadsAdapter(lifecycleScope)
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)
}
}

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.download.ui
import androidx.recyclerview.widget.DiffUtil
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,
) : AsyncListDifferDelegationAdapter<JobStateFlow<DownloadManager.State>>(DiffCallback()) {
init {
delegatesManager.addDelegate(downloadItemAD(scope))
setHasStableIds(true)
}
override fun getItemId(position: Int): Long {
return items[position].value.startId.toLong()
}
private class DiffCallback : DiffUtil.ItemCallback<JobStateFlow<DownloadManager.State>>() {
override fun areItemsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
): Boolean {
return oldItem.value.startId == newItem.value.startId
}
override fun areContentsTheSame(
oldItem: JobStateFlow<DownloadManager.State>,
newItem: JobStateFlow<DownloadManager.State>,
): Boolean {
return oldItem.value == newItem.value
}
}
}

View File

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

View File

@@ -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<Int, JobStateFlow<DownloadManager.State>>()
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<Manga>(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<Long>?,
): JobStateFlow<DownloadManager.State> {
val initialState = DownloadManager.State.Queued(startId, manga, null)
val stateFlow = MutableStateFlow<DownloadManager.State>(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<Collection<JobStateFlow<DownloadManager.State>>>
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<Long>? = 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<AppSettings>()
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()
}
}
}
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel
@@ -13,11 +12,11 @@ val favouritesModule
single { FavouritesRepository(get()) } single { FavouritesRepository(get()) }
viewModel { (categoryId: Long) -> viewModel { categoryId ->
FavouritesListViewModel(categoryId, get(), get()) FavouritesListViewModel(categoryId.get(), get(), get())
} }
viewModel { FavouritesCategoriesViewModel(get()) } viewModel { FavouritesCategoriesViewModel(get()) }
viewModel { (manga: Manga) -> viewModel { manga ->
MangaCategoriesViewModel(manga, get()) MangaCategoriesViewModel(manga.get(), get())
} }
} }

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.model.FavouriteCategory
@Dao @Dao
abstract class FavouriteCategoriesDao { abstract class FavouriteCategoriesDao {
@@ -13,6 +12,9 @@ abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories ORDER BY sort_key") @Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>> abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@Query("SELECT * FROM favourite_categories WHERE category_id = :id")
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity>
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
abstract suspend fun insert(category: FavouriteCategoryEntity): Long abstract suspend fun insert(category: FavouriteCategoryEntity): Long
@@ -23,10 +25,13 @@ abstract class FavouriteCategoriesDao {
abstract suspend fun delete(id: Long) abstract suspend fun delete(id: Long)
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id") @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") @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") @Query("SELECT MAX(sort_key) FROM favourite_categories")
protected abstract suspend fun getMaxSortKey(): Int? protected abstract suspend fun getMaxSortKey(): Int?

View File

@@ -4,6 +4,7 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.SortOrder
import java.util.* import java.util.*
@Entity(tableName = "favourite_categories") @Entity(tableName = "favourite_categories")
@@ -12,13 +13,15 @@ data class FavouriteCategoryEntity(
@ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "category_id") val categoryId: Int,
@ColumnInfo(name = "created_at") val createdAt: Long, @ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int, @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( fun toFavouriteCategory(id: Long? = null) = FavouriteCategory(
id = id ?: categoryId.toLong(), id = id ?: categoryId.toLong(),
title = title, title = title,
sortKey = sortKey, sortKey = sortKey,
createdAt = Date(createdAt) order = SortOrder.values().find { x -> x.name == order } ?: SortOrder.NEWEST,
createdAt = Date(createdAt),
) )
} }

View File

@@ -1,8 +1,11 @@
package org.koitharu.kotatsu.favourites.data package org.koitharu.kotatsu.favourites.data
import androidx.room.* import androidx.room.*
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.model.SortOrder
@Dao @Dao
abstract class FavouritesDao { abstract class FavouritesDao {
@@ -11,9 +14,13 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC") @Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(): List<FavouriteManga> abstract suspend fun findAll(): List<FavouriteManga>
@Transaction fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC") val orderBy = getOrderBy(order)
abstract fun observeAll(): Flow<List<FavouriteManga>> 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 @Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @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") @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga> abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
@Transaction fun observeAll(categoryId: Long, order: SortOrder): Flow<List<FavouriteManga>> {
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC") val orderBy = getOrderBy(order)
abstract fun observeAll(categoryId: Long): Flow<List<FavouriteManga>> 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<Any>(categoryId),
)
return observeAllRaw(query)
}
@Transaction @Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @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) insert(entity)
} }
} }
@Transaction
@RawQuery(observedEntities = [FavouriteEntity::class])
protected abstract fun observeAllRaw(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
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")
}
} }

View File

@@ -3,12 +3,14 @@ package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.Manga 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.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.utils.ext.mapItems 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)) } return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
} }
fun observeAll(): Flow<List<Manga>> { fun observeAll(order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll() return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } .mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
} }
suspend fun getAllManga(offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(offset, 20)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
}
suspend fun getManga(categoryId: Long): List<Manga> { suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId) val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
} }
fun observeAll(categoryId: Long): Flow<List<Manga>> { fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId) return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } .mapItems { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
} }
fun observeAll(categoryId: Long): Flow<List<Manga>> {
return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order) }
}
suspend fun getManga(categoryId: Long, offset: Int): List<Manga> { suspend fun getManga(categoryId: Long, offset: Int): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId, offset, 20) val entities = db.favouritesDao.findAll(categoryId, offset, 20)
return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) } return entities.map { it.manga.toManga(it.tags.mapToSet(TagEntity::toMangaTag)) }
@@ -77,25 +79,30 @@ class FavouritesRepository(private val db: MangaDatabase) {
title = title, title = title,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(), sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0 categoryId = 0,
order = SortOrder.UPDATED.name,
) )
val id = db.favouriteCategoriesDao.insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
return entity.toFavouriteCategory(id) return entity.toFavouriteCategory(id)
} }
suspend fun renameCategory(id: Long, title: String) { suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.update(id, title) db.favouriteCategoriesDao.updateTitle(id, title)
} }
suspend fun removeCategory(id: Long) { suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id) db.favouriteCategoriesDao.delete(id)
} }
suspend fun setCategoryOrder(id: Long, order: SortOrder) {
db.favouriteCategoriesDao.updateOrder(id, order.name)
}
suspend fun reorderCategories(orderedIds: List<Long>) { suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao val dao = db.favouriteCategoriesDao
db.withTransaction { db.withTransaction {
for ((i, id) in orderedIds.withIndex()) { 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) { suspend fun removeFromFavourites(manga: Manga) {
db.favouritesDao.delete(manga.id) db.favouritesDao.delete(manga.id)
} }
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
return db.favouriteCategoriesDao.observe(categoryId)
.map { x -> SortOrder.values().find { it.name == x.order } ?: SortOrder.NEWEST }
.distinctUntilChanged()
}
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar 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.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel 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.RecycledViewPoolHolder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu import org.koitharu.kotatsu.utils.ext.showPopupMenu
import java.util.* import java.util.*
import kotlin.collections.ArrayList
class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(), class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback, FavouritesTabLongClickListener, CategoriesEditDelegate.CategoriesEditCallback,
@@ -65,10 +68,22 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.tabs.updatePadding( val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
left = insets.left, binding.root.updatePadding(
right = insets.right top = headerHeight - insets.top
) )
binding.pager.updatePadding(
top = -headerHeight
)
binding.tabs.apply {
updatePadding(
left = insets.left,
right = insets.right
)
updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
} }
private fun onCategoriesChanged(categories: List<FavouriteCategory>) { private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
@@ -100,11 +115,19 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean { override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category 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) { when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category) R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_rename -> editDelegate.renameCategory(category) R.id.action_rename -> editDelegate.renameCategory(category)
R.id.action_create -> editDelegate.createCategory() 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 true
} }
@@ -125,11 +148,26 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesBinding>(),
private fun wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> { private fun wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> {
val data = ArrayList<FavouriteCategory>(categories.size + 1) val data = ArrayList<FavouriteCategory>(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 data += categories
return data 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 { companion object {
fun newInstance() = FavouritesContainerFragment() fun newInstance() = FavouritesContainerFragment()

View File

@@ -36,7 +36,7 @@ class FavouritesPagerAdapter(
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position] val item = differ.currentList[position]
tab.text = item.title tab.text = item.title
tab.view.tag = item tab.view.tag = item.id
tab.view.setOnLongClickListener(this) tab.view.setOnLongClickListener(this)
} }
@@ -45,7 +45,8 @@ class FavouritesPagerAdapter(
} }
override fun onLongClick(v: View): Boolean { 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) return longClickListener.onTabLongClick(v, item)
} }

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.graphics.Insets 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.BaseActivity
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.SortOrder
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.showPopupMenu import org.koitharu.kotatsu.utils.ext.showPopupMenu
@@ -44,6 +46,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
adapter = CategoriesAdapter(this) adapter = CategoriesAdapter(this)
editDelegate = CategoriesEditDelegate(this, this) editDelegate = CategoriesEditDelegate(this, this)
binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL)) binding.recyclerView.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this) binding.fabAdd.setOnClickListener(this)
reorderHelper = ItemTouchHelper(ReorderHelperCallback()) reorderHelper = ItemTouchHelper(ReorderHelperCallback())
@@ -60,10 +63,17 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
} }
override fun onItemClick(item: FavouriteCategory, view: View) { 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) { when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(item) R.id.action_remove -> editDelegate.deleteCategory(item)
R.id.action_rename -> editDelegate.renameCategory(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 true
} }
@@ -116,6 +126,21 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
viewModel.createCategory(name) 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( private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0 ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
) { ) {
@@ -144,6 +169,12 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
companion object { companion object {
val SORT_ORDERS = arrayOf(
SortOrder.ALPHABETICAL,
SortOrder.NEWEST,
SortOrder.RATING,
)
fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java) fun newIntent(context: Context) = Intent(context, CategoriesActivity::class.java)
} }
} }

View File

@@ -6,7 +6,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
class CategoriesAdapter( class CategoriesAdapter(
onItemClickListener: OnListItemClickListener<FavouriteCategory> onItemClickListener: OnListItemClickListener<FavouriteCategory>,
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
init { init {
@@ -20,12 +20,27 @@ class CategoriesAdapter(
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() { private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean { override fun areItemsTheSame(
oldItem: FavouriteCategory,
newItem: FavouriteCategory,
): Boolean {
return oldItem.id == newItem.id 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 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)
} }
} }
} }

View File

@@ -4,10 +4,10 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import org.koitharu.kotatsu.base.ui.BaseViewModel 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.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import java.util.* import java.util.*
import kotlin.collections.ArrayList
class FavouritesCategoriesViewModel( class FavouritesCategoriesViewModel(
private val repository: FavouritesRepository private val repository: FavouritesRepository
@@ -19,23 +19,29 @@ class FavouritesCategoriesViewModel(
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) .asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
fun createCategory(name: String) { fun createCategory(name: String) {
launchJob(Dispatchers.Default) { launchJob {
repository.addCategory(name) repository.addCategory(name)
} }
} }
fun renameCategory(id: Long, name: String) { fun renameCategory(id: Long, name: String) {
launchJob(Dispatchers.Default) { launchJob {
repository.renameCategory(id, name) repository.renameCategory(id, name)
} }
} }
fun deleteCategory(id: Long) { fun deleteCategory(id: Long) {
launchJob(Dispatchers.Default) { launchJob {
repository.removeCategory(id) repository.removeCategory(id)
} }
} }
fun setCategoryOrder(id: Long, order: SortOrder) {
launchJob {
repository.setCategoryOrder(id, order)
}
}
fun reorderCategories(oldPos: Int, newPos: Int) { fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) { reorderJob = launchJob(Dispatchers.Default) {

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga 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.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
@@ -22,12 +23,18 @@ class FavouritesListViewModel(
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
override val content = combine( 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() createListModeFlow()
) { list, mode -> ) { list, mode ->
when { when {
list.isEmpty() -> listOf( list.isEmpty() -> listOf(
EmptyState( EmptyState(
R.drawable.ic_heart_outline,
R.string.text_empty_holder_primary,
if (categoryId == 0L) { if (categoryId == 0L) {
R.string.you_have_not_favourites_yet R.string.you_have_not_favourites_yet
} else { } else {

View File

@@ -20,7 +20,6 @@ import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
class HistoryListViewModel( class HistoryListViewModel(
private val repository: HistoryRepository, private val repository: HistoryRepository,
@@ -44,7 +43,7 @@ class HistoryListViewModel(
createListModeFlow() createListModeFlow()
) { list, grouped, mode -> ) { list, grouped, mode ->
when { when {
list.isEmpty() -> listOf(EmptyState(R.string.text_history_holder)) list.isEmpty() -> listOf(EmptyState(R.drawable.ic_history, R.string.text_history_holder_primary, R.string.text_history_holder_secondary))
else -> mapList(list, grouped, mode) else -> mapList(list, grouped, mode)
} }
}.onFirst { }.onFirst {
@@ -81,8 +80,11 @@ class HistoryListViewModel(
grouped: Boolean, grouped: Boolean,
mode: ListMode mode: ListMode
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size) val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
var prevDate: DateTimeAgo? = null var prevDate: DateTimeAgo? = null
if (!grouped) {
result += ListHeader(null, R.string.history)
}
for ((manga, history) in list) { for ((manga, history) in list) {
if (grouped) { if (grouped) {
val date = timeAgo(history.updatedAt) val date = timeAgo(history.updatedAt)

View File

@@ -4,16 +4,15 @@ import android.os.Bundle
import android.view.* import android.view.*
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -37,11 +36,10 @@ import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.filter.FilterAdapter import org.koitharu.kotatsu.list.ui.filter.FilterAdapter
import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener import org.koitharu.kotatsu.list.ui.filter.OnFilterChangedListener
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.utils.RecycledViewPoolHolder import org.koitharu.kotatsu.utils.RecycledViewPoolHolder
import org.koitharu.kotatsu.utils.ext.clearItemDecorations import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.toggleDrawer
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
abstract class MangaListFragment : BaseFragment<FragmentListBinding>(), abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
PaginationScrollListener.Callback, OnListItemClickListener<Manga>, OnFilterChangedListener, PaginationScrollListener.Callback, OnListItemClickListener<Manga>, OnFilterChangedListener,
@@ -73,7 +71,13 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
drawer = binding.root as? DrawerLayout drawer = binding.root as? DrawerLayout
drawer?.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) 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
)
paginationListener = PaginationScrollListener(4, this) paginationListener = PaginationScrollListener(4, this)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
@@ -81,6 +85,10 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
addOnScrollListener(paginationListener!!) addOnScrollListener(paginationListener!!)
} }
with(binding.swipeRefreshLayout) { with(binding.swipeRefreshLayout) {
setColorSchemeColors(
ContextCompat.getColor(context, R.color.color_primary),
ContextCompat.getColor(context, R.color.color_primary_variant)
)
setOnRefreshListener(this@MangaListFragment) setOnRefreshListener(this@MangaListFragment)
isEnabled = isSwipeRefreshEnabled isEnabled = isSwipeRefreshEnabled
} }
@@ -215,22 +223,29 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
@CallSuper override fun onFilterChanged(filter: MangaFilter) = Unit
override fun onFilterChanged(filter: MangaFilter) {
drawer?.closeDrawers()
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding( val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top
bottom = insets.bottom
)
binding.recyclerViewFilter.updatePadding( binding.recyclerViewFilter.updatePadding(
top = headerHeight,
bottom = insets.bottom bottom = insets.bottom
) )
binding.root.updatePadding( binding.root.updatePadding(
left = insets.left, left = insets.left,
right = insets.right 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) { private fun onGridScaleChanged(scale: Float) {
@@ -246,13 +261,9 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
when (mode) { when (mode) {
ListMode.LIST -> { ListMode.LIST -> {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
addItemDecoration( val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
DividerItemDecoration( addItemDecoration(SpacingItemDecoration(spacing))
context, updatePadding(left = spacing, right = spacing)
RecyclerView.VERTICAL
)
)
updatePadding(left = 0, right = 0)
} }
ListMode.DETAILED_LIST -> { ListMode.DETAILED_LIST -> {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
@@ -282,7 +293,7 @@ abstract class MangaListFragment : BaseFragment<FragmentListBinding>(),
final override fun getSectionTitle(position: Int): CharSequence? { final override fun getSectionTitle(position: Int): CharSequence? {
return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) { return when (binding.recyclerViewFilter.adapter?.getItemViewType(position)) {
FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order) FilterAdapter.VIEW_TYPE_SORT -> getString(R.string.sort_order)
FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genre) FilterAdapter.VIEW_TYPE_TAG -> getString(R.string.genres)
else -> null else -> null
} }
} }

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -36,6 +37,8 @@ abstract class MangaListViewModel(
} }
} }
open fun onRemoveFilterTag(tag: MangaTag) = Unit
abstract fun onRefresh() abstract fun onRefresh()
abstract fun onRetry() abstract fun onRetry()

View File

@@ -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<CurrentFilterModel, ListModel, ItemCurrentFilterBinding>(
{ 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)
}
}

View File

@@ -1,14 +1,23 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.widget.TextView import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
fun emptyStateListAD() = adapterDelegate<EmptyState, ListModel>(R.layout.item_empty_state) { fun emptyStateListAD() = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }
) {
bind { bind {
(itemView as TextView).setText(item.text) with(binding.icon) {
setImageResource(item.icon)
}
with(binding.textPrimary) {
setText(item.textPrimary)
}
with(binding.textSecondary) {
setText(item.textSecondary)
}
} }
} }

View File

@@ -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<ListHeader, ListModel>(R.layout.item_header) {
bind {
val textView = (itemView as TextView)
if (item.text != null) {
textView.text = item.text
} else {
textView.setText(item.textRes)
}
}
}

View File

@@ -6,6 +6,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.Manga 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.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel
@@ -17,7 +18,8 @@ class MangaListAdapter(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>, clickListener: OnListItemClickListener<Manga>,
onRetryClick: (Throwable) -> Unit onRetryClick: (Throwable) -> Unit,
onTagRemoveClick: (MangaTag) -> Unit,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) { ) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init { init {
@@ -37,6 +39,8 @@ class MangaListAdapter(
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(onRetryClick)) .addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(onRetryClick))
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(onRetryClick)) .addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(onRetryClick))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD()) .addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD())
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
.addDelegate(ITEM_TYPE_FILTER, currentFilterAD(onTagRemoveClick))
} }
fun setItems(list: List<ListModel>, commitCallback: Runnable) { fun setItems(list: List<ListModel>, commitCallback: Runnable) {
@@ -77,5 +81,7 @@ class MangaListAdapter(
const val ITEM_TYPE_ERROR_STATE = 6 const val ITEM_TYPE_ERROR_STATE = 6
const val ITEM_TYPE_ERROR_FOOTER = 7 const val ITEM_TYPE_ERROR_FOOTER = 7
const val ITEM_TYPE_EMPTY = 8 const val ITEM_TYPE_EMPTY = 8
const val ITEM_TYPE_HEADER = 9
const val ITEM_TYPE_FILTER = 10
} }
} }

View File

@@ -6,20 +6,15 @@ import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaFilter import org.koitharu.kotatsu.core.model.MangaFilter
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.core.model.SortOrder import org.koitharu.kotatsu.core.model.SortOrder
import java.util.*
import kotlin.collections.ArrayList
class FilterAdapter( class FilterAdapter(
sortOrders: List<SortOrder> = emptyList(), private val sortOrders: List<SortOrder> = emptyList(),
tags: List<MangaTag> = emptyList(), private val tags: List<MangaTag> = emptyList(),
state: MangaFilter?, state: MangaFilter?,
private val listener: OnFilterChangedListener private val listener: OnFilterChangedListener
) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() { ) : RecyclerView.Adapter<BaseViewHolder<*, Boolean, *>>() {
private val sortOrders = ArrayList<SortOrder>(sortOrders) private var currentState = state ?: MangaFilter(sortOrders.firstOrNull(), emptySet())
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) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
VIEW_TYPE_SORT -> FilterSortHolder(parent).apply { VIEW_TYPE_SORT -> FilterSortHolder(parent).apply {
@@ -29,7 +24,7 @@ class FilterAdapter(
} }
VIEW_TYPE_TAG -> FilterTagHolder(parent).apply { VIEW_TYPE_TAG -> FilterTagHolder(parent).apply {
itemView.setOnClickListener { itemView.setOnClickListener {
setCheckedTag(boundData) setCheckedTag(boundData ?: return@setOnClickListener, !isChecked)
} }
} }
else -> throw IllegalArgumentException("Unknown viewType $viewType") else -> throw IllegalArgumentException("Unknown viewType $viewType")
@@ -45,7 +40,7 @@ class FilterAdapter(
} }
is FilterTagHolder -> { is FilterTagHolder -> {
val item = tags[position - sortOrders.size] val item = tags[position - sortOrders.size]
holder.bind(item, item == currentState.tag) holder.bind(item, item in currentState.tags)
} }
} }
} }
@@ -55,19 +50,25 @@ class FilterAdapter(
else -> VIEW_TYPE_TAG else -> VIEW_TYPE_TAG
} }
fun setCheckedTag(tag: MangaTag?) { fun setCheckedTag(tag: MangaTag, isChecked: Boolean) {
if (tag != currentState.tag) { currentState = if (tag in currentState.tags) {
val oldItemPos = tags.indexOf(currentState.tag) if (!isChecked) {
val newItemPos = tags.indexOf(tag) currentState.copy(tags = currentState.tags - tag)
currentState = currentState.copy(tag = tag) } else {
if (oldItemPos in tags.indices) { return
notifyItemChanged(sortOrders.size + oldItemPos)
} }
if (newItemPos in tags.indices) { } else {
notifyItemChanged(sortOrders.size + newItemPos) if (isChecked) {
currentState.copy(tags = currentState.tags + tag)
} else {
return
} }
listener.onFilterChanged(currentState)
} }
val index = tags.indexOf(tag)
if (index in tags.indices) {
notifyItemChanged(sortOrders.size + index)
}
listener.onFilterChanged(currentState)
} }
fun setCheckedSort(sort: SortOrder) { fun setCheckedSort(sort: SortOrder) {

View File

@@ -12,7 +12,7 @@ class FilterSortHolder(parent: ViewGroup) :
) { ) {
override fun onBind(data: SortOrder, extra: Boolean) { override fun onBind(data: SortOrder, extra: Boolean) {
binding.radio.setText(data.titleRes) binding.root.setText(data.titleRes)
binding.radio.isChecked = extra binding.root.isChecked = extra
} }
} }

View File

@@ -2,18 +2,20 @@ package org.koitharu.kotatsu.list.ui.filter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.BaseViewHolder import org.koitharu.kotatsu.base.ui.list.BaseViewHolder
import org.koitharu.kotatsu.core.model.MangaTag import org.koitharu.kotatsu.core.model.MangaTag
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
class FilterTagHolder(parent: ViewGroup) : class FilterTagHolder(parent: ViewGroup) :
BaseViewHolder<MangaTag?, Boolean, ItemCheckableSingleBinding>( BaseViewHolder<MangaTag, Boolean, ItemCheckableMultipleBinding>(
ItemCheckableSingleBinding.inflate(LayoutInflater.from(parent.context), parent, false) ItemCheckableMultipleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
) { ) {
override fun onBind(data: MangaTag?, extra: Boolean) { val isChecked: Boolean
binding.radio.text = data?.title ?: context.getString(R.string.all) get() = binding.root.isChecked
binding.radio.isChecked = extra
override fun onBind(data: MangaTag, extra: Boolean) {
binding.root.text = data.title
binding.root.isChecked = extra
} }
} }

View File

@@ -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<ChipsView.ChipModel>,
) : ListModel

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
data class EmptyState( data class EmptyState(
@StringRes val text: Int @DrawableRes val icon: Int,
@StringRes val textPrimary: Int,
@StringRes val textSecondary: Int
) : ListModel ) : ListModel

View File

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

View File

@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException import org.koitharu.kotatsu.core.exceptions.resolve.ResolvableException
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import kotlin.math.roundToInt
fun Manga.toListModel() = MangaListModel( fun Manga.toListModel() = MangaListModel(
id = id, id = id,
@@ -20,7 +19,7 @@ fun Manga.toListDetailedModel() = MangaListDetailedModel(
id = id, id = id,
title = title, title = title,
subtitle = altTitle, subtitle = altTitle,
rating = if (rating == Manga.NO_RATING) null else "${(rating * 10).roundToInt()}/10", rating = if (rating == Manga.NO_RATING) null else String.format("%.1f", rating * 5),
tags = tags.joinToString(", ") { it.title }, tags = tags.joinToString(", ") { it.title },
coverUrl = coverUrl, coverUrl = coverUrl,
manga = this manga = this

View File

@@ -15,5 +15,5 @@ val localModule
single { LocalMangaRepository(androidContext()) } single { LocalMangaRepository(androidContext()) }
factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() } factory<MangaRepository>(named(MangaSource.LOCAL)) { get<LocalMangaRepository>() }
viewModel { LocalListViewModel(get(), get(), get(), get(), androidContext()) } viewModel { LocalListViewModel(get(), get(), get(), get()) }
} }

View File

@@ -20,19 +20,22 @@ class CbzFetcher : Fetcher<Uri> {
pool: BitmapPool, pool: BitmapPool,
data: Uri, data: Uri,
size: Size, size: Size,
options: Options options: Options,
): FetchResult { ): FetchResult {
val zip = ZipFile(data.schemeSpecificPart) val zip = ZipFile(data.schemeSpecificPart)
val entry = zip.getEntry(data.fragment) val entry = zip.getEntry(data.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name) val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
return SourceResult( return SourceResult(
source = zip.getInputStream(entry).source().buffer(), source = ExtraCloseableBufferedSource(
zip.getInputStream(entry).source().buffer(),
zip,
),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext), mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext),
dataSource = DataSource.DISK 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" override fun handles(data: Uri) = data.scheme == "cbz"
} }

View File

@@ -7,7 +7,7 @@ import java.util.*
class CbzFilter : FilenameFilter { class CbzFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean { 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" return ext == "cbz" || ext == "zip"
} }
} }

View File

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

View File

@@ -4,7 +4,7 @@ import android.content.Context
import com.tomclaw.cache.DiskLruCache import com.tomclaw.cache.DiskLruCache
import org.koitharu.kotatsu.utils.FileSizeUtils import org.koitharu.kotatsu.utils.FileSizeUtils
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.sub import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
@@ -13,8 +13,10 @@ import java.io.OutputStream
class PagesCache(context: Context) { class PagesCache(context: Context) {
private val cacheDir = context.externalCacheDir ?: context.cacheDir private val cacheDir = context.externalCacheDir ?: context.cacheDir
private val lruCache = private val lruCache = DiskLruCache.create(
DiskLruCache.create(cacheDir.sub(Cache.PAGES.dir), FileSizeUtils.mbToBytes(200)) cacheDir.subdir(Cache.PAGES.dir),
FileSizeUtils.mbToBytes(200)
)
operator fun get(url: String): File? { operator fun get(url: String): File? {
return lruCache.get(url)?.takeIfReadable() return lruCache.get(url)?.takeIfReadable()
@@ -22,7 +24,7 @@ class PagesCache(context: Context) {
@Deprecated("Useless lambda") @Deprecated("Useless lambda")
fun put(url: String, writer: (OutputStream) -> Unit): File { fun put(url: String, writer: (OutputStream) -> Unit): File {
val file = cacheDir.sub(url.longHashCode().toString()) val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use(writer) file.outputStream().use(writer)
val res = lruCache.put(url, file) val res = lruCache.put(url, file)
file.delete() file.delete()
@@ -30,7 +32,7 @@ class PagesCache(context: Context) {
} }
fun put(url: String, inputStream: InputStream): File { fun put(url: String, inputStream: InputStream): File {
val file = cacheDir.sub(url.longHashCode().toString()) val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use { out -> file.outputStream().use { out ->
inputStream.copyTo(out) inputStream.copyTo(out)
} }

View File

@@ -15,9 +15,7 @@ import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.MangaZip import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.utils.AlphanumComparator import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.sub
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
@@ -27,17 +25,16 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
private val filenameFilter = CbzFilter() private val filenameFilter = CbzFilter()
override suspend fun getList( override suspend fun getList2(
offset: Int, offset: Int,
query: String?, query: String?,
sortOrder: SortOrder?, tags: Set<MangaTag>?,
tag: MangaTag? sortOrder: SortOrder?
): List<Manga> { ): List<Manga> {
require(offset == 0) { require(offset == 0) {
"LocalMangaRepository does not support pagination" "LocalMangaRepository does not support pagination"
} }
val files = getAvailableStorageDirs(context) val files = getAllFiles()
.flatMap { x -> x.listFiles(filenameFilter)?.toList().orEmpty() }
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() } return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
} }
@@ -78,9 +75,9 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
} }
} }
fun delete(manga: Manga): Boolean { suspend fun delete(manga: Manga): Boolean {
val file = Uri.parse(manga.url).toFile() val file = Uri.parse(manga.url).toFile()
return file.delete() return file.deleteAwait()
} }
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@@ -98,11 +95,14 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
entryName = index.getCoverEntry() entryName = index.getCoverEntry()
?: findFirstEntry(zip.entries(), isImage = true)?.name.orEmpty() ?: 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 // fallback
val title = file.nameWithoutExtension.replace("_", " ").capitalize() val title = file.nameWithoutExtension.replace("_", " ").toCamelCase()
val chapters = ArraySet<String>() val chapters = ArraySet<String>()
for (x in zip.entries()) { for (x in zip.entries()) {
if (!x.isDirectory) { if (!x.isDirectory) {
@@ -120,7 +120,7 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s -> chapters = chapters.sortedWith(AlphanumComparator()).mapIndexed { i, s ->
MangaChapter( MangaChapter(
id = "$i$s".longHashCode(), id = "$i$s".longHashCode(),
name = if (s.isEmpty()) title else s, name = s.ifEmpty { title },
number = i + 1, number = i + 1,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
url = uriBuilder.fragment(s).build().toString() url = uriBuilder.fragment(s).build().toString()
@@ -134,13 +134,36 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
Uri.parse(localManga.url).toFile() Uri.parse(localManga.url).toFile()
}.getOrNull() ?: return null }.getOrNull() ?: return null
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val zip = ZipFile(file) @Suppress("BlockingMethodInNonBlockingContext")
val entry = zip.getEntry(MangaZip.INDEX_ENTRY) ZipFile(file).use { zip ->
val index = entry?.let(zip::readText)?.let(::MangaIndex) ?: return@withContext null val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
index.getMangaInfo() 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) = private fun zipUri(file: File, entryName: String) =
Uri.fromParts("cbz", file.path, entryName).toString() Uri.fromParts("cbz", file.path, entryName).toString()
@@ -165,20 +188,26 @@ class LocalMangaRepository(private val context: Context) : MangaRepository {
override suspend fun getTags() = emptySet<MangaTag>() override suspend fun getTags() = emptySet<MangaTag>()
private fun getAllFiles() = getAvailableStorageDirs(context).flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty()
}
companion object { companion object {
private const val DIR_NAME = "manga" private const val DIR_NAME = "manga"
fun isFileSupported(name: String): Boolean { fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').toLowerCase(Locale.ROOT) val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip" return ext == "cbz" || ext == "zip"
} }
fun getAvailableStorageDirs(context: Context): List<File> { fun getAvailableStorageDirs(context: Context): List<File> {
val result = ArrayList<File>(5) val result = ArrayList<File?>(5)
result += context.filesDir.sub(DIR_NAME) result += File(context.filesDir, DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME) result += context.getExternalFilesDirs(DIR_NAME)
return result.distinctBy { it.canonicalPath }.filter { it.exists() || it.mkdir() } return result.filterNotNull()
.distinctBy { it.canonicalPath }
.filter { it.exists() || it.mkdir() }
} }
fun getFallbackStorageDir(context: Context): File? { fun getFallbackStorageDir(context: Context): File? {

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.local.ui package org.koitharu.kotatsu.local.ui
import android.content.ActivityNotFoundException import android.content.*
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
@@ -15,6 +15,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.Manga 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.list.ui.MangaListFragment
import org.koitharu.kotatsu.utils.ext.ellipsize import org.koitharu.kotatsu.utils.ext.ellipsize
@@ -25,12 +26,32 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
ActivityResultContracts.OpenDocument(), ActivityResultContracts.OpenDocument(),
this 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved) viewModel.onMangaRemoved.observe(viewLifecycleOwner, ::onItemRemoved)
} }
override fun onDetach() {
requireContext().unregisterReceiver(downloadReceiver)
super.onDetach()
}
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@@ -65,7 +86,7 @@ class LocalListFragment : MangaListFragment(), ActivityResultCallback<Uri> {
override fun onActivityResult(result: Uri?) { override fun onActivityResult(result: Uri?) {
if (result != null) { if (result != null) {
viewModel.importFile(result) viewModel.importFile(context?.applicationContext ?: return, result)
} }
} }

View File

@@ -14,15 +14,12 @@ import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.*
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.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.utils.MediaStoreCompat import org.koitharu.kotatsu.utils.MediaStoreCompat
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.sub import java.io.File
import java.io.IOException import java.io.IOException
class LocalListViewModel( class LocalListViewModel(
@@ -30,12 +27,12 @@ class LocalListViewModel(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val shortcutsRepository: ShortcutsRepository, private val shortcutsRepository: ShortcutsRepository,
private val context: Context
) : MangaListViewModel(settings) { ) : MangaListViewModel(settings) {
val onMangaRemoved = SingleLiveEvent<Manga>() val onMangaRemoved = SingleLiveEvent<Manga>()
private val listError = MutableStateFlow<Throwable?>(null) private val listError = MutableStateFlow<Throwable?>(null)
private val mangaList = MutableStateFlow<List<Manga>?>(null) private val mangaList = MutableStateFlow<List<Manga>?>(null)
private val headerModel = ListHeader(null, R.string.local_storage)
override val content = combine( override val content = combine(
mangaList, mangaList,
@@ -45,8 +42,11 @@ class LocalListViewModel(
when { when {
error != null -> listOf(error.toErrorState(canRetry = true)) error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState) list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(EmptyState(R.string.text_local_holder)) 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<ListModel>(list.size + 1).apply {
add(headerModel)
list.toUi(this, mode)
}
} }
}.asLiveDataDistinct( }.asLiveDataDistinct(
viewModelScope.coroutineContext + Dispatchers.Default, viewModelScope.coroutineContext + Dispatchers.Default,
@@ -61,7 +61,7 @@ class LocalListViewModel(
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
try { try {
listError.value = null listError.value = null
mangaList.value = repository.getList(0) mangaList.value = repository.getList2(0)
} catch (e: Throwable) { } catch (e: Throwable) {
listError.value = e listError.value = e
} }
@@ -70,7 +70,7 @@ class LocalListViewModel(
override fun onRetry() = onRefresh() override fun onRetry() = onRefresh()
fun importFile(uri: Uri) { fun importFile(context: Context, uri: Uri) {
launchLoadingJob { launchLoadingJob {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -79,8 +79,9 @@ class LocalListViewModel(
if (!LocalMangaRepository.isFileSupported(name)) { if (!LocalMangaRepository.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri") throw UnsupportedFileException("Unsupported file on $uri")
} }
val dest = settings.getStorageDir(context)?.sub(name) val dest = settings.getStorageDir(context)?.let { File(it, name) }
?: throw IOException("External files dir unavailable") ?: throw IOException("External files dir unavailable")
@Suppress("BlockingMethodInNonBlockingContext")
contentResolver.openInputStream(uri)?.use { source -> contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output -> dest.outputStream().use { output ->
source.copyTo(output) source.copyTo(output)

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.main.ui
import com.google.android.material.appbar.AppBarLayout
interface AppBarOwner {
val appBar: AppBarLayout
}

View File

@@ -6,79 +6,130 @@ import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams
import androidx.appcompat.app.ActionBarDrawerToggle 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.graphics.Insets
import androidx.core.view.* import androidx.core.view.*
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment 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.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.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.Manga import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSection 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.ActivityMainBinding
import org.koitharu.kotatsu.databinding.NavigationHeaderBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment import org.koitharu.kotatsu.favourites.ui.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.search.ui.SearchHelper import org.koitharu.kotatsu.search.ui.SearchActivity
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.settings.AppUpdateChecker import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.tracker.ui.FeedFragment import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.resolveDp
import java.io.Closeable
class MainActivity : BaseActivity<ActivityMainBinding>(), class MainActivity : BaseActivity<ActivityMainBinding>(),
NavigationView.OnNavigationItemSelectedListener, NavigationView.OnNavigationItemSelectedListener, AppBarOwner,
View.OnClickListener { View.OnClickListener, View.OnFocusChangeListener, SearchSuggestionListener {
private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE) private val viewModel by viewModel<MainViewModel>(mode = LazyThreadSafetyMode.NONE)
private val searchSuggestionViewModel by viewModel<SearchSuggestionViewModel>(
mode = LazyThreadSafetyMode.NONE
)
private lateinit var navHeaderBinding: NavigationHeaderBinding
private lateinit var drawerToggle: ActionBarDrawerToggle private lateinit var drawerToggle: ActionBarDrawerToggle
private var closeable: Closeable? = null private var searchViewElevation = 0f
override val appBar: AppBarLayout
get() = binding.appbar
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityMainBinding.inflate(layoutInflater)) setContentView(ActivityMainBinding.inflate(layoutInflater))
drawerToggle = searchViewElevation = binding.toolbarCard.cardElevation
ActionBarDrawerToggle( navHeaderBinding = NavigationHeaderBinding.inflate(layoutInflater)
this, drawerToggle = ActionBarDrawerToggle(
binding.drawer, this,
binding.toolbar, binding.drawer,
R.string.open_menu, binding.toolbar,
R.string.close_menu 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) binding.drawer.addDrawerListener(drawerToggle)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
binding.navigationView.setNavigationItemSelectedListener(this) if (get<AppSettings>().isAmoledTheme && get<AppSettings>().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
}
with(binding.navigationView) {
val menuView =
findViewById<RecyclerView>(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 = systemWindowInsets.bottom)
insets
}
addHeaderView(navHeaderBinding.root)
itemBackground = navigationItemBackground(context)
setNavigationItemSelectedListener(this@MainActivity)
}
with(binding.fab) { with(binding.fab) {
imageTintList = ColorStateList.valueOf(Color.WHITE) imageTintList = ColorStateList.valueOf(Color.WHITE)
setOnClickListener(this@MainActivity) setOnClickListener(this@MainActivity)
} }
supportFragmentManager.findFragmentById(R.id.container)?.let { supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
binding.fab.isVisible = it is HistoryListFragment binding.fab.isVisible = it is HistoryListFragment
} ?: run { } ?: run {
openDefaultSection() openDefaultSection()
} }
if (savedInstanceState == null) { if (savedInstanceState == null) {
TrackWorker.setup(applicationContext) onFirstStart()
SuggestionsWorker.setup(applicationContext)
AppUpdateChecker(this).launchIfNeeded()
} }
viewModel.onOpenReader.observe(this, this::onOpenReader) viewModel.onOpenReader.observe(this, this::onOpenReader)
@@ -87,9 +138,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
viewModel.remoteSources.observe(this, this::updateSideMenu) viewModel.remoteSources.observe(this, this::updateSideMenu)
} }
override fun onDestroy() { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
closeable?.close() super.onRestoreInstanceState(savedInstanceState)
super.onDestroy() drawerToggle.isDrawerIndicatorEnabled =
binding.drawer.getDrawerLockMode(GravityCompat.START) == DrawerLayout.LOCK_MODE_UNLOCKED
} }
override fun onPostCreate(savedInstanceState: Bundle?) { override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -103,21 +155,20 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
} }
override fun onBackPressed() { override fun onBackPressed() {
if (binding.drawer.isDrawerOpen(binding.navigationView)) { val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
binding.drawer.closeDrawer(binding.navigationView) binding.searchView.clearFocus()
} else { when {
super.onBackPressed() 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()
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_main, menu)
menu.findItem(R.id.action_search)?.let { menuItem ->
closeable = SearchHelper.setupSearchView(menuItem)
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return drawerToggle.onOptionsItemSelected(item) || when (item.itemId) { return drawerToggle.onOptionsItemSelected(item) || when (item.itemId) {
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
@@ -134,48 +185,99 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
if (item.groupId == R.id.group_remote_sources) { if (item.groupId == R.id.group_remote_sources) {
val source = MangaSource.values().getOrNull(item.itemId) ?: return false val source = MangaSource.values().getOrNull(item.itemId) ?: return false
setPrimaryFragment(RemoteListFragment.newInstance(source)) setPrimaryFragment(RemoteListFragment.newInstance(source))
} else when (item.itemId) { searchSuggestionViewModel.onSourceChanged(source)
R.id.nav_history -> { } else {
viewModel.defaultSection = AppSection.HISTORY searchSuggestionViewModel.onSourceChanged(null)
setPrimaryFragment(HistoryListFragment.newInstance()) when (item.itemId) {
R.id.nav_history -> {
viewModel.defaultSection = AppSection.HISTORY
setPrimaryFragment(HistoryListFragment.newInstance())
}
R.id.nav_favourites -> {
viewModel.defaultSection = AppSection.FAVOURITES
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
R.id.nav_local_storage -> {
viewModel.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_suggestions -> {
viewModel.defaultSection = AppSection.SUGGESTIONS
setPrimaryFragment(SuggestionsFragment.newInstance())
}
R.id.nav_feed -> {
viewModel.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
}
R.id.nav_action_settings -> {
startActivity(SettingsActivity.newIntent(this))
return true
}
else -> return false
} }
R.id.nav_favourites -> {
viewModel.defaultSection = AppSection.FAVOURITES
setPrimaryFragment(FavouritesContainerFragment.newInstance())
}
R.id.nav_local_storage -> {
viewModel.defaultSection = AppSection.LOCAL
setPrimaryFragment(LocalListFragment.newInstance())
}
R.id.nav_suggestions -> {
viewModel.defaultSection = AppSection.SUGGESTIONS
setPrimaryFragment(SuggestionsFragment.newInstance())
}
R.id.nav_feed -> {
viewModel.defaultSection = AppSection.FEED
setPrimaryFragment(FeedFragment.newInstance())
}
R.id.nav_action_settings -> {
startActivity(SettingsActivity.newIntent(this))
return true
}
else -> return false
} }
binding.drawer.closeDrawers() binding.drawer.closeDrawers()
return true return true
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.toolbar.updatePadding( binding.toolbarCard.updateLayoutParams<MarginLayoutParams> {
top = insets.top, topMargin = insets.top + resources.resolveDp(8)
left = insets.left, }
right = insets.right binding.fab.updateLayoutParams<MarginLayoutParams> {
)
binding.fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.bottom + topMargin bottomMargin = insets.bottom + topMargin
leftMargin = insets.left + topMargin leftMargin = insets.left + topMargin
rightMargin = insets.right + topMargin rightMargin = insets.right + topMargin
} }
binding.container.updateLayoutParams<MarginLayoutParams> {
topMargin = -(binding.appbar.measureHeight())
}
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
if (v?.id == R.id.searchView && hasFocus) {
if (fragment == null) {
supportFragmentManager.commit {
add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchOpened() }
}
}
}
}
override fun onMangaClick(manga: Manga) {
startActivity(DetailsActivity.newIntent(this, manga))
}
override fun onQueryClick(query: String, submit: Boolean) {
binding.searchView.query = query
if (submit) {
if (query.isNotEmpty()) {
val source = searchSuggestionViewModel.getLocalSearchSource()
if (source != null) {
startActivity(SearchActivity.newIntent(this, source, query))
} else {
startActivity(GlobalSearchActivity.newIntent(this, query))
}
searchSuggestionViewModel.saveQuery(query)
}
}
}
override fun onQueryChanged(query: String) {
searchSuggestionViewModel.onQueryChanged(query)
}
override fun onClearSearchHistory() {
AlertDialog.Builder(this)
.setTitle(R.string.clear_search_history)
.setMessage(R.string.text_clear_search_history_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
searchSuggestionViewModel.clearSearchHistory()
}.show()
} }
private fun onOpenReader(manga: Manga) { private fun onOpenReader(manga: Manga) {
@@ -245,8 +347,62 @@ class MainActivity : BaseActivity<ActivityMainBinding>(),
private fun setPrimaryFragment(fragment: Fragment) { private fun setPrimaryFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment) .replace(R.id.container, fragment, TAG_PRIMARY)
.commit() .commit()
binding.fab.isVisible = fragment is HistoryListFragment 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<MarginLayoutParams> {
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<MarginLayoutParams> {
leftMargin = resources.resolveDp(16)
rightMargin = resources.resolveDp(16)
}
}
}
private fun onFirstStart() {
TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext)
AppUpdateChecker(this@MainActivity).launchIfNeeded()
OnboardDialogFragment.showWelcome(get(), supportFragmentManager)
}
private companion object {
const val TAG_PRIMARY = "primary"
const val TAG_SEARCH = "search"
}
} }

View File

@@ -3,9 +3,7 @@ package org.koitharu.kotatsu.reader
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaDataRepository import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ReaderViewModel
val readerModule val readerModule
@@ -14,7 +12,7 @@ val readerModule
single { MangaDataRepository(get()) } single { MangaDataRepository(get()) }
single { PagesCache(get()) } single { PagesCache(get()) }
viewModel { (intent: MangaIntent, state: ReaderState?) -> viewModel { params ->
ReaderViewModel(intent, state, get(), get(), get(), get()) ReaderViewModel(params[0], params[1], get(), get(), get(), get())
} }
} }

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