Merge branch 'devel' into feature/suggestions
This commit is contained in:
1
.idea/codeStyles/Project.xml
generated
1
.idea/codeStyles/Project.xml
generated
@@ -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
1
.idea/gradle.xml
generated
@@ -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
51
.idea/misc.xml
generated
@@ -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>
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
  [](https://travis-ci.org/nv95/Kotatsu)  [](http://4pda.ru/forum/index.php?showtopic=697669)
|
  [](https://travis-ci.org/nv95/Kotatsu)  [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669)
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ val databaseModule
|
|||||||
Migration5To6(),
|
Migration5To6(),
|
||||||
Migration6To7(),
|
Migration6To7(),
|
||||||
Migration7To8(),
|
Migration7To8(),
|
||||||
|
Migration8To9(),
|
||||||
).addCallback(
|
).addCallback(
|
||||||
DatabasePrePopulateCallback(androidContext().resources)
|
DatabasePrePopulateCallback(androidContext().resources)
|
||||||
).build()
|
).build()
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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() {
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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("")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
interface MangaRepositoryAuthProvider {
|
||||||
|
|
||||||
|
val authUrl: String
|
||||||
|
|
||||||
|
fun isAuthorized(): Boolean
|
||||||
|
}
|
||||||
@@ -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()) }
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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('/'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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?
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()) }
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.koitharu.kotatsu.main.ui
|
||||||
|
|
||||||
|
import com.google.android.material.appbar.AppBarLayout
|
||||||
|
|
||||||
|
interface AppBarOwner {
|
||||||
|
|
||||||
|
val appBar: AppBarLayout
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user