Compare commits

..

5 Commits

Author SHA1 Message Date
5436c65b76 Update about settings 2023-04-19 18:26:19 +03:00
c590813a1a Filter GitHub assets by type 2023-04-19 18:24:50 +03:00
1cf36e1b41 Fix domain validator 2023-04-19 18:24:14 +03:00
5895a20af1 Update shikimori domain 2023-04-19 18:24:00 +03:00
fd5fd43b72 Update parsers 2023-04-19 18:23:36 +03:00
534 changed files with 10555 additions and 13761 deletions

View File

@@ -2,12 +2,17 @@
Kotatsu is a free and open source manga reader for Android.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
### Download
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/org.koitharu.kotatsu)
Download APK directly from GitHub:
- **[Latest release](https://github.com/KotatsuApp/Kotatsu/releases/latest)**
### Main Features

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 547
versionName '5.1.3'
versionCode 525
versionName '4.4.9'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -44,17 +44,18 @@ android {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi',
'-opt-in=com.google.android.material.badge.ExperimentalBadgeUtils',
]
}
lint {
@@ -78,42 +79,35 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:fa7ea5b16a') {
implementation('com.github.KotatsuApp:kotatsu-parsers:1b6d1456f3') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.21'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-ktx:1.7.2'
implementation 'androidx.fragment:fragment-ktx:1.5.7'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1'
implementation 'androidx.lifecycle:lifecycle-process:2.6.1'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.5'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.0'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.8.0'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0'
implementation 'com.google.android.material:material:1.8.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
implementation 'androidx.work:work-runtime-ktx:2.8.1'
//noinspection GradleDependency
implementation('com.google.guava:guava:31.1-android') {
exclude group: 'com.google.guava', module: 'failureaccess'
exclude group: 'org.checkerframework', module: 'checker-qual'
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
}
implementation 'androidx.room:room-runtime:2.5.1'
implementation 'androidx.room:room-ktx:2.5.1'
kapt 'androidx.room:room-compiler:2.5.1'
implementation 'androidx.room:room-runtime:2.5.0'
implementation 'androidx.room:room-ktx:2.5.0'
kapt 'androidx.room:room-compiler:2.5.0'
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
@@ -122,13 +116,13 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.46.1'
kapt 'com.google.dagger:hilt-compiler:2.46.1'
implementation 'com.google.dagger:hilt-android:2.45'
kapt 'com.google.dagger:hilt-compiler:2.45'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.3.0'
implementation 'io.coil-kt:coil-svg:2.3.0'
implementation 'io.coil-kt:coil-base:2.2.2'
implementation 'io.coil-kt:coil-svg:2.2.2'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:1b19231b2f'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
@@ -136,22 +130,22 @@ dependencies {
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20230227'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.room:room-testing:2.5.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'androidx.room:room-testing:2.5.0'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.45'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.45'
}

View File

@@ -10,10 +10,7 @@
}
-keep public class ** extends org.koitharu.kotatsu.base.ui.BaseFragment
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
-dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-dontwarn okhttp3.internal.platform.ConscryptPlatform
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

10
app/sampledata/genres Normal file
View File

@@ -0,0 +1,10 @@
Slice of Life, Mystery
Slice of Life, Mystery
Psychological, Romance, Comedy, Slice of Life, Supernatural
Sci-Fi, Comedy
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Reincarnation, Sci-Fi, Historical, Psychological, Drama, Slice of Life, Supernatural, Mystery
Adventure, Slice of Life, Mystery
Adventure, Slice of Life, Mystery

10
app/sampledata/titles Normal file
View File

@@ -0,0 +1,10 @@
Forget-me-not Vol. 1
Forget-me-not Vol. 2
La Pomme Prisoinniere
Momo Kanchou no Himitsu Kichi
Omoide Emanon
Sasurai Emanon Vol. 1
Sasurai Emanon Vol. 2
Sasurai Emanon Vol. 3
Wandering Island Vol. 1
Wandering Island Vol. 2

View File

@@ -5,6 +5,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import java.io.File
import javax.inject.Inject
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
@@ -18,8 +20,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import java.io.File
import javax.inject.Inject
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
@@ -52,7 +52,6 @@ class AppBackupAgentTest {
title = SampleData.favouriteCategory.title,
sortOrder = SampleData.favouriteCategory.order,
isTrackerEnabled = SampleData.favouriteCategory.isTrackingEnabled,
isVisibleOnShelf = SampleData.favouriteCategory.isVisibleInLibrary,
)
favouritesRepository.addToCategory(categoryId = category.id, mangas = listOf(SampleData.manga))
historyRepository.addOrUpdate(

View File

@@ -17,7 +17,7 @@ import java.util.EnumSet
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain()
get() = ConfigKey.Domain("", null)
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
<bool name="is_sync_enabled">true</bool>
</resources>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_type_sync" translatable="false">org.kotatsu.debug.sync</string>
<string name="sync_authority_history" translatable="false">org.koitharu.kotatsu.debug.history</string>
<string name="sync_authority_favourites" translatable="false">org.koitharu.kotatsu.debug.favourites</string>
</resources>

View File

@@ -86,18 +86,7 @@
<activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:exported="true"
android:label="@string/settings">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
<data android:host="about" />
<data android:host="sync-settings" />
</intent-filter>
</activity>
android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -129,7 +118,7 @@
android:name="org.koitharu.kotatsu.settings.protect.ProtectSetupActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="org.koitharu.kotatsu.download.ui.list.DownloadsActivity"
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads"
android:launchMode="singleTop" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
@@ -156,14 +145,16 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
<data android:host="shikimori-auth" />
<data android:host="anilist-auth" />
<data android:host="mal-auth" />
</intent-filter>
</activity>
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service android:name="org.koitharu.kotatsu.local.ui.ImportService" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />
@@ -224,26 +215,20 @@
</provider>
<provider
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncProvider"
android:authorities="@string/sync_authority_favourites"
android:authorities="org.koitharu.kotatsu.favourites"
android:exported="false"
android:label="@string/favourites"
android:syncable="true" />
<provider
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncProvider"
android:authorities="@string/sync_authority_history"
android:authorities="org.koitharu.kotatsu.history"
android:exported="false"
android:label="@string/history"
android:syncable="true" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
tools:node="remove" />
<receiver
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"

View File

@@ -23,8 +23,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.WorkServiceStopHelper
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import javax.inject.Inject
@@ -57,7 +55,6 @@ class KotatsuApp : Application(), Configuration.Provider {
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
}
WorkServiceStopHelper(applicationContext).setup()
}
override fun attachBaseContext(base: Context?) {
@@ -128,7 +125,6 @@ class KotatsuApp : Application(), Configuration.Provider {
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.setClassInstanceLimit(PageLoader::class.java, 1)
.penaltyLog()
.build(),
)

View File

@@ -4,7 +4,6 @@ import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Size
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -35,7 +34,6 @@ import kotlin.math.roundToInt
private const val MIN_WEBTOON_RATIO = 2
@Reusable
class MangaDataRepository @Inject constructor(
private val okHttpClient: OkHttpClient,
private val db: MangaDatabase,
@@ -128,7 +126,7 @@ class MangaDataRepository @Inject constructor(
.url(url)
.get()
.tag(MangaSource::class.java, page.source)
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.build()
okHttpClient.newCall(request).await().use {
runInterruptible(Dispatchers.IO) {

View File

@@ -3,17 +3,15 @@ package org.koitharu.kotatsu.base.domain
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
class MangaIntent private constructor(
@JvmField val manga: Manga?,
@JvmField val mangaId: Long,
@JvmField val uri: Uri?,
val manga: Manga?,
val mangaId: Long,
val uri: Uri?,
) {
constructor(intent: Intent?) : this(
@@ -22,12 +20,6 @@ class MangaIntent private constructor(
uri = intent?.data,
)
constructor(savedStateHandle: SavedStateHandle) : this(
manga = savedStateHandle.get<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = savedStateHandle[KEY_ID] ?: ID_NONE,
uri = savedStateHandle[BaseActivity.EXTRA_DATA],
)
constructor(args: Bundle?) : this(
manga = args?.getParcelableCompat<ParcelableManga>(KEY_MANGA)?.manga,
mangaId = args?.getLong(KEY_ID, ID_NONE) ?: ID_NONE,

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.base.ui
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
@@ -28,51 +26,39 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.base.ui.util.inject
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.getThemeColor
import javax.inject.Inject
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener {
private var isAmoledTheme = false
@Inject
lateinit var settings: AppSettings
protected lateinit var binding: B
private set
@JvmField
@Suppress("LeakingThis")
protected val exceptionResolver = ExceptionResolver(this)
@JvmField
@Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
@JvmField
val actionModeDelegate = ActionModeDelegate()
private var defaultStatusBarColor = Color.TRANSPARENT
override fun onCreate(savedInstanceState: Bundle?) {
val settings = EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).settings
isAmoledTheme = settings.isAmoledTheme
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this)
setTheme(settings.colorScheme.styleResId)
if (isAmoledTheme) {
if (settings.isAmoledTheme) {
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
putDataToExtras(intent)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(actionModeDelegate)
}
override fun onNewIntent(intent: Intent?) {
putDataToExtras(intent)
super.onNewIntent(intent)
}
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
@@ -116,21 +102,17 @@ abstract class BaseActivity<B : ViewBinding> :
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 && isAmoledTheme
return isNight && settings.isAmoledTheme
}
@CallSuper
override fun onSupportActionModeStarted(mode: ActionMode) {
super.onSupportActionModeStarted(mode)
actionModeDelegate.onSupportActionModeStarted(mode)
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
getThemeColor(com.google.android.material.R.attr.colorSurface),
)
} else {
ContextCompat.getColor(this, R.color.kotatsu_secondaryContainer)
}
val actionModeColor = ColorUtils.compositeColors(
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
getThemeColor(com.google.android.material.R.attr.colorSurface),
)
val insets = ViewCompat.getRootWindowInsets(binding.root)
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply {
@@ -139,7 +121,6 @@ abstract class BaseActivity<B : ViewBinding> :
topMargin = insets.top
}
}
defaultStatusBarColor = window.statusBarColor
window.statusBarColor = actionModeColor
}
@@ -147,15 +128,20 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onSupportActionModeFinished(mode: ActionMode) {
super.onSupportActionModeFinished(mode)
actionModeDelegate.onSupportActionModeFinished(mode)
window.statusBarColor = defaultStatusBarColor
window.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
}
private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data)
}
companion object {
const val EXTRA_DATA = "data"
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
@Deprecated("Should not be used")
override fun onBackPressed() {
if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
isTaskRoot &&
supportFragmentManager.backStackEntryCount == 0
) {
finishAfterTransition()
} else {
super.onBackPressed()
}
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui
import android.app.Dialog
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -14,8 +15,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.utils.ext.findActivity
import org.koitharu.kotatsu.utils.ext.getDisplaySize
import org.koitharu.kotatsu.utils.ext.displayCompat
import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@@ -41,20 +41,21 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
): View {
val binding = onInflateView(inflater, container)
viewBinding = binding
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Enforce max width for tablets
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
if (width > 0) {
behavior?.maxWidth = width
}
// Set peek height to 40% display height
binding.root.context.findActivity()?.getDisplaySize()?.let {
behavior?.peekHeight = (it.height() * 0.4).toInt()
// Set peek height to 50% display height
requireContext().displayCompat?.let {
val metrics = DisplayMetrics()
it.getRealMetrics(metrics)
behavior?.peekHeight = (metrics.heightPixels * 0.4).toInt()
}
return binding.root
}
override fun onDestroyView() {

View File

@@ -10,7 +10,6 @@ import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@Suppress("LeakingThis")
abstract class BaseFragment<B : ViewBinding> :
Fragment(),
WindowInsetsDelegate.WindowInsetsListener {
@@ -20,10 +19,10 @@ abstract class BaseFragment<B : ViewBinding> :
protected val binding: B
get() = checkNotNull(viewBinding)
@JvmField
@Suppress("LeakingThis")
protected val exceptionResolver = ExceptionResolver(this)
@JvmField
@Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
protected val actionModeDelegate: ActionModeDelegate

View File

@@ -9,13 +9,12 @@ import androidx.core.view.updatePadding
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
import javax.inject.Inject
@Suppress("LeakingThis")
@AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
@@ -25,7 +24,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@Inject
lateinit var settings: AppSettings
@JvmField
@Suppress("LeakingThis")
protected val insetsDelegate = WindowInsetsDelegate(this)
override val recyclerView: RecyclerView
@@ -56,6 +55,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
)
}
@Suppress("UsePropertyAccessSyntax")
protected fun setTitle(title: CharSequence) {
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
?: activity?.setTitle(title)

View File

@@ -3,24 +3,16 @@ package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.*
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
abstract class BaseViewModel : ViewModel() {
@JvmField
protected val loadingCounter = CountedBooleanLiveData()
@JvmField
protected val errorEvent = SingleLiveEvent<Throwable>()
val onError: LiveData<Throwable>
@@ -54,4 +46,4 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.postCall(throwable)
}
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.base.ui
import android.app.Service
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
@@ -19,7 +20,7 @@ abstract class CoroutineIntentService : BaseService() {
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
launchCoroutine(intent, startId)
return START_REDELIVER_INTENT
return Service.START_REDELIVER_INTENT
}
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {

View File

@@ -1,93 +0,0 @@
package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.R
class RecyclerViewAlertDialog private constructor(
private val delegate: AlertDialog
) : DialogInterface by delegate {
fun show() = delegate.show()
class Builder<T>(context: Context) {
private val recyclerView = RecyclerView(context)
private val delegatesManager = AdapterDelegatesManager<List<T>>()
private var items: List<T>? = null
private val delegate = MaterialAlertDialogBuilder(context)
.setView(recyclerView)
init {
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.updatePadding(
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
)
recyclerView.clipToPadding = false
}
fun setTitle(@StringRes titleResId: Int): Builder<T> {
delegate.setTitle(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder<T> {
delegate.setTitle(title)
return this
}
fun setIcon(@DrawableRes iconId: Int): Builder<T> {
delegate.setIcon(iconId)
return this
}
fun setPositiveButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder<T> {
delegate.setPositiveButton(textId, listener)
return this
}
fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder<T> {
delegate.setNegativeButton(textId, listener)
return this
}
fun setCancelable(isCancelable: Boolean): Builder<T> {
delegate.setCancelable(isCancelable)
return this
}
fun addAdapterDelegate(subject: AdapterDelegate<List<T>>): Builder<T> {
delegatesManager.addDelegate(subject)
return this
}
fun setItems(list: List<T>): Builder<T> {
items = list
return this
}
fun create(): RecyclerViewAlertDialog {
recyclerView.adapter = ListDelegationAdapter(delegatesManager).also {
it.items = items
}
return RecyclerViewAlertDialog(delegate.create())
}
}
}

View File

@@ -1,79 +0,0 @@
package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.content.DialogInterface
import android.view.LayoutInflater
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.databinding.DialogTwoButtonsBinding
class TwoButtonsAlertDialog private constructor(
private val delegate: AlertDialog
) : DialogInterface by delegate {
fun show() = delegate.show()
class Builder(context: Context) {
private val binding = DialogTwoButtonsBinding.inflate(LayoutInflater.from(context))
private val delegate = MaterialAlertDialogBuilder(context)
.setView(binding.root)
fun setTitle(@StringRes titleResId: Int): Builder {
binding.title.setText(titleResId)
return this
}
fun setTitle(title: CharSequence): Builder {
binding.title.text = title
return this
}
fun setIcon(@DrawableRes iconId: Int): Builder {
binding.icon.setImageResource(iconId)
return this
}
fun setPositiveButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder {
initButton(binding.button1, DialogInterface.BUTTON_POSITIVE, textId, listener)
return this
}
fun setNegativeButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener? = null
): Builder {
initButton(binding.button2, DialogInterface.BUTTON_NEGATIVE, textId, listener)
return this
}
fun create(): TwoButtonsAlertDialog {
val dialog = delegate.create()
binding.root.tag = dialog
return TwoButtonsAlertDialog(dialog)
}
private fun initButton(
button: MaterialButton,
which: Int,
@StringRes textId: Int,
listener: DialogInterface.OnClickListener?,
) {
button.setText(textId)
button.isVisible = true
button.setOnClickListener {
val dialog = binding.root.tag as DialogInterface
listener?.onClick(dialog, which)
dialog.dismiss()
}
}
}
}

View File

@@ -6,27 +6,26 @@ import androidx.recyclerview.widget.RecyclerView
abstract class BoundsScrollListener(private val offsetTop: Int, private val offsetBottom: Int) :
RecyclerView.OnScrollListener() {
constructor(offset: Int = 0) : this(offset, offset)
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (recyclerView.hasPendingAdapterUpdates()) {
return
}
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
if (firstVisibleItemPosition == RecyclerView.NO_POSITION) {
return
}
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
}
val visibleItemCount = layoutManager.childCount
val totalItemCount = layoutManager.itemCount
if (visibleItemCount + firstVisibleItemPosition >= totalItemCount - offsetBottom) {
onScrolledToEnd(recyclerView)
}
if (firstVisibleItemPosition <= offsetTop) {
onScrolledToStart(recyclerView)
}
}
abstract fun onScrolledToStart(recyclerView: RecyclerView)
abstract fun onScrolledToEnd(recyclerView: RecyclerView)
}
}

View File

@@ -1,64 +0,0 @@
package org.koitharu.kotatsu.base.ui.list
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
import androidx.core.os.BundleCompat
import androidx.core.view.doOnNextLayout
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
import java.util.WeakHashMap
class NestedScrollStateHandle(
savedInstanceState: Bundle?,
private val key: String,
) {
private val storage: SparseArray<Parcelable?> = savedInstanceState?.let {
BundleCompat.getSparseParcelableArray(it, key, Parcelable::class.java)
} ?: SparseArray<Parcelable?>()
private val controllers = Collections.newSetFromMap<Controller>(WeakHashMap())
fun attach(recycler: RecyclerView) = Controller(recycler).also(controllers::add)
fun onSaveInstanceState(outState: Bundle) {
controllers.forEach {
it.saveState()
}
outState.putSparseParcelableArray(key, storage)
}
inner class Controller(
private val recycler: RecyclerView
) {
private var lastPosition: Int = -1
fun onBind(position: Int) {
if (position != lastPosition) {
saveState()
lastPosition = position
storage[position]?.let {
restoreState(it)
}
}
}
fun onRecycled() {
saveState()
lastPosition = -1
}
fun saveState() {
if (lastPosition != -1) {
storage[lastPosition] = recycler.layoutManager?.onSaveInstanceState()
}
}
private fun restoreState(state: Parcelable) {
recycler.doOnNextLayout {
recycler.layoutManager?.onRestoreInstanceState(state)
}
}
}
}

View File

@@ -2,9 +2,9 @@ package org.koitharu.kotatsu.base.ui.list
import android.view.View
fun interface OnListItemClickListener<I> {
interface OnListItemClickListener<I> {
fun onItemClick(item: I, view: View)
fun onItemLongClick(item: I, view: View) = false
}
}

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.base.ui.list
interface OnTipCloseListener<T> {
fun onCloseTip(tip: T)
}

View File

@@ -1,30 +0,0 @@
package org.koitharu.kotatsu.base.ui.list
import androidx.recyclerview.widget.RecyclerView
class ScrollListenerInvalidationObserver(
private val recyclerView: RecyclerView,
private val scrollListener: RecyclerView.OnScrollListener,
) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
invalidateScroll()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
invalidateScroll()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
super.onItemRangeRemoved(positionStart, itemCount)
invalidateScroll()
}
private fun invalidateScroll() {
recyclerView.post {
scrollListener.onScrolled(recyclerView, 0, 0)
}
}
}

View File

@@ -1,11 +1,10 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class ActionModeDelegate : OnBackPressedCallback(false) {
class ActionModeDelegate {
private var activeActionMode: ActionMode? = null
private var listeners: MutableList<ActionModeListener>? = null
@@ -13,19 +12,13 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
val isActionModeStarted: Boolean
get() = activeActionMode != null
override fun handleOnBackPressed() {
activeActionMode?.finish()
}
fun onSupportActionModeStarted(mode: ActionMode) {
activeActionMode = mode
isEnabled = true
listeners?.forEach { it.onActionModeStarted(mode) }
}
fun onSupportActionModeFinished(mode: ActionMode) {
activeActionMode = null
isEnabled = false
listeners?.forEach { it.onActionModeFinished(mode) }
}
@@ -54,4 +47,4 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
removeListener(listener)
}
}
}
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.base.ui.util
import android.app.Activity
import android.os.Bundle
import androidx.core.app.ActivityCompat
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import java.util.WeakHashMap
import javax.inject.Inject
@@ -23,6 +22,6 @@ class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleC
fun recreateAll() {
val snapshot = activities.keys.toList()
snapshot.forEach { ActivityCompat.recreate(it) }
snapshot.forEach { it.recreate() }
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.base.ui.util
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@@ -10,3 +11,8 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
interface BaseActivityEntryPoint {
val settings: AppSettings
}
// Hilt cannot inject into parametrized classes
fun BaseActivityEntryPoint.inject(activity: BaseActivity<*>) {
activity.settings = settings
}

View File

@@ -1,13 +0,0 @@
package org.koitharu.kotatsu.base.ui.util
import android.text.Editable
import android.text.TextWatcher
interface DefaultTextWatcher : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) = Unit
}

View File

@@ -1,25 +0,0 @@
package org.koitharu.kotatsu.base.ui.util
import android.view.View
import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
class ReversibleActionObserver(
private val snackbarHost: View,
) : Observer<ReversibleAction?> {
override fun onChanged(value: ReversibleAction?) {
if (value == null) {
return
}
val handle = value.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.show()
}
}

View File

@@ -10,7 +10,9 @@ import com.google.android.material.floatingactionbutton.ExtendedFloatingActionBu
open class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
@Suppress("unused")
constructor() : super()
@Suppress("unused")
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
override fun onStartNestedScroll(

View File

@@ -10,10 +10,8 @@ class WindowInsetsDelegate(
private val listener: WindowInsetsListener,
) : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
@JvmField
var handleImeInsets: Boolean = false
@JvmField
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
private var lastInsets: Insets? = null
@@ -65,4 +63,4 @@ class WindowInsetsDelegate(
fun onWindowInsetsChanged(insets: Insets)
}
}
}

View File

@@ -1,20 +1,17 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.view.View.OnClickListener
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.getColorStateListOrThrow
import androidx.annotation.DrawableRes
import androidx.core.view.children
import com.google.android.material.R as materialR
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.castOrNull
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
class ChipsView @JvmOverloads constructor(
context: Context,
@@ -30,9 +27,6 @@ class ChipsView @JvmOverloads constructor(
private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
}
private val defaultChipStrokeColor: ColorStateList
private val defaultChipTextColor: ColorStateList
private val defaultChipIconTint: ColorStateList
var onChipClickListener: OnChipClickListener? = null
set(value) {
field = value
@@ -46,15 +40,6 @@ class ChipsView @JvmOverloads constructor(
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
}
init {
@SuppressLint("CustomViewStyleable")
val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip)
defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor)
defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor)
defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint)
a.recycle()
}
override fun requestLayout() {
if (isLayoutSuppressedCompat) {
isLayoutCalledOnSuppressed = true
@@ -90,15 +75,12 @@ class ChipsView @JvmOverloads constructor(
private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title
val tint = if (model.tint == 0) {
null
if (model.icon == 0) {
chip.isChipIconVisible = false
} else {
ContextCompat.getColorStateList(context, model.tint)
chip.isChipIconVisible = true
chip.setChipIconResource(model.icon)
}
chip.chipIconTint = tint ?: defaultChipIconTint
chip.checkedIconTint = tint ?: defaultChipIconTint
chip.chipStrokeColor = tint ?: defaultChipStrokeColor
chip.setTextColor(tint ?: defaultChipTextColor)
chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable
chip.isChecked = model.isChecked
@@ -110,9 +92,8 @@ class ChipsView @JvmOverloads constructor(
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true
chip.isChipIconVisible = false
chip.setCheckedIconResource(R.drawable.ic_check)
chip.checkedIconTint = defaultChipIconTint
chip.checkedIconTint = context.getThemeColorStateList(materialR.attr.colorControlNormal)
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
@@ -132,7 +113,7 @@ class ChipsView @JvmOverloads constructor(
}
class ChipModel(
@ColorRes val tint: Int,
@DrawableRes val icon: Int,
val title: CharSequence,
val isCheckable: Boolean,
val isChecked: Boolean,
@@ -145,7 +126,7 @@ class ChipsView @JvmOverloads constructor(
other as ChipModel
if (tint != other.tint) return false
if (icon != other.icon) return false
if (title != other.title) return false
if (isCheckable != other.isCheckable) return false
if (isChecked != other.isChecked) return false
@@ -155,7 +136,7 @@ class ChipsView @JvmOverloads constructor(
}
override fun hashCode(): Int {
var result = tint.hashCode()
var result = icon
result = 31 * result + title.hashCode()
result = 31 * result + isCheckable.hashCode()
result = 31 * result + isChecked.hashCode()

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.animation.Animator
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Outline
@@ -9,34 +7,48 @@ import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.view.animation.DecelerateInterpolator
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.getAnimationDuration
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.isAnimationsEnabled
import org.koitharu.kotatsu.utils.ext.resolveDp
import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR
import kotlin.random.Random
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.utils.ext.getThemeColor
import org.koitharu.kotatsu.utils.ext.resolveDp
class SegmentedBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val segmentsData = ArrayList<Segment>()
private val segmentsSizes = ArrayList<Float>()
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
private var cornerSize = 0f
private var scaleFactor = 1f
private var scaleAnimator: ValueAnimator? = null
var segments: List<Segment>
get() = segmentsData
set(value) {
segmentsData.replaceWith(value)
updateSizes()
invalidate()
}
init {
paint.strokeWidth = context.resources.resolveDp(1f)
outlineProvider = OutlineProvider()
clipToOutline = true
if (isInEditMode) {
segments = List(Random.nextInt(3, 5)) {
Segment(
percent = Random.nextFloat(),
color = ColorUtils.HSLToColor(floatArrayOf(Random.nextInt(0, 360).toFloat(), 0.5f, 0.5f)),
)
}
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
@@ -67,56 +79,12 @@ class SegmentedBarView @JvmOverloads constructor(
canvas.drawRoundRect(0f, 0f, w, height.toFloat(), cornerSize, cornerSize, paint)
}
override fun onAnimationStart(animation: Animator) = Unit
override fun onAnimationEnd(animation: Animator) {
if (scaleAnimator === animation) {
scaleAnimator = null
}
}
override fun onAnimationUpdate(animation: ValueAnimator) {
scaleFactor = animation.animatedValue as Float
updateSizes()
invalidate()
}
override fun onAnimationCancel(animation: Animator) = Unit
override fun onAnimationRepeat(animation: Animator) = Unit
fun animateSegments(value: List<Segment>) {
scaleAnimator?.cancel()
segmentsData.replaceWith(value)
if (!context.isAnimationsEnabled) {
scaleAnimator = null
scaleFactor = 1f
updateSizes()
invalidate()
return
}
scaleFactor = 0f
updateSizes()
invalidate()
val animator = ValueAnimator.ofFloat(0f, 1f)
animator.duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
animator.interpolator = DecelerateInterpolator()
animator.addUpdateListener(this@SegmentedBarView)
animator.addListener(this@SegmentedBarView)
scaleAnimator = animator
animator.start()
}
private fun updateSizes() {
segmentsSizes.clear()
segmentsSizes.ensureCapacity(segmentsData.size + 1)
var w = width.toFloat()
val maxScale = (scaleFactor * (segmentsData.size - 1)).coerceAtLeast(1f)
for ((index, segment) in segmentsData.withIndex()) {
val scale = (scaleFactor * (index + 1) / maxScale).coerceAtMost(1f)
val segmentWidth = (w * segment.percent).coerceAtLeast(
if (index == 0) height.toFloat() else cornerSize,
) * scale
for (segment in segmentsData) {
val segmentWidth = (w * segment.percent).coerceAtLeast(cornerSize)
segmentsSizes.add(segmentWidth)
w -= segmentWidth
}

View File

@@ -1,104 +0,0 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.TypedArray
import android.graphics.Color
import android.graphics.drawable.InsetDrawable
import android.graphics.drawable.RippleDrawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RoundRectShape
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.updateLayoutParams
import androidx.core.widget.ImageViewCompat
import androidx.core.widget.TextViewCompat
import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ViewTwoLinesItemBinding
import org.koitharu.kotatsu.utils.ext.resolveDp
@SuppressLint("RestrictedApi")
class TwoLinesItemView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr) {
private val binding = ViewTwoLinesItemBinding.inflate(LayoutInflater.from(context), this)
init {
var textColors: ColorStateList? = null
context.withStyledAttributes(
set = attrs,
attrs = R.styleable.TwoLinesItemView,
defStyleAttr = defStyleAttr,
defStyleRes = R.style.Widget_Kotatsu_TwoLinesItemView,
) {
val itemRippleColor = getRippleColor(context)
val shape = createShapeDrawable(this)
val roundCorners = FloatArray(8) { resources.resolveDp(16f) }
background = RippleDrawable(
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
shape,
ShapeDrawable(RoundRectShape(roundCorners, null, null)),
)
val drawablePadding = getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_drawablePadding, 0)
binding.layoutText.updateLayoutParams<MarginLayoutParams> { marginStart = drawablePadding }
setIconResource(getResourceId(R.styleable.TwoLinesItemView_icon, 0))
binding.title.text = getText(R.styleable.TwoLinesItemView_title)
binding.subtitle.text = getText(R.styleable.TwoLinesItemView_subtitle)
textColors = getColorStateList(R.styleable.TwoLinesItemView_android_textColor)
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
TextViewCompat.setTextAppearance(
binding.title,
getResourceId(R.styleable.TwoLinesItemView_titleTextAppearance, textAppearanceFallback),
)
TextViewCompat.setTextAppearance(
binding.subtitle,
getResourceId(R.styleable.TwoLinesItemView_subtitleTextAppearance, textAppearanceFallback),
)
}
if (textColors == null) {
textColors = binding.title.textColors
}
binding.title.setTextColor(textColors)
binding.subtitle.setTextColor(textColors)
ImageViewCompat.setImageTintList(binding.icon, textColors)
}
fun setIconResource(@DrawableRes resId: Int) {
val icon = if (resId != 0) ContextCompat.getDrawable(context, resId) else null
binding.icon.setImageDrawable(icon)
}
private fun createShapeDrawable(ta: TypedArray): InsetDrawable {
val shapeAppearance = ShapeAppearanceModel.builder(
context,
ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearance, 0),
ta.getResourceId(R.styleable.TwoLinesItemView_shapeAppearanceOverlay, 0),
).build()
val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.TwoLinesItemView_backgroundFillColor)
return InsetDrawable(
shapeDrawable,
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetLeft, 0),
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetTop, 0),
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetRight, 0),
ta.getDimensionPixelOffset(R.styleable.TwoLinesItemView_android_insetBottom, 0),
)
}
private fun getRippleColor(context: Context): ColorStateList {
return ContextCompat.getColorStateList(context, R.color.selector_overlay)
?: ColorStateList.valueOf(Color.TRANSPARENT)
}
}

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.bookmarks.domain
import android.database.SQLException
import androidx.room.withTransaction
import dagger.Reusable
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.base.domain.ReversibleHandle
@@ -17,9 +17,7 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.mapItems
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import javax.inject.Inject
@Reusable
class BookmarksRepository @Inject constructor(
private val db: MangaDatabase,
) {

View File

@@ -1,12 +1,7 @@
package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import android.view.*
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
@@ -15,6 +10,7 @@ import androidx.fragment.app.viewModels
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -28,7 +24,6 @@ import org.koitharu.kotatsu.bookmarks.data.ids
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksGroupAdapter
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -37,9 +32,9 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@AndroidEntryPoint
class BookmarksFragment :
@@ -81,7 +76,7 @@ class BookmarksFragment :
binding.recyclerView.addItemDecoration(spacingDecoration)
viewModel.content.observe(viewLifecycleOwner, ::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
}
@@ -95,7 +90,6 @@ class BookmarksFragment :
if (selectionController?.onItemClick(item.manga, item.pageId) != true) {
val intent = ReaderActivity.newIntent(view.context, item)
startActivity(intent, scaleUpActivityOptionsOf(view).toBundle())
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
@@ -138,7 +132,6 @@ class BookmarksFragment :
mode.finish()
true
}
else -> false
}
}
@@ -161,6 +154,14 @@ class BookmarksFragment :
adapter?.items = list
}
private fun onError(e: Throwable) {
Snackbar.make(
binding.recyclerView,
e.getDisplayMessage(resources),
Snackbar.LENGTH_SHORT,
).show()
}
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.bookmarks.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@@ -17,8 +18,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import javax.inject.Inject
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@HiltViewModel
class BookmarksViewModel @Inject constructor(
@@ -43,12 +43,12 @@ class BookmarksViewModel @Inject constructor(
}
}
.catch { e -> emit(listOf(e.toErrorState(canRetry = false))) }
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun removeBookmarks(ids: Map<Manga, Set<Long>>) {
launchJob(Dispatchers.Default) {
val handle = repository.removeBookmarks(ids)
onActionDone.emitCall(ReversibleAction(R.string.bookmarks_removed, handle))
onActionDone.postCall(ReversibleAction(R.string.bookmarks_removed, handle))
}
}
}

View File

@@ -8,12 +8,9 @@ import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun bookmarkListAD(
coil: ImageLoader,
@@ -28,14 +25,12 @@ fun bookmarkListAD(
binding.root.setOnLongClickListener(listener)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
binding.imageViewThumb.newImageRequest(item.imageUrl, item.manga.source)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
decodeRegion(item.scroll)
source(item.manga.source)
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
}

View File

@@ -18,8 +18,6 @@ import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.source
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun bookmarksGroupAD(
coil: ImageLoader,
@@ -50,13 +48,12 @@ fun bookmarksGroupAD(
binding.recyclerView.addItemDecoration(spacingDecoration)
selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
}
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
binding.imageViewCover.newImageRequest(item.manga.coverUrl, item.manga.source)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
size(CoverSizeResolver(binding.imageViewCover))
source(item.manga.source)
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
binding.textViewTitle.text = item.manga.title

View File

@@ -5,18 +5,14 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.model.BookmarksGroup
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.adapter.*
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.jvm.internal.Intrinsics
class BookmarksGroupAdapter(
coil: ImageLoader,
@@ -42,7 +38,7 @@ class BookmarksGroupAdapter(
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
.addDelegate(emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(emptyStateListAD(coil, listener))
.addDelegate(errorStateListAD(listener))
}
@@ -53,7 +49,6 @@ class BookmarksGroupAdapter(
oldItem is BookmarksGroup && newItem is BookmarksGroup -> {
oldItem.manga.id == newItem.manga.id
}
else -> oldItem.javaClass == newItem.javaClass
}
}

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.utils.ext.catchingWebViewUnavailability
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled")
@@ -25,9 +24,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
return
}
setContentView(ActivityBrowserBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)

View File

@@ -2,13 +2,14 @@ package org.koitharu.kotatsu.browser
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.widget.ProgressBar
import androidx.core.view.isVisible
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.utils.ext.setProgressCompat
private const val PROGRESS_MAX = 100
class ProgressChromeClient(
private val progressIndicator: BaseProgressIndicator<*>,
private val progressIndicator: ProgressBar,
) : WebChromeClient() {
init {
@@ -27,4 +28,4 @@ class ProgressChromeClient(
progressIndicator.isIndeterminate = true
}
}
}
}

View File

@@ -20,13 +20,14 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import org.koitharu.kotatsu.utils.ext.stringArgument
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint
class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), CloudFlareCallback {
private lateinit var url: String
private val url by stringArgument(ARG_URL)
private val pendingResult = Bundle(1)
@Inject
@@ -34,11 +35,6 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
url = requireArguments().getString(ARG_URL).orEmpty()
}
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -54,12 +50,12 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
databaseEnabled = true
userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
}
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url.orEmpty())
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
if (url.isEmpty()) {
if (url.isNullOrEmpty()) {
dismissAllowingStateLoss()
} else {
binding.webView.loadUrl(url)
binding.webView.loadUrl(url.orEmpty())
}
}

View File

@@ -20,10 +20,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.Cache
import okhttp3.CookieJar
import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
@@ -44,8 +40,6 @@ import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -87,21 +81,15 @@ interface AppModule {
PreferencesCookieJar(context)
}
@Provides
@Singleton
fun provideHttpCache(
localStorageManager: LocalStorageManager,
): Cache = localStorageManager.createHttpCache()
@Provides
@Singleton
fun provideOkHttpClient(
cache: Cache,
localStorageManager: LocalStorageManager,
commonHeadersInterceptor: CommonHeadersInterceptor,
mirrorSwitchInterceptor: MirrorSwitchInterceptor,
cookieJar: CookieJar,
settings: AppSettings,
): OkHttpClient {
val cache = localStorageManager.createHttpCache()
return OkHttpClient.Builder().apply {
connectTimeout(20, TimeUnit.SECONDS)
readTimeout(60, TimeUnit.SECONDS)
@@ -112,11 +100,9 @@ interface AppModule {
bypassSSLErrors()
}
cache(cache)
addNetworkInterceptor(CacheLimitInterceptor())
addInterceptor(GZipInterceptor())
addInterceptor(commonHeadersInterceptor)
addInterceptor(CloudFlareInterceptor())
addInterceptor(mirrorSwitchInterceptor)
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
@@ -181,6 +167,7 @@ interface AppModule {
}
@Provides
@Singleton
@ElementsIntoSet
fun provideDatabaseObservers(
widgetUpdater: WidgetUpdater,
@@ -195,6 +182,7 @@ interface AppModule {
)
@Provides
@Singleton
@ElementsIntoSet
fun provideActivityLifecycleCallbacks(
appProtectHelper: AppProtectHelper,
@@ -217,16 +205,5 @@ interface AppModule {
MemoryContentCache(application)
}
}
@Provides
@Singleton
@LocalStorageChanges
fun provideMutableLocalStorageChangesFlow(): MutableSharedFlow<LocalManga?> = MutableSharedFlow()
@Provides
@LocalStorageChanges
fun provideLocalStorageChangesFlow(
@LocalStorageChanges flow: MutableSharedFlow<LocalManga?>,
): SharedFlow<LocalManga?> = flow.asSharedFlow()
}
}

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
class DeferredLruCache<T>(maxSize: Int) : LruCache<ContentCache.Key, SafeDeferred<T>>(maxSize)

View File

@@ -1,33 +0,0 @@
package org.koitharu.kotatsu.core.cache
import androidx.collection.LruCache
import java.util.concurrent.TimeUnit
class ExpiringLruCache<T>(
val maxSize: Int,
private val lifetime: Long,
private val timeUnit: TimeUnit,
) {
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
operator fun get(key: ContentCache.Key): T? {
val value = cache.get(key) ?: return null
if (value.isExpired) {
cache.remove(key)
}
return value.get()
}
operator fun set(key: ContentCache.Key, value: T) {
cache.put(key, ExpiringValue(value, lifetime, timeUnit))
}
fun clear() {
cache.evictAll()
}
fun trimToSize(size: Int) {
cache.trimToSize(size)
}
}

View File

@@ -1,34 +0,0 @@
package org.koitharu.kotatsu.core.cache
import android.os.SystemClock
import java.util.concurrent.TimeUnit
class ExpiringValue<T>(
private val value: T,
lifetime: Long,
timeUnit: TimeUnit,
) {
private val expiresAt = SystemClock.elapsedRealtime() + timeUnit.toMillis(lifetime)
val isExpired: Boolean
get() = SystemClock.elapsedRealtime() >= expiresAt
fun get(): T? = if (isExpired) null else value
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ExpiringValue<*>
if (value != other.value) return false
return expiresAt == other.expiresAt
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + expiresAt.hashCode()
return result
}
}

View File

@@ -6,7 +6,6 @@ import android.content.res.Configuration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.concurrent.TimeUnit
class MemoryContentCache(application: Application) : ContentCache, ComponentCallbacks2 {
@@ -14,8 +13,8 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
application.registerComponentCallbacks(this)
}
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(4, 5, TimeUnit.MINUTES)
private val pagesCache = ExpiringLruCache<SafeDeferred<List<MangaPage>>>(4, 10, TimeUnit.MINUTES)
private val detailsCache = DeferredLruCache<Manga>(4)
private val pagesCache = DeferredLruCache<List<MangaPage>>(4)
override val isCachingEnabled: Boolean = true
@@ -24,7 +23,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
}
override fun putDetails(source: MangaSource, url: String, details: SafeDeferred<Manga>) {
detailsCache[ContentCache.Key(source, url)] = details
detailsCache.put(ContentCache.Key(source, url), details)
}
override suspend fun getPages(source: MangaSource, url: String): List<MangaPage>? {
@@ -32,7 +31,7 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
}
override fun putPages(source: MangaSource, url: String, pages: SafeDeferred<List<MangaPage>>) {
pagesCache[ContentCache.Key(source, url)] = pages
pagesCache.put(ContentCache.Key(source, url), pages)
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@@ -44,17 +43,17 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
trimCache(pagesCache, level)
}
private fun trimCache(cache: ExpiringLruCache<*>, level: Int) {
private fun trimCache(cache: DeferredLruCache<*>, level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL,
ComponentCallbacks2.TRIM_MEMORY_COMPLETE,
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.clear()
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> cache.evictAll()
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> cache.trimToSize(1)
else -> cache.trimToSize(cache.maxSize / 2)
else -> cache.trimToSize(cache.maxSize() / 2)
}
}
}

View File

@@ -1,10 +1,7 @@
package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.longHashCode
@@ -69,6 +66,7 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
SortOrder.valueOf(name)
}.getOrDefault(fallback)
@Suppress("FunctionName")
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()

View File

@@ -5,5 +5,5 @@ import okio.IOException
class CloudFlareProtectedException(
val url: String,
@Transient val headers: Headers,
val headers: Headers,
) : IOException("Protected by CloudFlare")

View File

@@ -1,3 +1,3 @@
package org.koitharu.kotatsu.core.exceptions
class WrongPasswordException : IllegalArgumentException()
class WrongPasswordException : SecurityException()

View File

@@ -1,66 +0,0 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.DialogInterface
import android.view.View
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DialogErrorObserver(
host: View,
fragment: Fragment?,
resolver: ExceptionResolver?,
private val onResolved: Consumer<Boolean>?,
) : ErrorObserver(host, fragment, resolver, onResolved) {
constructor(
host: View,
fragment: Fragment?,
) : this(host, fragment, null, null)
override fun onChanged(value: Throwable?) {
if (value == null) {
return
}
val listener = DialogListener(value)
val dialogBuilder = MaterialAlertDialogBuilder(activity ?: host.context)
.setMessage(value.getDisplayMessage(host.context.resources))
.setNegativeButton(R.string.close, listener)
.setOnCancelListener(listener)
if (canResolve(value)) {
dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener)
} else if (value is ParseException) {
val fm = fragmentManager
if (fm != null) {
dialogBuilder.setPositiveButton(R.string.details) { _, _ ->
ErrorDetailsDialog.show(fm, value, value.url)
}
}
}
val dialog = dialogBuilder.create()
if (activity != null) {
dialog.setOwnerActivity(activity)
}
dialog.show()
}
private inner class DialogListener(
private val error: Throwable,
) : DialogInterface.OnClickListener, DialogInterface.OnCancelListener {
override fun onClick(dialog: DialogInterface?, which: Int) {
when (which) {
DialogInterface.BUTTON_NEGATIVE -> onResolved?.accept(false)
DialogInterface.BUTTON_POSITIVE -> resolve(error)
}
}
override fun onCancel(dialog: DialogInterface?) {
onResolved?.accept(false)
}
}
}

View File

@@ -1,44 +0,0 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.utils.ext.findActivity
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
abstract class ErrorObserver(
protected val host: View,
protected val fragment: Fragment?,
private val resolver: ExceptionResolver?,
private val onResolved: Consumer<Boolean>?,
) : Observer<Throwable?> {
protected val activity = host.context.findActivity()
private val lifecycleScope: LifecycleCoroutineScope
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
protected val fragmentManager: FragmentManager?
get() = fragment?.childFragmentManager ?: (activity as? AppCompatActivity)?.supportFragmentManager
protected fun canResolve(error: Throwable): Boolean {
return resolver != null && ExceptionResolver.canResolve(error)
}
protected fun resolve(error: Throwable) {
lifecycleScope.launch {
val isResolved = resolver?.resolve(error) ?: false
if (isActive) {
onResolved?.accept(isResolved)
}
}
}
}

View File

@@ -1,23 +1,27 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.TaggedActivityResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.isSuccess
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@@ -39,14 +43,11 @@ class ExceptionResolver private constructor(
sourceAuthContract = fragment.registerForActivityResult(SourceAuthActivity.Contract(), this)
}
override fun onActivityResult(result: TaggedActivityResult) {
override fun onActivityResult(result: TaggedActivityResult?) {
result ?: return
continuations.remove(result.tag)?.resume(result.isSuccess)
}
fun showDetails(e: Throwable, url: String?) {
ErrorDetailsDialog.show(getFragmentManager(), e, url)
}
suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e.url, e.headers)
is AuthRequiredException -> resolveAuthException(e.source)
@@ -99,5 +100,21 @@ class ExceptionResolver private constructor(
}
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
fun showDetails(context: Context, e: Throwable) {
val stackTrace = e.stackTraceToString()
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(e.getDisplayMessage(context.resources))
.setMessage(stackTrace)
.setPositiveButton(androidx.preference.R.string.copy) { _, _ ->
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(context.getString(R.string.error), stackTrace),
)
}
.setNegativeButton(R.string.close, null)
.create()
dialog.show()
}
}
}

View File

@@ -1,47 +0,0 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.view.View
import androidx.core.util.Consumer
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.ErrorDetailsDialog
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class SnackbarErrorObserver(
host: View,
fragment: Fragment?,
resolver: ExceptionResolver?,
onResolved: Consumer<Boolean>?,
) : ErrorObserver(host, fragment, resolver, onResolved) {
constructor(
host: View,
fragment: Fragment?,
) : this(host, fragment, null, null)
override fun onChanged(value: Throwable?) {
if (value == null) {
return
}
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
if (activity is BottomNavOwner) {
snackbar.anchorView = activity.bottomNav
}
if (canResolve(value)) {
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
resolve(value)
}
} else if (value is ParseException) {
val fm = fragmentManager
if (fm != null) {
snackbar.setAction(R.string.details) {
ErrorDetailsDialog.show(fm, value, value.url)
}
}
}
snackbar.show()
}
}

View File

@@ -4,15 +4,12 @@ import android.content.Context
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
@@ -85,15 +82,6 @@ class FileLogger(
flushImpl()
}
@WorkerThread
fun flushBlocking() {
if (!isEnabled) {
return
}
runBlockingSafe { flushJob?.cancelAndJoin() }
runBlockingSafe { flushImpl() }
}
private fun postFlush() {
if (flushJob?.isActive == true) {
return
@@ -108,10 +96,10 @@ class FileLogger(
}
}
private suspend fun flushImpl() = withContext(NonCancellable) {
private suspend fun flushImpl() {
mutex.withLock {
if (buffer.isEmpty()) {
return@withContext
return
}
runInterruptible(Dispatchers.IO) {
if (file.length() > MAX_SIZE_BYTES) {
@@ -143,9 +131,4 @@ class FileLogger(
}
bakFile.delete()
}
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
runBlocking(NonCancellable) { block() }
} catch (_: InterruptedException) {
}
}

View File

@@ -1,28 +1,13 @@
package org.koitharu.kotatsu.core.model
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.utils.ext.iterator
fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.distinctById() = distinctBy { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
}
val acc = HashMap<String?, Int>()
for (item in this) {
val branch = item.chapter.branch
acc[branch] = (acc[branch] ?: 0) + 1
}
return acc.values.max()
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val ch = chapters
if (ch.isNullOrEmpty()) {
@@ -35,22 +20,15 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
}
}
val groups = ch.groupBy { it.branch }
if (groups.size == 1) {
return groups.keys.first()
}
val candidates = HashMap<String?, List<MangaChapter>>(groups.size)
for (locale in LocaleListCompat.getAdjustedDefault()) {
val displayLanguage = locale.getDisplayLanguage(locale)
val displayName = locale.getDisplayName(locale)
for (branch in groups.keys) {
if (branch != null && (
branch.contains(displayLanguage, ignoreCase = true) ||
branch.contains(displayName, ignoreCase = true)
)
) {
candidates[branch] = groups[branch] ?: continue
}
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
language = locale.getDisplayName(locale).toTitleCase(locale)
if (groups.containsKey(language)) {
return language
}
}
return candidates.ifEmpty { groups }.maxByOrNull { it.value.size }?.key
}
return groups.maxByOrNull { it.value.size }?.key
}

View File

@@ -1,26 +0,0 @@
package org.koitharu.kotatsu.core.network
import okhttp3.CacheControl
import okhttp3.Interceptor
import okhttp3.Response
import java.util.concurrent.TimeUnit
class CacheLimitInterceptor : Interceptor {
private val defaultMaxAge = TimeUnit.HOURS.toSeconds(1)
private val defaultCacheControl = CacheControl.Builder()
.maxAge(defaultMaxAge.toInt(), TimeUnit.SECONDS)
.build()
.toString()
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
val responseCacheControl = CacheControl.parse(response.headers)
if (responseCacheControl.noStore || responseCacheControl.maxAgeSeconds <= defaultMaxAge) {
return response
}
return response.newBuilder()
.header(CommonHeaders.CACHE_CONTROL, defaultCacheControl)
.build()
}
}

View File

@@ -13,8 +13,7 @@ object CommonHeaders {
const val CONTENT_ENCODING = "Content-Encoding"
const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization"
const val CACHE_CONTROL = "Cache-Control"
val CACHE_CONTROL_NO_STORE: CacheControl
val CACHE_CONTROL_DISABLED: CacheControl
get() = CacheControl.Builder().noStore().build()
}

View File

@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.net.IDN
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -42,8 +41,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
}
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
val idn = IDN.toASCII(repository.domain)
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
headersBuilder.trySet(CommonHeaders.REFERER, "https://${repository.domain}/")
}
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)

View File

@@ -1,107 +0,0 @@
package org.koitharu.kotatsu.core.network
import dagger.Lazy
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MirrorSwitchInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
private val settings: AppSettings,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!settings.isMirrorSwitchingAvailable) {
return chain.proceed(request)
}
return try {
val response = chain.proceed(request)
if (response.isFailed) {
val responseCopy = response.copy()
response.closeQuietly()
trySwitchMirror(request, chain)?.also {
responseCopy.closeQuietly()
} ?: responseCopy
} else {
response
}
} catch (e: Exception) {
trySwitchMirror(request, chain) ?: throw e
}
}
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
val mirrors = repository.getAvailableMirrors()
if (mirrors.isEmpty()) {
return null
}
return tryMirrors(repository, mirrors, chain, request)
}
private fun tryMirrors(
repository: RemoteMangaRepository,
mirrors: List<String>,
chain: Interceptor.Chain,
request: Request,
): Response? {
val url = request.url
val currentDomain = url.topPrivateDomain()
if (currentDomain !in mirrors) {
return null
}
val urlBuilder = url.newBuilder()
for (mirror in mirrors) {
if (mirror == currentDomain) {
continue
}
val newHost = hostOf(url.host, mirror) ?: continue
val newRequest = request.newBuilder()
.url(urlBuilder.host(newHost).build())
.build()
val response = chain.proceed(newRequest)
if (response.isFailed) {
response.closeQuietly()
} else {
repository.domain = mirror
return response
}
}
return null
}
private val Response.isFailed: Boolean
get() = code in 400..599
private fun hostOf(host: String, newDomain: String): String? {
if (newDomain.canParseAsIpAddress()) {
return newDomain
}
val domain = PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: return null
return host.removeSuffix(domain) + newDomain
}
private fun Response.copy(): Response {
return newBuilder()
.body(body?.copy())
.build()
}
private fun ResponseBody.copy(): ResponseBody {
return source().readByteArray().toResponseBody(contentType())
}
}

View File

@@ -3,28 +3,23 @@ package org.koitharu.kotatsu.core.os
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.flow.first
import org.koitharu.kotatsu.utils.MediatorStateFlow
import org.koitharu.kotatsu.utils.ext.isOnline
import org.koitharu.kotatsu.utils.ext.isNetworkAvailable
class NetworkState(
private val connectivityManager: ConnectivityManager,
) : MediatorStateFlow<Boolean>(connectivityManager.isOnline()) {
) : MediatorStateFlow<Boolean>(connectivityManager.isNetworkAvailable) {
private val callback = NetworkCallbackImpl()
@Synchronized
override fun onActive() {
invalidate()
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
val request = NetworkRequest.Builder().build()
connectivityManager.registerNetworkCallback(request, callback)
}
@Synchronized
override fun onInactive() {
connectivityManager.unregisterNetworkCallback(callback)
}
@@ -37,7 +32,7 @@ class NetworkState(
}
private fun invalidate() {
publishValue(connectivityManager.isOnline())
publishValue(connectivityManager.isNetworkAvailable)
}
private inner class NetworkCallbackImpl : NetworkCallback() {

View File

@@ -11,7 +11,6 @@ import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.room.InvalidationTracker
import coil.ImageLoader
import coil.request.ImageRequest
@@ -28,9 +27,9 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.utils.ext.getDrawableOrThrow
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.requireBitmap
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
import javax.inject.Singleton
@@ -93,14 +92,6 @@ class ShortcutsUpdater @Inject constructor(
return manager.maxShortcutCountPerActivity > 0
}
fun notifyMangaOpened(mangaId: Long) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
return
}
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
manager.reportShortcutUsed(mangaId.toString())
}
@RequiresApi(Build.VERSION_CODES.N_MR1)
private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val manager = context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager
@@ -131,7 +122,7 @@ class ShortcutsUpdater @Inject constructor(
.precision(Precision.EXACT)
.scale(Scale.FILL)
.build(),
).getDrawableOrThrow().toBitmap()
).requireBitmap()
}.fold(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.toList
import java.lang.ref.WeakReference
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@@ -29,14 +28,10 @@ class MangaLoaderContextImpl @Inject constructor(
@ApplicationContext private val androidContext: Context,
) : MangaLoaderContext() {
private var webViewCached: WeakReference<WebView>? = null
@SuppressLint("SetJavaScriptEnabled")
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
val webView = webViewCached?.get() ?: WebView(androidContext).also {
it.settings.javaScriptEnabled = true
webViewCached = WeakReference(it)
}
val webView = WebView(androidContext)
webView.settings.javaScriptEnabled = true
suspendCoroutine { cont ->
webView.evaluateJavascript(script) { result ->
cont.resume(result?.takeUnless { it == "null" })

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -43,7 +42,6 @@ interface MangaRepository {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@AnyThread
fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) {
return localMangaRepository

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.parser
import android.content.Context
import androidx.annotation.ColorRes
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject
@Reusable
class MangaTagHighlighter @Inject constructor(
@ApplicationContext context: Context,
) {
private val dict by lazy {
context.resources.openRawResource(R.raw.tags_redlist).use {
val set = HashSet<String>()
it.bufferedReader().forEachLine { x ->
val line = x.trim()
if (line.isNotEmpty()) {
set.add(line)
}
}
set
}
}
@ColorRes
fun getTint(tag: MangaTag): Int {
return if (tag.title.lowercase() in dict) {
R.color.warning
} else {
0
}
}
}

View File

@@ -45,11 +45,8 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value
}
var domain: String
val domain: String
get() = parser.domain
set(value) {
getConfig()[parser.configKeyDomain] = value
}
val headers: Headers?
get() = parser.headers
@@ -100,10 +97,6 @@ class RemoteMangaRepository(
parser.onCreateConfig(it)
}
fun getAvailableMirrors(): List<String> {
return parser.configKeyDomain.presetValues?.toList().orEmpty()
}
private fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {

View File

@@ -19,13 +19,10 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.Closeable
import okio.buffer
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import java.net.HttpURLConnection
@@ -57,9 +54,7 @@ class FaviconFetcher(
val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" }
val response = loadIcon(icon.url, mangaSource)
val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource()?.also {
response.closeQuietly()
} ?: responseBody.toImageSource(response)
val source = writeToDiskCache(responseBody)?.toImageSource() ?: responseBody.toImageSource()
return SourceResult(
source = source,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(icon.type),
@@ -76,7 +71,7 @@ class FaviconFetcher(
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
val response = okHttpClient.newCall(request.build()).await()
if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) {
response.closeQuietly()
response.body?.closeQuietly()
throw HttpException(response)
}
return response
@@ -121,12 +116,8 @@ class FaviconFetcher(
return ImageSource(data, fileSystem, diskCacheKey, this)
}
private fun ResponseBody.toImageSource(response: Closeable): ImageSource {
return ImageSource(
source().withExtraCloseable(response).buffer(),
options.context,
FaviconMetadata(mangaSource),
)
private fun ResponseBody.toImageSource(): ImageSource {
return ImageSource(source(), options.context, FaviconMetadata(mangaSource))
}
private fun Response.toDataSource(): DataSource {

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.provider.Settings
import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.arraySetOf
import androidx.core.content.edit
@@ -16,7 +15,6 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.filterToSet
@@ -166,9 +164,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
val isMirrorSwitchingAvailable: Boolean
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, true)
val isExitConfirmationEnabled: Boolean
get() = prefs.getBoolean(KEY_EXIT_CONFIRM, false)
@@ -242,28 +237,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val downloadsParallelism: Int
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
var isSuggestionsEnabled: Boolean
val isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
val isSuggestionsNotificationAvailable: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true)
val suggestionsTagsBlacklist: Set<String>
get() {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return emptySet()
}
return string.split(',').mapToSet { it.trim() }
}
val isReaderBarEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_BAR, true)
@@ -283,16 +265,23 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
@get:FloatRange(from = 0.0, to = 1.0)
var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
fun isPagesPreloadEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
return policy.isNetworkAllowed(connectivityManager)
}
fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return null
}
val tags = string.split(',')
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
Regex.escape(tag.trim())
}
return Regex(regex, RegexOption.IGNORE_CASE)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = remoteSources.toMutableList()
val order = sourcesOrder
@@ -307,18 +296,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return list
}
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
fun closeTip(tip: String) {
val closedTips = prefs.getStringSet(KEY_TIPS_CLOSED, emptySet()).orEmpty()
if (tip in closedTips) {
return
}
prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
@@ -345,7 +322,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
const val KEY_COOKIES_CLEAR = "cookies_clear"
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
@@ -384,18 +360,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
const val KEY_EXIT_CONFIRM = "exit_confirm"
const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync"
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
@@ -409,10 +383,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass"
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
const val KEY_MIRROR_SWITCHING = "mirror_switching"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -28,12 +28,4 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
} as T
}
operator fun <T> set(key: ConfigKey<T>, value: T) = prefs.edit {
when (key) {
is ConfigKey.Domain -> putString(key.key, value as String?)
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
is ConfigKey.UserAgent -> putString(key.key, value as String?)
}
}
}

View File

@@ -1,8 +1,5 @@
package org.koitharu.kotatsu.core.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
@@ -15,24 +12,28 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding
import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.databinding.DialogMangaErrorBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.requireParcelable
import org.koitharu.kotatsu.utils.ext.requireSerializable
import org.koitharu.kotatsu.utils.ext.withArgs
class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
class MangaErrorDialog : AlertDialogFragment<DialogMangaErrorBinding>() {
private lateinit var exception: Throwable
private lateinit var error: Throwable
private lateinit var manga: Manga
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val args = requireArguments()
exception = args.requireSerializable(ARG_ERROR)
manga = args.requireParcelable<ParcelableManga>(ARG_MANGA).manga
error = args.requireSerializable(ARG_ERROR)
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding {
return DialogErrorDetailsBinding.inflate(inflater, container, false)
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogMangaErrorBinding {
return DialogMangaErrorBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -41,47 +42,31 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
movementMethod = LinkMovementMethod.getInstance()
text = context.getString(
R.string.manga_error_description_pattern,
exception.message?.htmlEncode().orEmpty(),
arguments?.getString(ARG_URL),
this@MangaErrorDialog.error.message?.htmlEncode().orEmpty(),
manga.publicUrl,
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
@Suppress("NAME_SHADOWING")
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
val builder = super.onBuildDialog(builder)
return super.onBuildDialog(builder)
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.error_occurred)
.setNeutralButton(androidx.preference.R.string.copy) { _, _ ->
copyToClipboard()
}
if (exception.isReportable()) {
builder.setPositiveButton(R.string.report) { _, _ ->
.setPositiveButton(R.string.report) { _, _ ->
dismiss()
exception.report()
}
}
return builder
}
private fun copyToClipboard() {
val clipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return
clipboardManager.setPrimaryClip(
ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
)
error.report()
}.setTitle(R.string.error_occurred)
}
companion object {
private const val TAG = "ErrorDetailsDialog"
private const val TAG = "MangaErrorDialog"
private const val ARG_ERROR = "error"
private const val ARG_URL = "url"
private const val ARG_MANGA = "manga"
fun show(fm: FragmentManager, error: Throwable, url: String?) = ErrorDetailsDialog().withArgs(2) {
fun show(fm: FragmentManager, manga: Manga, error: Throwable) = MangaErrorDialog().withArgs(2) {
putParcelable(ARG_MANGA, ParcelableManga(manga, false))
putSerializable(ARG_ERROR, error)
putString(ARG_URL, url)
}.show(fm, TAG)
}
}

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.details.ui.model.MangaBranch
class BranchComparator : Comparator<String?> {
class BranchComparator : Comparator<MangaBranch> {
override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name)
}
override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2)
}

View File

@@ -14,7 +14,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
@@ -96,12 +95,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
context.startService(intent)
}
fun prefetchLast(context: Context) {

View File

@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity
@@ -94,7 +95,11 @@ class ChaptersFragment :
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
viewModel.download(selectionController?.snapshot())
DownloadService.start(
binding.recyclerViewChapters,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionController?.snapshot(),
)
mode.finish()
true
}

View File

@@ -1,49 +1,51 @@
package org.koitharu.kotatsu.details.ui
import android.content.BroadcastReceiver
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.transition.Slide
import android.transition.TransitionManager
import android.view.Gravity
import android.view.MenuItem
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.BaseTransientBottomBar
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.ui.MangaErrorDialog
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.adapter.branchAD
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.utils.ViewBadge
import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.textAndVisible
import javax.inject.Inject
@@ -53,21 +55,31 @@ class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
View.OnClickListener,
BottomSheetHeaderBar.OnExpansionChangeListener,
NoModalBottomSheetOwner,
View.OnLongClickListener,
PopupMenu.OnMenuItemClickListener {
NoModalBottomSheetOwner {
override val bsHeader: BottomSheetHeaderBar?
get() = binding.headerChapters
@Inject
lateinit var viewModelFactory: DetailsViewModel.Factory
@Inject
lateinit var shortcutsUpdater: ShortcutsUpdater
private lateinit var viewBadge: ViewBadge
private val viewModel: DetailsViewModel by viewModels()
private val viewModel: DetailsViewModel by assistedViewModels {
viewModelFactory.create(MangaIntent(intent))
}
private lateinit var chaptersMenuProvider: ChaptersMenuProvider
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val downloadedManga = DownloadService.getDownloadedManga(intent) ?: return
viewModel.onDownloadComplete(downloadedManga)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDetailsBinding.inflate(layoutInflater))
@@ -76,7 +88,6 @@ class DetailsActivity :
setDisplayShowTitleEnabled(false)
}
binding.buttonRead.setOnClickListener(this)
binding.buttonRead.setOnLongClickListener(this)
binding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(binding.buttonRead, this)
@@ -94,19 +105,7 @@ class DetailsActivity :
viewModel.manga.observe(this, ::onMangaUpdated)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
viewModel.onError.observe(
this,
SnackbarErrorObserver(
host = binding.containerDetails,
fragment = null,
resolver = exceptionResolver,
onResolved = { isResolved ->
if (isResolved) {
viewModel.reload()
}
},
),
)
viewModel.onError.observe(this, ::onError)
viewModel.onShowToast.observe(this) {
makeSnackbar(getString(it), Snackbar.LENGTH_SHORT).show()
}
@@ -125,8 +124,8 @@ class DetailsActivity :
binding.buttonDropdown.isVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observe(this, DownloadStartedObserver(binding.containerDetails))
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
addMenuProvider(
DetailsMenuProvider(
activity = this,
@@ -138,35 +137,33 @@ class DetailsActivity :
binding.headerChapters?.addOnExpansionChangeListener(this) ?: addMenuProvider(chaptersMenuProvider)
}
override fun onDestroy() {
unregisterReceiver(downloadReceiver)
super.onDestroy()
}
override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.button_read -> {
val chapterId = viewModel.historyInfo.value?.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
showChapterMissingDialog(chapterId)
} else {
startActivity(
ReaderActivity.newIntent(
context = this,
manga = manga,
branch = viewModel.selectedBranchValue,
),
)
}
}
R.id.button_dropdown -> showBranchPopupMenu()
}
}
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true)
menu.show()
true
}
else -> false
}
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
R.id.action_incognito -> {
openReader(isIncognitoMode = true)
true
}
else -> false
}
override fun onExpansionStateChanged(headerBar: BottomSheetHeaderBar, isExpanded: Boolean) {
if (isExpanded) {
headerBar.addMenuProvider(chaptersMenuProvider)
@@ -194,6 +191,37 @@ class DetailsActivity :
finishAfterTransition()
}
private fun onError(e: Throwable) {
val manga = viewModel.manga.value
when {
ExceptionResolver.canResolve(e) -> {
resolveError(e)
}
manga == null -> {
Toast.makeText(this, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition()
}
else -> {
val snackbar = makeSnackbar(
e.getDisplayMessage(resources),
if (viewModel.manga.value?.chapters == null) {
Snackbar.LENGTH_INDEFINITE
} else {
Snackbar.LENGTH_LONG
},
)
if (e.isReportable()) {
snackbar.setAction(R.string.details) {
MangaErrorDialog.show(supportFragmentManager, manga, e)
}
}
snackbar.show()
}
}
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding(
left = insets.left,
@@ -249,44 +277,34 @@ class DetailsActivity :
)
}
setNeutralButton(R.string.download) { _, _ ->
viewModel.download(setOf(chapterId))
DownloadService.start(binding.appbar, remoteManga, setOf(chapterId))
}
setCancelable(true)
}.show()
}
private fun showBranchPopupMenu() {
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<MangaBranch> { item, _ ->
viewModel.setSelectedBranch(item.name)
dialog?.dismiss()
val menu = PopupMenu(this, binding.headerChapters ?: binding.buttonDropdown)
val currentBranch = viewModel.selectedBranchValue
for (branch in viewModel.branches.value ?: return) {
val item = menu.menu.add(R.id.group_branches, Menu.NONE, Menu.NONE, branch)
item.isChecked = branch == currentBranch
}
dialog = RecyclerViewAlertDialog.Builder<MangaBranch>(this)
.addAdapterDelegate(branchAD(listener))
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.translations)
.setItems(viewModel.branches.value.orEmpty())
.create()
.also { it.show() }
menu.menu.setGroupCheckable(R.id.group_branches, true, true)
menu.setOnMenuItemClickListener { item ->
viewModel.setSelectedBranch(item.title?.toString())
true
}
menu.show()
}
private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value?.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
showChapterMissingDialog(chapterId)
} else {
startActivity(
ReaderActivity.newIntent(
context = this,
manga = manga,
branch = viewModel.selectedBranchValue,
isIncognitoMode = isIncognitoMode,
),
)
if (isIncognitoMode) {
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
private fun resolveError(e: Throwable) {
lifecycleScope.launch {
if (exceptionResolver.resolve(e)) {
viewModel.reload()
} else if (viewModel.manga.value == null) {
Toast.makeText(this@DetailsActivity, e.getDisplayMessage(resources), Toast.LENGTH_LONG).show()
finishAfterTransition()
}
}
}
@@ -313,17 +331,17 @@ class DetailsActivity :
private class PrefetchObserver(
private val context: Context,
) : Observer<List<ChapterListItem>?> {
) : Observer<List<ChapterListItem>> {
private var isCalled = false
override fun onChanged(value: List<ChapterListItem>?) {
if (value.isNullOrEmpty()) {
override fun onChanged(t: List<ChapterListItem>?) {
if (t.isNullOrEmpty()) {
return
}
if (!isCalled) {
isCalled = true
val item = value.find { it.hasFlag(ChapterListItem.FLAG_CURRENT) } ?: value.first()
val item = t.find { it.hasFlag(ChapterListItem.FLAG_CURRENT) } ?: t.first()
MangaPrefetchService.prefetchPages(context, item.chapter)
}
}

View File

@@ -5,10 +5,10 @@ import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.net.toUri
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
@@ -18,6 +18,7 @@ import coil.request.ImageRequest
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -25,8 +26,6 @@ import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
@@ -40,10 +39,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.crossfade
import org.koitharu.kotatsu.utils.ext.drawableTop
import org.koitharu.kotatsu.utils.ext.enqueueWith
@@ -52,6 +53,8 @@ import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.image.CoverSizeResolver
import javax.inject.Inject
@@ -59,15 +62,13 @@ import javax.inject.Inject
class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener,
View.OnLongClickListener,
ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark> {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var tagHighlighter: MangaTagHighlighter
private val viewModel by activityViewModels<DetailsViewModel>()
override fun onInflateView(
@@ -89,7 +90,6 @@ class DetailsFragment :
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
}
override fun onItemClick(item: Bookmark, view: View) {
@@ -97,7 +97,6 @@ class DetailsFragment :
ReaderActivity.newIntent(view.context, item),
scaleUpActivityOptionsOf(view).toBundle(),
)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
@@ -146,9 +145,20 @@ class DetailsFragment :
}
if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false
val file = manga.url.toUri().toFileOrNull()
if (file != null) {
viewLifecycleScope.launch {
val size = file.computeSize()
infoLayout.textViewSize.text = FileSize.BYTES.format(requireContext(), size)
infoLayout.textViewSize.isVisible = true
}
} else {
infoLayout.textViewSize.isVisible = false
}
} else {
infoLayout.textViewSource.text = manga.source.title
infoLayout.textViewSource.isVisible = true
infoLayout.textViewSize.isVisible = false
}
infoLayout.textViewNsfw.isVisible = manga.isNsfw
@@ -163,9 +173,12 @@ class DetailsFragment :
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
} else {
val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true
infoLayout.textViewChapters.text = resources.getQuantityString(R.plurals.chapters, count, count)
infoLayout.textViewChapters.text = resources.getQuantityString(
R.plurals.chapters,
chapters.size,
chapters.size,
)
}
}
@@ -177,16 +190,6 @@ class DetailsFragment :
}
}
private fun onLocalSizeChanged(size: Long) {
val textView = binding.infoLayout.textViewSize
if (size == 0L) {
textView.isVisible = false
} else {
textView.text = FileSize.BYTES.format(textView.context, size)
textView.isVisible = true
}
}
private fun onHistoryChanged(history: HistoryInfo) {
binding.progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
}
@@ -261,6 +264,43 @@ class DetailsFragment :
}
}
override fun onLongClick(v: View): Boolean {
when (v.id) {
R.id.button_read -> {
if (viewModel.historyInfo.value?.history == null) {
return false
}
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_read -> {
val branch = viewModel.selectedBranchValue
startActivity(
ReaderActivity.newIntent(
context = context ?: return@setOnMenuItemClickListener false,
manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
state = viewModel.chapters.value?.firstOrNull { c ->
c.chapter.branch == branch
}?.let { c ->
ReaderState(c.chapter.id, 0, 0)
},
),
)
true
}
else -> false
}
}
menu.show()
return true
}
else -> return false
}
}
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
@@ -281,7 +321,7 @@ class DetailsFragment :
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTint(tag),
icon = 0,
data = tag,
isCheckable = false,
isChecked = false,
@@ -301,7 +341,7 @@ class DetailsFragment :
.size(CoverSizeResolver(binding.imageViewCover))
.data(imageUrl)
.tag(manga.source)
.crossfade(requireContext())
.crossfade(context)
.lifecycle(viewLifecycleOwner)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable

View File

@@ -16,7 +16,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -86,7 +86,7 @@ class DetailsMenuProvider(
if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches)
} else {
viewModel.download(null)
DownloadService.start(snackbarHost, it)
}
}
}
@@ -125,24 +125,22 @@ class DetailsMenuProvider(
return true
}
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<MangaBranch>) {
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) {
val dialogBuilder = MaterialAlertDialogBuilder(activity)
.setTitle(R.string.save_manga)
.setNegativeButton(android.R.string.cancel, null)
if (branches.size > 1) {
val items = Array(branches.size) { i -> branches[i].name.orEmpty() }
val currentBranch = branches.indexOfFirst { it.isSelected }
val items = Array(branches.size) { i -> branches[i].orEmpty() }
val currentBranch = viewModel.selectedBranchIndex.value ?: -1
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.mapIndexedNotNullTo(HashSet()) { i, b ->
if (checkedIndices[i]) b.name else null
}
val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] }
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
viewModel.download(chaptersIds)
DownloadService.start(snackbarHost, manga, chaptersIds)
}
} else {
dialogBuilder.setMessage(
@@ -151,7 +149,7 @@ class DetailsMenuProvider(
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
),
).setPositiveButton(R.string.save) { _, _ ->
viewModel.download(null)
DownloadService.start(snackbarHost, manga)
}
}
dialogBuilder.show()

View File

@@ -4,18 +4,18 @@ import android.text.Html
import android.text.SpannableString
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.core.net.toUri
import androidx.core.text.getSpans
import androidx.core.text.parseAsHtml
import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChangedBy
@@ -28,55 +28,59 @@ import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalManga
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.toFileOrNull
import java.io.IOException
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
class DetailsViewModel @AssistedInject constructor(
@Assisted intent: MangaIntent,
private val historyRepository: HistoryRepository,
favouritesRepository: FavouritesRepository,
private val localMangaRepository: LocalMangaRepository,
trackingRepository: TrackingRepository,
mangaDataRepository: MangaDataRepository,
private val bookmarksRepository: BookmarksRepository,
private val settings: AppSettings,
private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val imageGetter: Html.ImageGetter,
private val delegate: MangaDetailsDelegate,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val downloadScheduler: DownloadWorker.Scheduler,
mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() {
private val delegate = MangaDetailsDelegate(
intent = intent,
mangaDataRepository = mangaDataRepository,
historyRepository = historyRepository,
localMangaRepository = localMangaRepository,
mangaRepositoryFactory = mangaRepositoryFactory,
)
private var loadingJob: Job
val onShowToast = SingleLiveEvent<Int>()
val onDownloadStarted = SingleLiveEvent<Unit>()
private val history = historyRepository.observeOne(delegate.mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
@@ -105,36 +109,18 @@ class DetailsViewModel @Inject constructor(
val historyInfo: LiveData<HistoryInfo> = combine(
delegate.manga,
delegate.selectedBranch,
history,
historyRepository.observeShouldSkip(delegate.manga),
) { m, b, h, im ->
HistoryInfo(m, b, h, im)
) { m, h, im ->
HistoryInfo(m, h, im)
}.asFlowLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
defaultValue = HistoryInfo(null, null, null, false),
defaultValue = HistoryInfo(null, null, false),
)
val bookmarks = delegate.manga.flatMapLatest {
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val localSize = combine(
delegate.manga,
delegate.relatedManga,
) { m1, m2 ->
val url = when {
m1?.source == MangaSource.LOCAL -> m1.url
m2?.source == MangaSource.LOCAL -> m2.url
else -> null
}
if (url != null) {
val file = url.toUri().toFileOrNull()
file?.computeSize() ?: 0L
} else {
0L
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, 0)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val description = delegate.manga
.distinctUntilChangedBy { it?.description.orEmpty() }
@@ -146,7 +132,7 @@ class DetailsViewModel @Inject constructor(
emit(description.parseAsHtml().filterSpans())
emit(description.parseAsHtml(imageGetter = imageGetter).filterSpans())
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, null)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
val onMangaRemoved = SingleLiveEvent<Manga>()
val isScrobblingAvailable: Boolean
@@ -158,16 +144,18 @@ class DetailsViewModel @Inject constructor(
scrobblingInfo.filterNotNull()
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val branches: LiveData<List<MangaBranch>> = combine(
delegate.manga,
delegate.selectedBranch,
) { m, b ->
val chapters = m?.chapters ?: return@combine emptyList()
chapters.groupBy { x -> x.branch }
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator())
val branches: LiveData<List<String?>> = delegate.manga.map {
val chapters = it?.chapters ?: return@map emptyList()
chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
val selectedBranchIndex = combine(
branches.asFlow(),
delegate.selectedBranch,
) { branches, selected ->
branches.indexOf(selected)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, -1)
val selectedBranchName = delegate.selectedBranch
.asFlowLiveData(viewModelScope.coroutineContext, null)
@@ -176,7 +164,7 @@ class DetailsViewModel @Inject constructor(
isLoading.asFlow(),
) { m, loading ->
m != null && m.chapters.isNullOrEmpty() && !loading
}.asFlowLiveData(viewModelScope.coroutineContext, false)
}.asLiveDataDistinct(viewModelScope.coroutineContext, false)
val chapters = combine(
combine(
@@ -199,10 +187,6 @@ class DetailsViewModel @Inject constructor(
init {
loadingJob = doLoad()
launchJob(Dispatchers.Default) {
localStorageChanges
.collect { onDownloadComplete(it) }
}
}
fun reload() {
@@ -217,14 +201,14 @@ class DetailsViewModel @Inject constructor(
return
}
launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)?.manga
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
onMangaRemoved.emitCall(manga)
onMangaRemoved.postCall(manga)
}
}
@@ -251,6 +235,26 @@ class DetailsViewModel @Inject constructor(
chaptersQuery.value = query?.trim().orEmpty()
}
fun onDownloadComplete(downloadedManga: Manga) {
val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga)
}.onSuccess {
delegate.relatedManga.value = it
}.onFailure {
it.printStackTraceDebug()
}
}
}
}
fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
val scrobbler = getScrobbler(index) ?: return
launchJob(Dispatchers.Default) {
@@ -275,7 +279,7 @@ class DetailsViewModel @Inject constructor(
fun markChapterAsCurrent(chapterId: Long) {
launchJob(Dispatchers.Default) {
val manga = checkNotNull(delegate.manga.value)
val chapters = checkNotNull(manga.getChapters(selectedBranchValue))
val chapters = checkNotNull(manga.chapters)
val chapterIndex = chapters.indexOfFirst { it.id == chapterId }
check(chapterIndex in chapters.indices) { "Chapter not found" }
val percent = chapterIndex / chapters.size.toFloat()
@@ -283,16 +287,6 @@ class DetailsViewModel @Inject constructor(
}
}
fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(
getRemoteManga() ?: checkNotNull(manga.value),
chaptersIds,
)
onDownloadStarted.emitCall(Unit)
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
delegate.doLoad()
}
@@ -306,27 +300,6 @@ class DetailsViewModel @Inject constructor(
}
}
private suspend fun onDownloadComplete(downloadedManga: LocalManga?) {
downloadedManga ?: return
val currentManga = delegate.manga.value ?: return
if (currentManga.id != downloadedManga.manga.id) {
return
}
if (currentManga.source == MangaSource.LOCAL) {
reload()
} else {
viewModelScope.launch(Dispatchers.Default) {
runCatchingCancellable {
localMangaRepository.getDetails(downloadedManga.manga)
}.onSuccess {
delegate.relatedManga.value = it
}.onFailure {
it.printStackTraceDebug()
}
}
}
}
private fun Spanned.filterSpans(): CharSequence {
val spannable = SpannableString.valueOf(this)
val spans = spannable.getSpans<ForegroundColorSpan>()
@@ -348,4 +321,10 @@ class DetailsViewModel @Inject constructor(
}
return scrobbler
}
@AssistedFactory
interface Factory {
fun create(intent: MangaIntent): DetailsViewModel
}
}

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.details.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koitharu.kotatsu.base.domain.MangaDataRepository
@@ -19,17 +17,15 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
@ViewModelScoped
class MangaDetailsDelegate @Inject constructor(
savedStateHandle: SavedStateHandle,
class MangaDetailsDelegate(
private val intent: MangaIntent,
private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
private val intent = MangaIntent(savedStateHandle)
private val mangaData = MutableStateFlow(intent.manga)
val selectedBranch = MutableStateFlow<String?>(null)
@@ -53,7 +49,7 @@ class MangaDetailsDelegate @Inject constructor(
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatchingCancellable null
mangaRepositoryFactory.create(m.source).getDetails(m)
} else {
localMangaRepository.findSavedManga(manga)?.manga
localMangaRepository.findSavedManga(manga)
}
}.onFailure { error ->
error.printStackTraceDebug()

View File

@@ -1,39 +0,0 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Color
import android.text.Spannable
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import androidx.core.text.buildSpannedString
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import org.koitharu.kotatsu.utils.ext.getThemeColor
fun branchAD(
clickListener: OnListItemClickListener<MangaBranch>,
) = adapterDelegateViewBinding<MangaBranch, MangaBranch, ItemCheckableNewBinding>(
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
) {
val clickAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(clickAdapter)
val counterColorSpan = ForegroundColorSpan(context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY))
val counterSizeSpan = RelativeSizeSpan(0.86f)
bind {
binding.root.text = buildSpannedString {
append(item.name ?: getString(R.string.system_default))
append(' ')
append(' ')
val start = length
append(item.count.toString())
setSpan(counterColorSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(counterSizeSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
binding.root.isChecked = item.isSelected
}
}

View File

@@ -1,16 +1,45 @@
package org.koitharu.kotatsu.details.ui.adapter
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.details.ui.model.MangaBranch
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.replaceWith
class BranchesAdapter(
list: List<MangaBranch>,
listener: OnListItemClickListener<MangaBranch>,
) : ListDelegationAdapter<List<MangaBranch>>() {
class BranchesAdapter : BaseAdapter() {
init {
delegatesManager.addDelegate(branchAD(listener))
items = list
private val dataSet = ArrayList<String?>()
override fun getCount(): Int {
return dataSet.size
}
}
override fun getItem(position: Int): Any? {
return dataSet[position]
}
override fun getItemId(position: Int): Long {
return dataSet[position].hashCode().toLong()
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_branch, parent, false)
(view as TextView).text = dataSet[position]
return view
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.item_branch_dropdown, parent, false)
(view as TextView).text = dataSet[position]
return view
}
fun setItems(items: Collection<String?>) {
dataSet.replaceWith(items)
notifyDataSetChanged()
}
}

View File

@@ -36,13 +36,8 @@ class HistoryInfo(
}
}
fun HistoryInfo(
manga: Manga?,
branch: String?,
history: MangaHistory?,
isIncognitoMode: Boolean
): HistoryInfo {
val chapters = manga?.getChapters(branch)
fun HistoryInfo(manga: Manga?, history: MangaHistory?, isIncognitoMode: Boolean): HistoryInfo {
val chapters = manga?.chapters
return HistoryInfo(
totalChapters = chapters?.size ?: -1,
currentChapter = if (history != null && !chapters.isNullOrEmpty()) {

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaBranch(
val name: String?,
val count: Int,
val isSelected: Boolean,
) : ListModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaBranch
if (name != other.name) return false
if (count != other.count) return false
return isSelected == other.isSelected
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + count
result = 31 * result + isSelected.hashCode()
return result
}
override fun toString(): String {
return "$name: $count"
}
}

View File

@@ -23,10 +23,11 @@ fun scrobblingInfoAD(
}
bind {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
binding.imageViewCover.newImageRequest(item.coverUrl /* TODO */, null)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title

View File

@@ -123,7 +123,8 @@ class ScrobblingInfoBottomSheet :
binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
binding.imageViewCover.newImageRequest(viewLifecycleOwner, scrobbling.coverUrl)?.apply {
binding.imageViewCover.newImageRequest(scrobbling.coverUrl)?.apply {
lifecycle(viewLifecycleOwner)
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)

View File

@@ -0,0 +1,269 @@
package org.koitharu.kotatsu.download.domain
import android.content.Context
import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import okio.IOException
import org.koitharu.kotatsu.R
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.download.ui.service.PausingHandle
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File
private const val MAX_FAILSAFE_ATTEMPTS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val SLOWDOWN_DELAY = 200L
class DownloadManager @AssistedInject constructor(
@Assisted private val coroutineScope: CoroutineScope,
@ApplicationContext private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
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,
)
private val semaphore = Semaphore(settings.downloadsParallelism)
fun downloadManga(
manga: Manga,
chaptersIds: LongArray?,
startId: Int,
): PausingProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null),
)
val pausingHandle = PausingHandle()
val job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(stateFlow)) {
try {
downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
} catch (e: CancellationException) { // handle cancellation if not handled already
val state = stateFlow.value
if (state !is DownloadState.Cancelled) {
stateFlow.value = DownloadState.Cancelled(startId, state.manga, state.cover)
}
throw e
}
}
return PausingProgressJob(job, stateFlow, pausingHandle)
}
private suspend fun downloadMangaImpl(
manga: Manga,
chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
startId: Int,
) {
@Suppress("NAME_SHADOWING")
var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover)
withMangaLock(manga) {
semaphore.withPermit {
outState.value = DownloadState.Preparing(startId, manga, null)
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
val tempFileName = "${manga.id}_$startId.tmp"
var output: CbzMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance")
}
val repo = mangaRepositoryFactory.create(manga.source)
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = CbzMangaOutput.get(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = checkNotNull(
if (chaptersIdsSet == null) {
data.chapters
} else {
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
},
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
}
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = runFailsafe(outState, pausingHandle) {
repo.getPages(chapter)
}
for ((pageIndex, page) in pages.withIndex()) {
runFailsafe(outState, pausingHandle) {
val url = repo.getPageUrl(page)
val file = cache.get(url)
?: downloadFile(url, destination, tempFileName, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
outState.value = DownloadState.Progress(
startId = startId,
manga = data,
cover = cover,
totalChapters = chapters.size,
currentChapter = chapterIndex,
totalPages = pages.size,
currentPage = pageIndex,
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
}
outState.value = DownloadState.PostProcessing(startId, data, cover)
output.mergeWithExisting()
output.finish()
val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
outState.value = DownloadState.Error(startId, manga, cover, e, false)
} finally {
withContext(NonCancellable) {
output?.closeQuietly()
output?.cleanup()
File(destination, tempFileName).deleteAwait()
}
}
}
}
}
private suspend fun <R> runFailsafe(
outState: MutableStateFlow<DownloadState>,
pausingHandle: PausingHandle,
block: suspend () -> R,
): R {
var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) {
try {
return block()
} catch (e: IOException) {
if (countDown <= 0) {
val state = outState.value
outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true)
countDown = MAX_FAILSAFE_ATTEMPTS
pausingHandle.pause()
pausingHandle.awaitResumed()
outState.value = state
} else {
countDown--
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
}
private suspend fun downloadFile(
url: String,
destination: File,
tempFileName: String,
source: MangaSource,
): File {
val request = Request.Builder()
.url(url)
.tag(MangaSource::class.java, source)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.get()
.build()
val call = okHttp.newCall(request)
val file = File(destination, tempFileName)
val response = call.clone().await()
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyToSuspending(out)
}
return file
}
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
val prevValue = outState.value
outState.value = DownloadState.Error(
startId = prevValue.startId,
manga = prevValue.manga,
cover = prevValue.cover,
error = throwable,
canRetry = false,
)
}
private suspend fun loadCover(manga: Manga) = runCatchingCancellable {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.tag(manga.source)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build(),
).drawable
}.getOrNull()
private suspend inline fun <T> withMangaLock(manga: Manga, block: () -> T) = try {
localMangaRepository.lockManga(manga.id)
block()
} finally {
localMangaRepository.unlockManga(manga.id)
}
@AssistedFactory
interface Factory {
fun create(coroutineScope: CoroutineScope): DownloadManager
}
}

View File

@@ -1,123 +1,234 @@
package org.koitharu.kotatsu.download.domain
import androidx.work.Data
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalManga
import android.graphics.drawable.Drawable
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.Date
data class DownloadState(
val manga: Manga,
val isIndeterminate: Boolean,
val isPaused: Boolean = false,
val isStopped: Boolean = false,
val error: String? = null,
val totalChapters: Int = 0,
val currentChapter: Int = 0,
val totalPages: Int = 0,
val currentPage: Int = 0,
val eta: Long = -1L,
val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0),
val timestamp: Long = System.currentTimeMillis(),
) {
sealed interface DownloadState {
val max: Int = totalChapters * totalPages
val startId: Int
val manga: Manga
val cover: Drawable?
val progress: Int = totalPages * currentChapter + currentPage + 1
override fun equals(other: Any?): Boolean
val percent: Float = if (max > 0) progress.toFloat() / max else PROGRESS_NONE
override fun hashCode(): Int
val isFinalState: Boolean
get() = localManga != null || (error != null && !isPaused)
val isTerminal: Boolean
get() = this is Done || this is Cancelled || (this is Error && !canRetry)
val isParticularProgress: Boolean
get() = localManga == null && error == null && !isPaused && !isStopped && max > 0 && !isIndeterminate
class Queued(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
fun toWorkData() = Data.Builder()
.putLong(DATA_MANGA_ID, manga.id)
.putInt(DATA_MAX, max)
.putInt(DATA_PROGRESS, progress)
.putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused)
.build()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Queued
other as DownloadState
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (manga != other.manga) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (isStopped != other.isStopped) return false
if (error != other.error) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
return percent == other.percent
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + isStopped.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
result = 31 * result + percent.hashCode()
return result
class Preparing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Preparing
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
companion object {
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,
) : DownloadState {
private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter"
private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error"
private const val DATA_INDETERMINATE = "indeterminate"
private const val DATA_PAUSED = "paused"
val max: Int = totalChapters * totalPages
fun getMangaId(data: Data): Long = data.getLong(DATA_MANGA_ID, 0L)
val progress: Int = totalPages * currentChapter + currentPage + 1
fun isIndeterminate(data: Data): Boolean = data.getBoolean(DATA_INDETERMINATE, false)
val percent: Float = progress.toFloat() / max
fun isPaused(data: Data): Boolean = data.getBoolean(DATA_PAUSED, false)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
fun getMax(data: Data): Int = data.getInt(DATA_MAX, 0)
other as Progress
fun getError(data: Data): String? = data.getString(DATA_ERROR)
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
fun getProgress(data: Data): Int = data.getInt(DATA_PROGRESS, 0)
return true
}
fun getEta(data: Data): Long = data.getLong(DATA_ETA, -1L)
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
return result
}
}
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
class Done(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val localManga: Manga,
) : DownloadState {
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Done
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (localManga != other.localManga) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + localManga.hashCode()
return result
}
}
class Error(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
val error: Throwable,
val canRetry: Boolean,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Error
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
if (error != other.error) return false
if (canRetry != other.canRetry) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
result = 31 * result + error.hashCode()
result = 31 * result + canRetry.hashCode()
return result
}
}
class Cancelled(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Cancelled
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
class PostProcessing(
override val startId: Int,
override val manga: Manga,
override val cover: Drawable?,
) : DownloadState {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as PostProcessing
if (startId != other.startId) return false
if (manga != other.manga) return false
if (cover != other.cover) return false
return true
}
override fun hashCode(): Int {
var result = startId
result = 31 * result + manga.hashCode()
result = 31 * result + (cover?.hashCode() ?: 0)
return result
}
}
}

View File

@@ -0,0 +1,137 @@
package org.koitharu.kotatsu.download.ui
import android.view.View
import androidx.core.view.isVisible
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.onFirst
fun downloadItemAD(
scope: CoroutineScope,
coil: ImageLoader,
) = adapterDelegateViewBinding<DownloadItem, DownloadItem, ItemDownloadBinding>(
{ inflater, parent -> ItemDownloadBinding.inflate(inflater, parent, false) },
) {
var job: Job? = null
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_cancel -> item.cancel()
R.id.button_resume -> item.resume()
else -> context.startActivity(
DetailsActivity.newIntent(context, item.progressValue.manga),
)
}
}
binding.buttonCancel.setOnClickListener(clickListener)
binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener)
bind {
job?.cancel()
job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(state.manga.coverUrl, state.manga.source)?.run {
placeholder(state.cover)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
enqueueWith(coil)
}
}.onEach { state ->
binding.textViewTitle.text = state.manga.title
when (state) {
is DownloadState.Cancelled -> {
binding.textViewStatus.setText(R.string.cancelling_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Done -> {
binding.textViewStatus.setText(R.string.download_complete)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Error -> {
binding.textViewStatus.setText(R.string.error_occurred)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.text = state.error.getDisplayMessage(context.resources)
binding.textViewDetails.isVisible = true
binding.buttonCancel.isVisible = state.canRetry
binding.buttonResume.isVisible = state.canRetry
}
is DownloadState.PostProcessing -> {
binding.textViewStatus.setText(R.string.processing_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
}
is DownloadState.Preparing -> {
binding.textViewStatus.setText(R.string.preparing_)
binding.progressBar.isIndeterminate = true
binding.progressBar.isVisible = true
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Progress -> {
binding.textViewStatus.setText(R.string.manga_downloading_)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = true
binding.progressBar.max = state.max
binding.progressBar.setProgressCompat(state.progress, true)
binding.textViewPercent.text = percentPattern.format((state.percent * 100f).format(1))
binding.textViewPercent.isVisible = true
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
is DownloadState.Queued -> {
binding.textViewStatus.setText(R.string.queued)
binding.progressBar.isIndeterminate = false
binding.progressBar.isVisible = false
binding.textViewPercent.isVisible = false
binding.textViewDetails.isVisible = false
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
}
}
}.launchIn(scope)
}
onViewRecycled {
job?.cancel()
job = null
}
}

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