Merge branch 'devel' into feature/mal

pull/302/head
Koitharu 3 years ago
commit eef449af49
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -1,2 +1,2 @@
ko_fi: xtimms
custom: ["https://yoomoney.ru/to/410012543938752"] custom: ["https://yoomoney.ru/to/410012543938752"]
ko_fi: koitharu

@ -4,6 +4,6 @@
<option name="jvmTarget" value="1.8" /> <option name="jvmTarget" value="1.8" />
</component> </component>
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.10" /> <option name="version" value="1.7.20" />
</component> </component>
</project> </project>

@ -26,7 +26,7 @@ Download APK directly from GitHub:
* Notifications about new chapters with updates feed * Notifications about new chapters with updates feed
* Shikimori integration (manga tracking) * Shikimori integration (manga tracking)
* Password/fingerprint protect access to the app * Password/fingerprint protect access to the app
* History and favourites synchronization across devices (coming soon) * History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
### Screenshots ### Screenshots

@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 500 versionCode 503
versionName '4.0' versionName '4.0.3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -83,15 +83,15 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:5cb953eb86') { implementation('com.github.KotatsuApp:kotatsu-parsers:bf8a1f3db2') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.6.0' implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.3' implementation 'androidx.fragment:fragment-ktx:1.5.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-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-service:2.5.1'
@ -103,7 +103,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.7.0-rc01' implementation 'com.google.android.material:material:1.7.0'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
@ -123,9 +123,9 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.2.1' implementation 'io.coil-kt:coil-base:2.2.2'
implementation 'io.coil-kt:coil-svg:2.2.1' implementation 'io.coil-kt:coil-svg:2.2.2'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:0ff0278f0f' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:f8a38b08fe'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-http:5.9.6' implementation 'ch.acra:acra-http:5.9.6'
@ -134,7 +134,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20220320' testImplementation 'org.json:json:20220924'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.4.0' androidTestImplementation 'androidx.test:runner:1.4.0'

@ -28,6 +28,7 @@
android:fullBackupOnly="true" android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@ -108,8 +109,7 @@
<activity <activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity" android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true" android:exported="true"
android:label="@string/manga_shelf" android:label="@string/manga_shelf">
android:theme="@style/Theme.Kotatsu.DialogWhenLarge">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter> </intent-filter>
@ -127,18 +127,18 @@
<activity <activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity" android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads" android:label="@string/downloads"
android:launchMode="singleTop" android:launchMode="singleTop" />
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" /> <activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
<activity <activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
<activity <activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity" android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:label="@string/sync" /> android:label="@string/sync" />
<activity <activity
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity" android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
android:label="@string/color_correction" /> android:label="@string/color_correction" />
<activity
android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity"
android:label="@string/settings" />
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"

@ -10,7 +10,6 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker import androidx.room.InvalidationTracker
import androidx.work.Configuration import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.acra.ReportField import org.acra.ReportField
@ -25,6 +24,7 @@ import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class KotatsuApp : Application(), Configuration.Provider { class KotatsuApp : Application(), Configuration.Provider {
@ -72,6 +72,7 @@ class KotatsuApp : Application(), Configuration.Provider {
} }
reportContent = listOf( reportContent = listOf(
ReportField.PACKAGE_NAME, ReportField.PACKAGE_NAME,
ReportField.INSTALLATION_ID,
ReportField.APP_VERSION_CODE, ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME, ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION, ReportField.ANDROID_VERSION,

@ -53,7 +53,6 @@ abstract class BaseActivity<B : ViewBinding> :
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this) EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this)
val isAmoled = settings.isAmoledTheme val isAmoled = settings.isAmoledTheme
val isDynamic = settings.isDynamicTheme val isDynamic = settings.isDynamicTheme
// TODO support DialogWhenLarge theme
when { when {
isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled) isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled)
isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled) isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled)

@ -0,0 +1,22 @@
package org.koitharu.kotatsu.base.ui
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
interface DefaultActivityLifecycleCallbacks : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}

@ -6,7 +6,7 @@ import android.view.View.OnLongClickListener
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
class AdapterDelegateClickListenerAdapter<I>( class AdapterDelegateClickListenerAdapter<I>(
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<I, *>, private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
private val clickListener: OnListItemClickListener<I>, private val clickListener: OnListItemClickListener<I>,
) : OnClickListener, OnLongClickListener { ) : OnClickListener, OnLongClickListener {

@ -1,14 +1,14 @@
package org.koitharu.kotatsu.base.ui.util package org.koitharu.kotatsu.base.ui.util
import android.app.Activity import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle import android.os.Bundle
import java.util.* import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import java.util.WeakHashMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class ActivityRecreationHandle @Inject constructor() : ActivityLifecycleCallbacks { class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleCallbacks {
private val activities = WeakHashMap<Activity, Unit>() private val activities = WeakHashMap<Activity, Unit>()
@ -16,16 +16,6 @@ class ActivityRecreationHandle @Inject constructor() : ActivityLifecycleCallback
activities[activity] = Unit activities[activity] = Unit
} }
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) { override fun onActivityDestroyed(activity: Activity) {
activities.remove(activity) activities.remove(activity)
} }

@ -18,8 +18,6 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet import dagger.multibindings.ElementsIntoSet
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -40,9 +38,12 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.utils.IncognitoModeIndicator
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.image.CoilImageGetter import org.koitharu.kotatsu.utils.image.CoilImageGetter
import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.WidgetUpdater
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -152,9 +153,11 @@ interface AppModule {
fun provideActivityLifecycleCallbacks( fun provideActivityLifecycleCallbacks(
appProtectHelper: AppProtectHelper, appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle, activityRecreationHandle: ActivityRecreationHandle,
incognitoModeIndicator: IncognitoModeIndicator,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper, appProtectHelper,
activityRecreationHandle, activityRecreationHandle,
incognitoModeIndicator,
) )
} }
} }

@ -11,7 +11,7 @@ import org.koitharu.kotatsu.utils.ext.longHashCode
fun TagEntity.toMangaTag() = MangaTag( fun TagEntity.toMangaTag() = MangaTag(
key = this.key, key = this.key,
title = this.title.toTitleCase(), title = this.title.toTitleCase(),
source = MangaSource(this.source) ?: MangaSource.DUMMY, source = MangaSource(this.source),
) )
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag) fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
@ -28,7 +28,7 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
coverUrl = this.coverUrl, coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl, largeCoverUrl = this.largeCoverUrl,
author = this.author, author = this.author,
source = MangaSource(this.source) ?: MangaSource.DUMMY, source = MangaSource(this.source),
tags = tags, tags = tags,
) )

@ -6,9 +6,6 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
@ -20,6 +17,9 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.TaggedActivityResult import org.koitharu.kotatsu.utils.TaggedActivityResult
import org.koitharu.kotatsu.utils.isSuccess import org.koitharu.kotatsu.utils.isSuccess
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver private constructor( class ExceptionResolver private constructor(
private val activity: FragmentActivity?, private val activity: FragmentActivity?,
@ -49,6 +49,7 @@ class ExceptionResolver private constructor(
openInBrowser(e.url) openInBrowser(e.url)
false false
} }
else -> false else -> false
} }

@ -1,18 +1,17 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
fun MangaSource.getLocaleTitle(): String? { fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null) val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc) return lc.getDisplayLanguage(lc).toTitleCase(lc)
} }
@Suppress("FunctionName") fun MangaSource(name: String): MangaSource {
fun MangaSource(name: String): MangaSource? {
MangaSource.values().forEach { MangaSource.values().forEach {
if (it.name == name) return it if (it.name == name) return it
} }
return null return MangaSource.DUMMY
} }

@ -7,10 +7,12 @@ import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.ProducerScope import kotlinx.coroutines.channels.ProducerScope
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onSuccess
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.isNetworkAvailable import org.koitharu.kotatsu.utils.ext.isNetworkAvailable
import javax.inject.Inject import javax.inject.Inject
@ -26,7 +28,8 @@ class NetworkStateObserver @Inject constructor(
override val replayCache: List<Boolean> override val replayCache: List<Boolean>
get() = listOf(value) get() = listOf(value)
override var value: Boolean = connectivityManager.isNetworkAvailable override val value: Boolean
get() = connectivityManager.isNetworkAvailable
override suspend fun collect(collector: FlowCollector<Boolean>): Nothing { override suspend fun collect(collector: FlowCollector<Boolean>): Nothing {
collector.emit(value) collector.emit(value)
@ -35,6 +38,13 @@ class NetworkStateObserver @Inject constructor(
} }
} }
suspend fun awaitForConnection(): Unit {
if (value) {
return
}
first { it }
}
private fun observeImpl() = callbackFlow<Boolean> { private fun observeImpl() = callbackFlow<Boolean> {
val request = NetworkRequest.Builder().build() val request = NetworkRequest.Builder().build()
val callback = FlowNetworkCallback(this) val callback = FlowNetworkCallback(this)
@ -44,9 +54,12 @@ class NetworkStateObserver @Inject constructor(
} }
} }
inner class FlowNetworkCallback( private inner class FlowNetworkCallback(
private val producerScope: ProducerScope<Boolean>, private val producerScope: ProducerScope<Boolean>,
) : NetworkCallback() { ) : NetworkCallback() {
private var prevValue = value
override fun onAvailable(network: Network) = update() override fun onAvailable(network: Network) = update()
override fun onLost(network: Network) = update() override fun onLost(network: Network) = update()
@ -55,9 +68,10 @@ class NetworkStateObserver @Inject constructor(
private fun update() { private fun update() {
val newValue = connectivityManager.isNetworkAvailable val newValue = connectivityManager.isNetworkAvailable
if (value != newValue) { if (newValue != prevValue) {
value = newValue producerScope.trySendBlocking(newValue).onSuccess {
producerScope.trySendBlocking(newValue) prevValue = newValue
}
} }
} }
} }

@ -14,7 +14,6 @@ import coil.network.HttpException
import coil.request.Options import coil.request.Options
import coil.size.Size import coil.size.Size
import coil.size.pxOrElse import coil.size.pxOrElse
import java.net.HttpURLConnection
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@ -27,6 +26,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import java.net.HttpURLConnection
private const val FALLBACK_SIZE = 9999 // largest icon private const val FALLBACK_SIZE = 9999 // largest icon
@ -150,7 +150,7 @@ class FaviconFetcher(
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
return if (data.scheme == URI_SCHEME_FAVICON) { return if (data.scheme == URI_SCHEME_FAVICON) {
val mangaSource = MangaSource(data.schemeSpecificPart) ?: return null val mangaSource = MangaSource(data.schemeSpecificPart)
FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory) FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory)
} else { } else {
null null

@ -1,6 +0,0 @@
package org.koitharu.kotatsu.core.prefs
enum class AppSection {
LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS
}

@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
@ -44,14 +45,26 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val remoteMangaSources: Set<MangaSource> val remoteMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources) get() = Collections.unmodifiableSet(remoteSources)
var shelfSections: List<ShelfSection>
get() {
val raw = prefs.getString(KEY_SHELF_SECTIONS, null)
val values = enumValues<ShelfSection>()
if (raw.isNullOrEmpty()) {
return values.toList()
}
return raw.split('|')
.mapNotNull { values.getOrNull(it.toIntOrNull() ?: -1) }
.distinct()
}
set(value) {
val raw = value.joinToString("|") { it.ordinal.toString() }
prefs.edit { putString(KEY_SHELF_SECTIONS, raw) }
}
var listMode: ListMode var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
var defaultSection: AppSection
get() = prefs.getEnumValue(KEY_APP_SECTION, AppSection.HISTORY)
set(value) = prefs.edit { putEnumValue(KEY_APP_SECTION, value) }
val theme: Int val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
@ -342,6 +355,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom" const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_SHELF_SECTIONS = "shelf_sections_2"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

@ -17,7 +17,6 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -36,6 +35,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await 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.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
@ -219,10 +219,8 @@ class DownloadManager @AssistedInject constructor(
val call = okHttp.newCall(request) val call = okHttp.newCall(request)
val file = File(destination, tempFileName) val file = File(destination, tempFileName)
val response = call.clone().await() val response = call.clone().await()
runInterruptible(Dispatchers.IO) {
file.outputStream().use { out -> file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out) checkNotNull(response.body).byteStream().copyToSuspending(out)
}
} }
return file return file
} }

@ -6,24 +6,23 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.DialogListModeBinding import org.koitharu.kotatsu.databinding.DialogListModeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ListModeSelectDialog : class ListModeBottomSheet :
AlertDialogFragment<DialogListModeBinding>(), BaseBottomSheet<DialogListModeBinding>(),
CheckableButtonGroup.OnCheckedChangeListener, Slider.OnChangeListener,
Slider.OnChangeListener { MaterialButtonToggleGroup.OnButtonCheckedListener {
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@ -33,13 +32,6 @@ class ListModeSelectDialog :
container: ViewGroup?, container: ViewGroup?,
) = DialogListModeBinding.inflate(inflater, container, false) ) = DialogListModeBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.list_mode)
.setPositiveButton(R.string.done, null)
.setCancelable(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val mode = settings.listMode val mode = settings.listMode
@ -53,10 +45,10 @@ class ListModeSelectDialog :
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat()) binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
binding.sliderGrid.addOnChangeListener(this) binding.sliderGrid.addOnChangeListener(this)
binding.checkableGroup.onCheckedChangeListener = this binding.checkableGroup.addOnButtonCheckedListener(this)
} }
override fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) { override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
val mode = when (checkedId) { val mode = when (checkedId) {
R.id.button_list -> ListMode.LIST R.id.button_list -> ListMode.LIST
R.id.button_list_detailed -> ListMode.DETAILED_LIST R.id.button_list_detailed -> ListMode.DETAILED_LIST
@ -78,6 +70,6 @@ class ListModeSelectDialog :
private const val TAG = "ListModeSelectDialog" private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG) fun show(fm: FragmentManager) = ListModeBottomSheet().show(fm, TAG)
} }
} }

@ -17,9 +17,10 @@ class MangaListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_list_mode -> { R.id.action_list_mode -> {
ListModeSelectDialog.show(fragment.childFragmentManager) ListModeBottomSheet.show(fragment.childFragmentManager)
true true
} }
else -> false else -> false
} }
} }

@ -3,15 +3,17 @@ package org.koitharu.kotatsu.local.data
import android.content.Context import android.content.Context
import com.tomclaw.cache.DiskLruCache import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File import kotlinx.coroutines.Dispatchers
import java.io.InputStream import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.longHashCode import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
import java.io.InputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) { class PagesCache @Inject constructor(@ApplicationContext context: Context) {
@ -26,42 +28,15 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
return lruCache.get(url)?.takeIfReadable() return lruCache.get(url)?.takeIfReadable()
} }
fun put(url: String, inputStream: InputStream): File { suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) {
val file = File(cacheDir, url.longHashCode().toString()) val file = File(cacheDir, url.longHashCode().toString())
try {
file.outputStream().use { out -> file.outputStream().use { out ->
inputStream.copyTo(out) inputStream.copyToSuspending(out)
} }
val res = lruCache.put(url, file) lruCache.put(url, file)
} finally {
file.delete() file.delete()
return res
}
fun put(
url: String,
inputStream: InputStream,
contentLength: Long,
progress: MutableStateFlow<Float>,
): File {
val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use { out ->
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
publishProgress(contentLength, bytesCopied, progress)
bytes = inputStream.read(buffer)
}
}
val res = lruCache.put(url, file)
file.delete()
return res
}
private fun publishProgress(contentLength: Long, bytesCopied: Long, progress: MutableStateFlow<Float>) {
if (contentLength > 0) {
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
} }
} }
} }

@ -4,7 +4,6 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import java.io.File
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
@ -14,8 +13,10 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.longOf import org.koitharu.kotatsu.utils.ext.longOf
import java.io.File
// TODO: Add support for chapters in cbz // TODO: Add support for chapters in cbz
// https://github.com/KotatsuApp/Kotatsu/issues/31 // https://github.com/KotatsuApp/Kotatsu/issues/31
@ -62,6 +63,7 @@ class DirMangaImporter(
file.isDirectory -> { file.isDirectory -> {
addPages(output, file, path + "/" + file.name, state) addPages(output, file, path + "/" + file.name, state)
} }
file.isFile -> { file.isFile -> {
val tempFile = file.asTempFile() val tempFile = file.asTempFile()
if (!state.hasCover) { if (!state.hasCover) {
@ -86,7 +88,7 @@ class DirMangaImporter(
"Cannot open input stream for $uri" "Cannot open input stream for $uri"
}.use { input -> }.use { input ->
file.outputStream().use { output -> file.outputStream().use { output ->
input.copyTo(output) input.copyToSuspending(output)
} }
} }
return file return file

@ -1,8 +1,6 @@
package org.koitharu.kotatsu.local.domain.importer package org.koitharu.kotatsu.local.domain.importer
import android.net.Uri import android.net.Uri
import java.io.File
import java.io.IOException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -11,7 +9,10 @@ import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.resolveName import org.koitharu.kotatsu.utils.ext.resolveName
import java.io.File
import java.io.IOException
class ZipMangaImporter( class ZipMangaImporter(
storageManager: LocalStorageManager, storageManager: LocalStorageManager,
@ -27,10 +28,10 @@ class ZipMangaImporter(
} }
val dest = File(getOutputDir(), name) val dest = File(getOutputDir(), name)
runInterruptible { runInterruptible {
contentResolver.openInputStream(uri)?.use { source -> contentResolver.openInputStream(uri)
}?.use { source ->
dest.outputStream().use { output -> dest.outputStream().use { output ->
source.copyTo(output) source.copyToSuspending(output)
}
} }
} ?: throw IOException("Cannot open input stream: $uri") } ?: throw IOException("Cannot open input stream: $uri")
localMangaRepository.getFromFile(dest) localMangaRepository.getFromFile(dest)

@ -17,6 +17,7 @@ import androidx.core.graphics.Insets
import androidx.core.util.size import androidx.core.util.size
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@ -24,6 +25,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenResumed
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
@ -129,6 +131,7 @@ class MainActivity :
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged) viewModel.counters.observe(this, ::onCountersChanged)
viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged) viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged)
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
} }
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@ -152,11 +155,7 @@ class MainActivity :
} }
override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
if (fragment is ShelfFragment) { adjustFabVisibility(topFragment = fragment)
binding.fab?.show()
} else {
binding.fab?.hide()
}
if (fromUser) { if (fromUser) {
binding.appbar.setExpanded(true) binding.appbar.setExpanded(true)
} }
@ -278,6 +277,16 @@ class MainActivity :
navigationDelegate.setItemVisibility(R.id.nav_feed, isFeedAvailable) navigationDelegate.setItemVisibility(R.id.nav_feed, isFeedAvailable)
} }
private fun onIncognitoModeChanged(isIncognito: Boolean) {
var options = binding.searchView.imeOptions
options = if (isIncognito) {
options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else {
options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
}
binding.searchView.imeOptions = options
}
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
binding.fab?.isEnabled = !isLoading binding.fab?.isEnabled = !isLoading
} }
@ -313,8 +322,13 @@ class MainActivity :
private fun onFirstStart() { private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
when { when {
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager) !settings.isSourcesSelected -> whenResumed {
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager) OnboardDialogFragment.showWelcome(supportFragmentManager)
}
settings.newSources.isNotEmpty() -> whenResumed {
NewSourcesDialogFragment.show(supportFragmentManager)
}
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
@ -329,18 +343,18 @@ class MainActivity :
topFragment: Fragment? = navigationDelegate.primaryFragment, topFragment: Fragment? = navigationDelegate.primaryFragment,
isSearchOpened: Boolean = isSearchOpened(), isSearchOpened: Boolean = isSearchOpened(),
) { ) {
val fab = binding.fab val fab = binding.fab ?: return
if ( if (
isResumeEnabled && isResumeEnabled &&
!actionModeDelegate.isActionModeStarted && !actionModeDelegate.isActionModeStarted &&
!isSearchOpened && !isSearchOpened &&
topFragment is ShelfFragment topFragment is ShelfFragment
) { ) {
if (fab?.isVisible == false) { if (!fab.isVisible) {
fab.show() fab.show()
} }
} else { } else {
if (fab?.isVisible == true) { if (fab.isVisible) {
fab.hide() fab.hide()
} }
} }

@ -1,16 +1,16 @@
package org.koitharu.kotatsu.main.ui.protect package org.koitharu.kotatsu.main.ui.protect
import android.app.Activity import android.app.Activity
import android.app.Application
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import javax.inject.Inject
import javax.inject.Singleton
import org.acra.dialog.CrashReportDialog import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Singleton
class AppProtectHelper @Inject constructor(private val settings: AppSettings) : Application.ActivityLifecycleCallbacks { class AppProtectHelper @Inject constructor(private val settings: AppSettings) : DefaultActivityLifecycleCallbacks {
private var isUnlocked = settings.appPassword.isNullOrEmpty() private var isUnlocked = settings.appPassword.isNullOrEmpty()
@ -27,16 +27,6 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) :
} }
} }
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) { override fun onActivityDestroyed(activity: Activity) {
if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) { if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) {
restoreLock() restoreLock()

@ -7,14 +7,16 @@ import android.net.Uri
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File import kotlinx.coroutines.CompletableDeferred
import java.util.* import kotlinx.coroutines.CoroutineExceptionHandler
import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CoroutineScope
import java.util.zip.ZipFile import kotlinx.coroutines.Dispatchers
import javax.inject.Inject import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.* import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -30,7 +32,16 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.withProgress
import org.koitharu.kotatsu.utils.progress.ProgressDeferred import org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
private const val PROGRESS_UNDEFINED = -1f private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10 private const val PREFETCH_LIMIT_DEFAULT = 10
@ -43,7 +54,7 @@ class PageLoader @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : Closeable { ) : Closeable {
val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val loaderScope = CoroutineScope(SupervisorJob() + InternalErrorHandler() + Dispatchers.Default)
private val connectivityManager = context.connectivityManager private val connectivityManager = context.connectivityManager
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>() private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
@ -56,8 +67,10 @@ class PageLoader @Inject constructor(
override fun close() { override fun close() {
loaderScope.cancel() loaderScope.cancel()
synchronized(tasks) {
tasks.clear() tasks.clear()
} }
}
fun isPrefetchApplicable(): Boolean { fun isPrefetchApplicable(): Boolean {
return repository is RemoteMangaRepository && settings.isPagesPreloadAllowed(connectivityManager) return repository is RemoteMangaRepository && settings.isPagesPreloadAllowed(connectivityManager)
@ -93,7 +106,9 @@ class PageLoader @Inject constructor(
return task return task
} }
task = loadPageAsyncImpl(page) task = loadPageAsyncImpl(page)
synchronized(tasks) {
tasks[page.id] = task tasks[page.id] = task
}
return task return task
} }
@ -125,7 +140,9 @@ class PageLoader @Inject constructor(
while (prefetchQueue.isNotEmpty()) { while (prefetchQueue.isNotEmpty()) {
val page = prefetchQueue.pollFirst() ?: return val page = prefetchQueue.pollFirst() ?: return
if (cache[page.url] == null) { if (cache[page.url] == null) {
synchronized(tasks) {
tasks[page.id] = loadPageAsyncImpl(page) tasks[page.id] = loadPageAsyncImpl(page)
}
return return
} }
} }
@ -163,9 +180,12 @@ class PageLoader @Inject constructor(
val uri = Uri.parse(pageUrl) val uri = Uri.parse(pageUrl)
return if (uri.scheme == "cbz") { return if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart) ZipFile(uri.schemeSpecificPart)
}.use { zip ->
runInterruptible(Dispatchers.IO) {
val entry = zip.getEntry(uri.fragment) val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use { zip.getInputStream(entry)
}.use {
cache.put(pageUrl, it) cache.put(pageUrl, it)
} }
} }
@ -184,10 +204,8 @@ class PageLoader @Inject constructor(
val body = checkNotNull(response.body) { val body = checkNotNull(response.body) {
"Null response" "Null response"
} }
runInterruptible(Dispatchers.IO) { body.withProgress(progress).byteStream().use {
body.byteStream().use { cache.put(pageUrl, it)
cache.put(pageUrl, it, body.contentLength(), progress)
}
} }
} }
} }
@ -197,4 +215,13 @@ class PageLoader @Inject constructor(
val deferred = CompletableDeferred(file) val deferred = CompletableDeferred(file)
return ProgressDeferred(deferred, emptyProgressFlow) return ProgressDeferred(deferred, emptyProgressFlow)
} }
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTraceDebug()
}
}
} }

@ -6,10 +6,6 @@ import android.webkit.MimeTypeMap
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.core.net.toUri import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@ -20,6 +16,11 @@ import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
private const val MAX_FILENAME_LENGTH = 10 private const val MAX_FILENAME_LENGTH = 10
private const val EXTENSION_FALLBACK = "png" private const val EXTENSION_FALLBACK = "png"
@ -48,12 +49,12 @@ class PageSaveHelper @Inject constructor(
} }
} }
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.use { output -> contentResolver.openOutputStream(destination)
}?.use { output ->
pageFile.inputStream().use { input -> pageFile.inputStream().use { input ->
input.copyTo(output) input.copyToSuspending(output)
} }
} ?: throw IOException("Output stream is null") } ?: throw IOException("Output stream is null")
}
return destination return destination
} }

@ -58,6 +58,7 @@ import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.observeWithPrevious import org.koitharu.kotatsu.utils.ext.observeWithPrevious
import org.koitharu.kotatsu.utils.ext.postDelayed import org.koitharu.kotatsu.utils.ext.postDelayed
import org.koitharu.kotatsu.utils.ext.report import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.setValueRounded
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -388,7 +389,7 @@ class ReaderActivity :
} }
if (uiState.isSliderAvailable()) { if (uiState.isSliderAvailable()) {
binding.slider.valueTo = uiState.totalPages.toFloat() - 1 binding.slider.valueTo = uiState.totalPages.toFloat() - 1
binding.slider.value = uiState.currentPage.toFloat() binding.slider.setValueRounded(uiState.currentPage.toFloat())
binding.slider.isVisible = true binding.slider.isVisible = true
} else { } else {
binding.slider.isVisible = false binding.slider.isVisible = false

@ -103,8 +103,8 @@ class ColorFilterConfigActivity :
} }
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
binding.sliderBrightness.value = readerColorFilter?.brightness ?: 0f binding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f)
binding.sliderContrast.value = readerColorFilter?.contrast ?: 0f binding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f)
binding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() binding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
} }

@ -23,6 +23,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ScreenOrientationHelper import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
@ -65,7 +66,7 @@ class ReaderConfigBottomSheet :
binding.sliderTimer.setLabelFormatter(PageSwitchTimer.DelayLabelFormatter(view.resources)) binding.sliderTimer.setLabelFormatter(PageSwitchTimer.DelayLabelFormatter(view.resources))
findCallback()?.run { findCallback()?.run {
binding.sliderTimer.value = pageSwitchDelay binding.sliderTimer.setValueRounded(pageSwitchDelay)
} }
} }
@ -75,13 +76,16 @@ class ReaderConfigBottomSheet :
startActivity(SettingsActivity.newReaderSettingsIntent(v.context)) startActivity(SettingsActivity.newReaderSettingsIntent(v.context))
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
R.id.button_save_page -> { R.id.button_save_page -> {
val page = viewModel.getCurrentPage() ?: return val page = viewModel.getCurrentPage() ?: return
viewModel.saveCurrentPage(page, savePageRequest) viewModel.saveCurrentPage(page, savePageRequest)
} }
R.id.button_screen_rotate -> { R.id.button_screen_rotate -> {
orientationHelper?.toggleOrientation() orientationHelper?.toggleOrientation()
} }
R.id.button_color_filter -> { R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.manga ?: return val manga = viewModel.manga ?: return

@ -5,6 +5,7 @@ import androidx.annotation.CallSuper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@ -13,11 +14,12 @@ abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B, protected val binding: B,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkStateObserver: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback { ) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
@Suppress("LeakingThis") @Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver) protected val delegate = PageHolderDelegate(loader, settings, this, networkStateObserver, exceptionResolver)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
val context: Context val context: Context

@ -4,17 +4,19 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>( abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader, private val loader: PageLoader,
private val readerSettings: ReaderSettings, private val readerSettings: ReaderSettings,
private val networkState: NetworkStateObserver,
private val exceptionResolver: ExceptionResolver, private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() { ) : RecyclerView.Adapter<H>() {
@ -56,9 +58,9 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder( final override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
viewType: Int, viewType: Int,
): H = onCreateViewHolder(parent, loader, readerSettings, exceptionResolver) ): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont -> suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont ->
differ.submitList(items) { differ.submitList(items) {
cont.resume(Unit) cont.resume(Unit)
} }
@ -68,6 +70,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
): H ): H

@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -16,6 +17,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@ -26,6 +28,7 @@ class PageHolderDelegate(
private val loader: PageLoader, private val loader: PageLoader,
private val readerSettings: ReaderSettings, private val readerSettings: ReaderSettings,
private val callback: Callback, private val callback: Callback,
private val networkState: NetworkStateObserver,
private val exceptionResolver: ExceptionResolver, private val exceptionResolver: ExceptionResolver,
) : DefaultOnImageEventListener, Observer<ReaderSettings> { ) : DefaultOnImageEventListener, Observer<ReaderSettings> {
@ -118,29 +121,35 @@ class PageHolderDelegate(
} }
} }
private suspend fun CoroutineScope.doLoad(data: MangaPage, force: Boolean) { private suspend fun doLoad(data: MangaPage, force: Boolean) {
state = State.LOADING state = State.LOADING
error = null error = null
callback.onLoadingStarted() callback.onLoadingStarted()
try { try {
val task = loader.loadPageAsync(data, force) val task = loader.loadPageAsync(data, force)
file = coroutineScope {
val progressObserver = observeProgress(this, task.progressAsFlow()) val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await() val file = task.await()
progressObserver.cancel() progressObserver.cancel()
this@PageHolderDelegate.file = file file
}
state = State.LOADED state = State.LOADED
callback.onImageReady(file.toUri()) callback.onImageReady(checkNotNull(file).toUri())
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Throwable) {
state = State.ERROR state = State.ERROR
error = e error = e
callback.onError(e) callback.onError(e)
if (e is IOException && !networkState.value) {
networkState.awaitForConnection()
retry(data)
}
} }
} }
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.debounce(500) .debounce(250)
.onEach { callback.onProgressChanged((100 * it).toInt()) } .onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope) .launchIn(scope)

@ -6,6 +6,7 @@ import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@ -15,8 +16,9 @@ class ReversedPageHolder(
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : PageHolder(binding, loader, settings, exceptionResolver) { ) : PageHolder(binding, loader, settings, networkState, exceptionResolver) {
init { init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams) (binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
@ -35,6 +37,7 @@ class ReversedPageHolder(
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
resetScaleAndCenter() resetScaleAndCenter()
} }
ZoomMode.FIT_HEIGHT -> { ZoomMode.FIT_HEIGHT -> {
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
minScale = height / sHeight.toFloat() minScale = height / sHeight.toFloat()
@ -43,6 +46,7 @@ class ReversedPageHolder(
PointF(sWidth.toFloat(), sHeight / 2f), PointF(sWidth.toFloat(), sHeight / 2f),
) )
} }
ZoomMode.FIT_WIDTH -> { ZoomMode.FIT_WIDTH -> {
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
minScale = width / sWidth.toFloat() minScale = width / sWidth.toFloat()
@ -51,6 +55,7 @@ class ReversedPageHolder(
PointF(sWidth / 2f, 0f), PointF(sWidth / 2f, 0f),
) )
} }
ZoomMode.KEEP_START -> { ZoomMode.KEEP_START -> {
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
setScaleAndCenter( setScaleAndCenter(

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@ -11,18 +12,21 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter( class ReversedPagesAdapter(
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, exceptionResolver) { ) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = ReversedPageHolder( ) = ReversedPageHolder(
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader, loader = loader,
settings = settings, settings = settings,
networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )
} }

@ -7,8 +7,8 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.children import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.absoluteValue
import kotlinx.coroutines.async import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@ -19,10 +19,15 @@ import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.recyclerView import org.koitharu.kotatsu.utils.ext.recyclerView
import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.resetTransformations
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
import kotlin.math.absoluteValue
@AndroidEntryPoint @AndroidEntryPoint
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() { class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@Inject
lateinit var networkStateObserver: NetworkStateObserver
private var pagerAdapter: ReversedPagesAdapter? = null private var pagerAdapter: ReversedPagesAdapter? = null
override fun onInflateView( override fun onInflateView(
@ -33,7 +38,12 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver) pagerAdapter = ReversedPagesAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkStateObserver,
exceptionResolver,
)
with(binding.pager) { with(binding.pager) {
adapter = pagerAdapter adapter = pagerAdapter
offscreenPageLimit = 2 offscreenPageLimit = 2
@ -44,8 +54,8 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
val transformer = if (it) ReversedPageAnimTransformer() else null val transformer = if (it) ReversedPageAnimTransformer() else null
binding.pager.setPageTransformer(transformer) binding.pager.setPageTransformer(transformer)
if (transformer == null) { if (transformer == null) {
binding.pager.recyclerView?.children?.forEach { binding.pager.recyclerView?.children?.forEach { v ->
it.resetTransformations() v.resetTransformations()
} }
} }
} }

@ -10,6 +10,7 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@ -21,8 +22,9 @@ open class PageHolder(
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, exceptionResolver), ) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver),
View.OnClickListener { View.OnClickListener {
init { init {
@ -74,6 +76,7 @@ open class PageHolder(
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
binding.ssiv.resetScaleAndCenter() binding.ssiv.resetScaleAndCenter()
} }
ZoomMode.FIT_HEIGHT -> { ZoomMode.FIT_HEIGHT -> {
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat() binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat()
@ -82,6 +85,7 @@ open class PageHolder(
PointF(0f, binding.ssiv.sHeight / 2f), PointF(0f, binding.ssiv.sHeight / 2f),
) )
} }
ZoomMode.FIT_WIDTH -> { ZoomMode.FIT_WIDTH -> {
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat() binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat()
@ -90,6 +94,7 @@ open class PageHolder(
PointF(binding.ssiv.sWidth / 2f, 0f), PointF(binding.ssiv.sWidth / 2f, 0f),
) )
} }
ZoomMode.KEEP_START -> { ZoomMode.KEEP_START -> {
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
binding.ssiv.setScaleAndCenter( binding.ssiv.setScaleAndCenter(

@ -7,8 +7,8 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.children import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.absoluteValue
import kotlinx.coroutines.async import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@ -18,10 +18,15 @@ import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.recyclerView import org.koitharu.kotatsu.utils.ext.recyclerView
import org.koitharu.kotatsu.utils.ext.resetTransformations import org.koitharu.kotatsu.utils.ext.resetTransformations
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
import kotlin.math.absoluteValue
@AndroidEntryPoint @AndroidEntryPoint
class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() { class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@Inject
lateinit var networkStateObserver: NetworkStateObserver
private var pagesAdapter: PagesAdapter? = null private var pagesAdapter: PagesAdapter? = null
override fun onInflateView( override fun onInflateView(
@ -32,7 +37,12 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver) pagesAdapter = PagesAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkStateObserver,
exceptionResolver,
)
with(binding.pager) { with(binding.pager) {
adapter = pagesAdapter adapter = pagesAdapter
offscreenPageLimit = 2 offscreenPageLimit = 2

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.ItemPageBinding import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@ -11,18 +12,21 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter( class PagesAdapter(
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkStateObserver: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<PageHolder>(loader, settings, exceptionResolver) { ) : BaseReaderAdapter<PageHolder>(loader, settings, networkStateObserver, exceptionResolver) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = PageHolder( ) = PageHolder(
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false), binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader, loader = loader,
settings = settings, settings = settings,
networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )
} }

@ -3,7 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@ -12,13 +12,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter( class WebtoonAdapter(
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, exceptionResolver) { ) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) {
override fun onCreateViewHolder( override fun onCreateViewHolder(
parent: ViewGroup, parent: ViewGroup,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) = WebtoonHolder( ) = WebtoonHolder(
binding = ItemPageWebtoonBinding.inflate( binding = ItemPageWebtoonBinding.inflate(
@ -28,6 +30,7 @@ class WebtoonAdapter(
), ),
loader = loader, loader = loader,
settings = settings, settings = settings,
networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
) )
} }

@ -8,20 +8,26 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hideCompat
import org.koitharu.kotatsu.utils.ext.ifZero
import org.koitharu.kotatsu.utils.ext.setProgressCompat
import org.koitharu.kotatsu.utils.ext.showCompat
class WebtoonHolder( class WebtoonHolder(
binding: ItemPageWebtoonBinding, binding: ItemPageWebtoonBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, settings: ReaderSettings,
networkState: NetworkStateObserver,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver), ) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver),
View.OnClickListener { View.OnClickListener {
private var scrollToRestore = 0 private var scrollToRestore = 0

@ -7,6 +7,7 @@ import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@ -15,10 +16,14 @@ import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() { class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
@Inject
lateinit var networkStateObserver: NetworkStateObserver
private val scrollInterpolator = AccelerateDecelerateInterpolator() private val scrollInterpolator = AccelerateDecelerateInterpolator()
private var webtoonAdapter: WebtoonAdapter? = null private var webtoonAdapter: WebtoonAdapter? = null
@ -29,7 +34,12 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver) webtoonAdapter = WebtoonAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkStateObserver,
exceptionResolver,
)
with(binding.recyclerView) { with(binding.recyclerView) {
setHasFixedSize(true) setHasFixedSize(true)
adapter = webtoonAdapter adapter = webtoonAdapter

@ -6,6 +6,7 @@ import android.os.Bundle
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.commit import androidx.fragment.app.commit
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -15,7 +16,6 @@ import org.koitharu.kotatsu.databinding.ActivitySearchBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.utils.ext.showKeyboard import org.koitharu.kotatsu.utils.ext.showKeyboard
import kotlin.text.Typography.dagger
@AndroidEntryPoint @AndroidEntryPoint
class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener { class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener {
@ -32,6 +32,7 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
} }
val query = intent.getStringExtra(EXTRA_QUERY) val query = intent.getStringExtra(EXTRA_QUERY)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
with(binding.searchView) { with(binding.searchView) {
queryHint = getString(R.string.search_on_s, source.title) queryHint = getString(R.string.search_on_s, source.title)
setOnQueryTextListener(this@SearchActivity) setOnQueryTextListener(this@SearchActivity)
@ -72,6 +73,16 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
override fun onQueryTextChange(newText: String?): Boolean = false override fun onQueryTextChange(newText: String?): Boolean = false
private fun onIncognitoModeChanged(isIncognito: Boolean) {
var options = binding.searchView.imeOptions
options = if (isIncognito) {
options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else {
options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
}
binding.searchView.imeOptions = options
}
companion object { companion object {
private const val EXTRA_SOURCE = "source" private const val EXTRA_SOURCE = "source"

@ -3,17 +3,28 @@ package org.koitharu.kotatsu.search.ui.suggestion
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.* import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.widgets.ChipsView import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.MangaSearchRepository import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import javax.inject.Inject
private const val DEBOUNCE_TIMEOUT = 500L private const val DEBOUNCE_TIMEOUT = 500L
private const val MAX_MANGA_ITEMS = 6 private const val MAX_MANGA_ITEMS = 6
@ -30,6 +41,12 @@ class SearchSuggestionViewModel @Inject constructor(
private val query = MutableStateFlow("") private val query = MutableStateFlow("")
private var suggestionJob: Job? = null private var suggestionJob: Job? = null
val isIncognitoModeEnabled = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_INCOGNITO_MODE,
valueProducer = { isIncognitoModeEnabled },
)
val suggestion = MutableLiveData<List<SearchSuggestionItem>>() val suggestion = MutableLiveData<List<SearchSuggestionItem>>()
init { init {
@ -41,8 +58,12 @@ class SearchSuggestionViewModel @Inject constructor(
} }
fun saveQuery(query: String) { fun saveQuery(query: String) {
launchJob(Dispatchers.Default) {
if (!settings.isIncognitoModeEnabled) {
repository.saveSearchQuery(query) repository.saveSearchQuery(query)
} }
}
}
fun clearSearchHistory() { fun clearSearchHistory() {
launchJob { launchJob {

@ -30,7 +30,7 @@ class OnboardViewModel @Inject constructor(
init { init {
if (settings.isSourcesSelected) { if (settings.isSourcesSelected) {
selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x)?.locale }) selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x).locale })
} else { } else {
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
x.language x.language

@ -0,0 +1,35 @@
package org.koitharu.kotatsu.shelf.domain
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.parsers.model.Manga
class ShelfContent(
val history: List<MangaWithHistory>,
val favourites: Map<FavouriteCategory, List<Manga>>,
val updated: Map<Manga, Int>,
val local: List<Manga>,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ShelfContent
if (history != other.history) return false
if (favourites != other.favourites) return false
if (updated != other.updated) return false
if (local != other.local) return false
return true
}
override fun hashCode(): Int {
var result = history.hashCode()
result = 31 * result + favourites.hashCode()
result = 31 * result + updated.hashCode()
result = 31 * result + local.hashCode()
return result
}
}

@ -21,15 +21,26 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
class ShelfRepository @Inject constructor( class ShelfRepository @Inject constructor(
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository,
private val db: MangaDatabase, private val db: MangaDatabase,
) { ) {
fun observeShelfContent(): Flow<ShelfContent> = combine(
historyRepository.observeAllWithHistory(),
observeLocalManga(SortOrder.UPDATED),
observeFavourites(),
trackingRepository.observeUpdatedManga(),
) { history, local, favorites, updated ->
ShelfContent(history, favorites, updated, local)
}
fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> { fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> {
return flow { return flow {
emit(null) emit(null)

@ -0,0 +1,6 @@
package org.koitharu.kotatsu.shelf.domain
enum class ShelfSection {
HISTORY, LOCAL, UPDATED, FAVORITES;
}

@ -6,16 +6,16 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.*
import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.shelf.ui.config.categories.ShelfCategoriesConfigSheet
import org.koitharu.kotatsu.shelf.ui.config.size.ShelfSizeBottomSheet
import org.koitharu.kotatsu.local.ui.ImportDialogFragment import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity
import org.koitharu.kotatsu.shelf.ui.config.size.ShelfSizeBottomSheet
import org.koitharu.kotatsu.utils.ext.startOfDay import org.koitharu.kotatsu.utils.ext.startOfDay
import java.util.Date
import java.util.concurrent.TimeUnit
import com.google.android.material.R as materialR
class ShelfMenuProvider( class ShelfMenuProvider(
private val context: Context, private val context: Context,
@ -33,18 +33,22 @@ class ShelfMenuProvider(
showClearHistoryDialog() showClearHistoryDialog()
true true
} }
R.id.action_grid_size -> { R.id.action_grid_size -> {
ShelfSizeBottomSheet.show(fragmentManager) ShelfSizeBottomSheet.show(fragmentManager)
true true
} }
R.id.action_import -> { R.id.action_import -> {
ImportDialogFragment.show(fragmentManager) ImportDialogFragment.show(fragmentManager)
true true
} }
R.id.action_categories -> { R.id.action_categories -> {
ShelfCategoriesConfigSheet.show(fragmentManager) context.startActivity(ShelfSettingsActivity.newIntent(context))
true true
} }
else -> false else -> false
} }
} }

@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.os.NetworkStateObserver import org.koitharu.kotatsu.core.os.NetworkStateObserver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory import org.koitharu.kotatsu.history.domain.MangaWithHistory
@ -29,8 +30,9 @@ import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.shelf.domain.ShelfContent
import org.koitharu.kotatsu.shelf.domain.ShelfRepository import org.koitharu.kotatsu.shelf.domain.ShelfRepository
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
@ -44,19 +46,17 @@ class ShelfViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val settings: AppSettings, private val settings: AppSettings,
private val networkStateObserver: NetworkStateObserver, networkStateObserver: NetworkStateObserver,
) : BaseViewModel(), ListExtraProvider { ) : BaseViewModel(), ListExtraProvider {
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = SingleLiveEvent<ReversibleAction>()
val content: LiveData<List<ListModel>> = combine( val content: LiveData<List<ListModel>> = combine(
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
networkStateObserver, networkStateObserver,
historyRepository.observeAllWithHistory(), repository.observeShelfContent(),
repository.observeLocalManga(SortOrder.UPDATED), ) { sections, isConnected, content ->
repository.observeFavourites(), mapList(content, sections, isConnected)
trackingRepository.observeUpdatedManga(),
) { isConnected, history, local, favourites, updated ->
mapList(history, favourites, updated, local, isConnected)
}.debounce(500) }.debounce(500)
.catch { e -> .catch { e ->
emit(listOf(e.toErrorState(canRetry = false))) emit(listOf(e.toErrorState(canRetry = false)))
@ -134,25 +134,19 @@ class ShelfViewModel @Inject constructor(
} }
private suspend fun mapList( private suspend fun mapList(
history: List<MangaWithHistory>, content: ShelfContent,
favourites: Map<FavouriteCategory, List<Manga>>, sections: List<ShelfSection>,
updated: Map<Manga, Int>,
local: List<Manga>,
isNetworkAvailable: Boolean, isNetworkAvailable: Boolean,
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(favourites.keys.size + 3) val result = ArrayList<ListModel>(content.favourites.keys.size + 3)
if (isNetworkAvailable) { if (isNetworkAvailable) {
if (history.isNotEmpty()) { for (section in sections) {
mapHistory(result, history) when (section) {
} ShelfSection.HISTORY -> mapHistory(result, content.history)
if (local.isNotEmpty()) { ShelfSection.LOCAL -> mapLocal(result, content.local)
mapLocal(result, local) ShelfSection.UPDATED -> mapUpdated(result, content.updated)
ShelfSection.FAVORITES -> mapFavourites(result, content.favourites)
} }
if (updated.isNotEmpty()) {
mapUpdated(result, updated)
}
if (favourites.isNotEmpty()) {
mapFavourites(result, favourites)
} }
} else { } else {
result += EmptyHint( result += EmptyHint(
@ -161,12 +155,17 @@ class ShelfViewModel @Inject constructor(
textSecondary = R.string.network_unavailable_hint, textSecondary = R.string.network_unavailable_hint,
actionStringRes = R.string.manage, actionStringRes = R.string.manage,
) )
val offlineHistory = history.filter { it.manga.source == MangaSource.LOCAL } for (section in sections) {
if (offlineHistory.isNotEmpty()) { when (section) {
mapHistory(result, offlineHistory) ShelfSection.HISTORY -> mapHistory(
result,
content.history.filter { it.manga.source == MangaSource.LOCAL },
)
ShelfSection.LOCAL -> mapLocal(result, content.local)
ShelfSection.UPDATED -> Unit
ShelfSection.FAVORITES -> Unit
} }
if (local.isNotEmpty()) {
mapLocal(result, local)
} }
} }
if (result.isEmpty()) { if (result.isEmpty()) {
@ -189,6 +188,9 @@ class ShelfViewModel @Inject constructor(
destination: MutableList<in ShelfSectionModel.History>, destination: MutableList<in ShelfSectionModel.History>,
list: List<MangaWithHistory>, list: List<MangaWithHistory>,
) { ) {
if (list.isEmpty()) {
return
}
val showPercent = settings.isReadingIndicatorsEnabled val showPercent = settings.isReadingIndicatorsEnabled
destination += ShelfSectionModel.History( destination += ShelfSectionModel.History(
items = list.map { (manga, history) -> items = list.map { (manga, history) ->
@ -204,6 +206,9 @@ class ShelfViewModel @Inject constructor(
destination: MutableList<in ShelfSectionModel.Updated>, destination: MutableList<in ShelfSectionModel.Updated>,
updated: Map<Manga, Int>, updated: Map<Manga, Int>,
) { ) {
if (updated.isEmpty()) {
return
}
val showPercent = settings.isReadingIndicatorsEnabled val showPercent = settings.isReadingIndicatorsEnabled
destination += ShelfSectionModel.Updated( destination += ShelfSectionModel.Updated(
items = updated.map { (manga, counter) -> items = updated.map { (manga, counter) ->
@ -218,6 +223,9 @@ class ShelfViewModel @Inject constructor(
destination: MutableList<in ShelfSectionModel.Local>, destination: MutableList<in ShelfSectionModel.Local>,
local: List<Manga>, local: List<Manga>,
) { ) {
if (local.isEmpty()) {
return
}
destination += ShelfSectionModel.Local( destination += ShelfSectionModel.Local(
items = local.toUi(ListMode.GRID, this), items = local.toUi(ListMode.GRID, this),
showAllButtonText = R.string.show_all, showAllButtonText = R.string.show_all,
@ -228,6 +236,9 @@ class ShelfViewModel @Inject constructor(
destination: MutableList<in ShelfSectionModel.Favourites>, destination: MutableList<in ShelfSectionModel.Favourites>,
favourites: Map<FavouriteCategory, List<Manga>>, favourites: Map<FavouriteCategory, List<Manga>>,
) { ) {
if (favourites.isEmpty()) {
return
}
for ((category, list) in favourites) { for ((category, list) in favourites) {
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
destination += ShelfSectionModel.Favourites( destination += ShelfSectionModel.Favourites(

@ -0,0 +1,101 @@
package org.koitharu.kotatsu.shelf.ui.config
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityShelfSettingsBinding
@AndroidEntryPoint
class ShelfSettingsActivity :
BaseActivity<ActivityShelfSettingsBinding>(),
View.OnClickListener, ShelfSettingsListener {
private val viewModel by viewModels<ShelfSettingsViewModel>()
private lateinit var reorderHelper: ItemTouchHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityShelfSettingsBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
}
binding.buttonDone.setOnClickListener(this)
val settingsAdapter = ShelfSettingsAdapter(this)
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = settingsAdapter
reorderHelper = ItemTouchHelper(SectionsReorderCallback()).also {
it.attachToRecyclerView(this)
}
}
viewModel.content.observe(this) { settingsAdapter.items = it }
}
override fun onItemCheckedChanged(item: ShelfSettingsItemModel, isChecked: Boolean) {
viewModel.setItemChecked(item, isChecked)
}
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) {
reorderHelper.startDrag(holder)
}
override fun onClick(v: View?) {
finishAfterTransition()
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding(
left = insets.left,
right = insets.right,
)
binding.recyclerView.updatePadding(
bottom = insets.bottom,
)
binding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
private inner class SectionsReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSections(
viewHolder.bindingAdapterPosition,
target.bindingAdapterPosition,
)
override fun canDropOver(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}
companion object {
fun newIntent(context: Context) = Intent(context, ShelfSettingsActivity::class.java)
}
}

@ -0,0 +1,41 @@
package org.koitharu.kotatsu.shelf.ui.config
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
class ShelfSettingsAdapter(
listener: ShelfSettingsListener,
) : AsyncListDifferDelegationAdapter<ShelfSettingsItemModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(shelfCategoryAD(listener))
.addDelegate(shelfSectionAD(listener))
}
class DiffCallback : DiffUtil.ItemCallback<ShelfSettingsItemModel>() {
override fun areItemsTheSame(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Boolean {
return when {
oldItem is ShelfSettingsItemModel.Section && newItem is ShelfSettingsItemModel.Section -> {
oldItem.section == newItem.section
}
oldItem is ShelfSettingsItemModel.FavouriteCategory && newItem is ShelfSettingsItemModel.FavouriteCategory -> {
oldItem.id == newItem.id
}
else -> false
}
}
override fun areContentsTheSame(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Any? {
return if (oldItem.isChecked == newItem.isChecked) {
super.getChangePayload(oldItem, newItem)
} else Unit
}
}
}

@ -0,0 +1,75 @@
package org.koitharu.kotatsu.shelf.ui.config
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.widget.CompoundButton
import androidx.core.view.updatePaddingRelative
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemShelfSectionDraggableBinding
import org.koitharu.kotatsu.shelf.domain.ShelfSection
@SuppressLint("ClickableViewAccessibility")
fun shelfSectionAD(
listener: ShelfSettingsListener,
) =
adapterDelegateViewBinding<ShelfSettingsItemModel.Section, ShelfSettingsItemModel, ItemShelfSectionDraggableBinding>(
{ layoutInflater, parent -> ItemShelfSectionDraggableBinding.inflate(layoutInflater, parent, false) },
) {
val eventListener = object :
View.OnTouchListener,
CompoundButton.OnCheckedChangeListener {
override fun onTouch(v: View?, event: MotionEvent): Boolean {
return if (event.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onDragHandleTouch(this@adapterDelegateViewBinding)
true
} else {
false
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
listener.onItemCheckedChanged(item, isChecked)
}
}
binding.switchToggle.setOnCheckedChangeListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener)
bind {
binding.textViewTitle.setText(item.section.titleResId)
binding.switchToggle.isChecked = item.isChecked
}
}
fun shelfCategoryAD(
listener: ShelfSettingsListener,
) =
adapterDelegateViewBinding<ShelfSettingsItemModel.FavouriteCategory, ShelfSettingsItemModel, ItemCategoryCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onItemCheckedChanged(item, !item.isChecked)
}
binding.root.updatePaddingRelative(
start = binding.root.paddingStart * 2,
end = binding.root.paddingStart,
)
bind {
binding.root.text = item.title
binding.root.isChecked = item.isChecked
}
}
private val ShelfSection.titleResId: Int
get() = when (this) {
ShelfSection.HISTORY -> R.string.history
ShelfSection.LOCAL -> R.string.local_storage
ShelfSection.UPDATED -> R.string.updated
ShelfSection.FAVORITES -> R.string.favourites
}

@ -0,0 +1,60 @@
package org.koitharu.kotatsu.shelf.ui.config
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.shelf.domain.ShelfSection
sealed interface ShelfSettingsItemModel : ListModel {
val isChecked: Boolean
class Section(
val section: ShelfSection,
override val isChecked: Boolean,
) : ShelfSettingsItemModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Section
if (section != other.section) return false
if (isChecked != other.isChecked) return false
return true
}
override fun hashCode(): Int {
var result = section.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}
class FavouriteCategory(
val id: Long,
val title: String,
override val isChecked: Boolean,
) : ShelfSettingsItemModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FavouriteCategory
if (id != other.id) return false
if (title != other.title) return false
if (isChecked != other.isChecked) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}
}

@ -0,0 +1,10 @@
package org.koitharu.kotatsu.shelf.ui.config
import androidx.recyclerview.widget.RecyclerView
interface ShelfSettingsListener {
fun onItemCheckedChanged(item: ShelfSettingsItemModel, isChecked: Boolean)
fun onDragHandleTouch(holder: RecyclerView.ViewHolder)
}

@ -0,0 +1,101 @@
package org.koitharu.kotatsu.shelf.ui.config
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.move
import javax.inject.Inject
@HiltViewModel
class ShelfSettingsViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val content = combine(
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
favouritesRepository.observeCategories(),
) { sections, categories ->
buildList(sections, categories)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
private var updateJob: Job? = null
fun setItemChecked(item: ShelfSettingsItemModel, isChecked: Boolean) {
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob?.join()
when (item) {
is ShelfSettingsItemModel.FavouriteCategory -> {
favouritesRepository.updateCategory(item.id, isChecked)
}
is ShelfSettingsItemModel.Section -> {
val sections = settings.shelfSections
settings.shelfSections = if (isChecked) {
sections + item.section
} else {
if (sections.size > 1) {
sections - item.section
} else {
return@launchJob
}
}
}
}
}
}
fun reorderSections(oldPos: Int, newPos: Int): Boolean {
val snapshot = content.value?.toMutableList() ?: return false
snapshot.move(oldPos, newPos)
settings.shelfSections = snapshot.sections()
return true
}
private fun buildList(
sections: List<ShelfSection>,
categories: List<FavouriteCategory>
): List<ShelfSettingsItemModel> {
val result = ArrayList<ShelfSettingsItemModel>()
val sectionsList = ShelfSection.values().toMutableList()
for (section in sections) {
sectionsList.remove(section)
result.addSection(section, true, categories)
}
for (section in sectionsList) {
result.addSection(section, false, categories)
}
return result
}
private fun MutableList<in ShelfSettingsItemModel>.addSection(
section: ShelfSection,
isEnabled: Boolean,
favouriteCategories: List<FavouriteCategory>,
) {
add(ShelfSettingsItemModel.Section(section, isEnabled))
if (isEnabled && section == ShelfSection.FAVORITES) {
favouriteCategories.mapTo(this) {
ShelfSettingsItemModel.FavouriteCategory(
id = it.id,
title = it.title,
isChecked = it.isVisibleInLibrary,
)
}
}
}
private fun List<ShelfSettingsItemModel>.sections(): List<ShelfSection> {
return mapNotNull { (it as? ShelfSettingsItemModel.Section)?.takeIf { x -> x.isChecked }?.section }
}
}

@ -1,32 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
class ShelfCategoriesConfigAdapter(
listener: OnListItemClickListener<FavouriteCategory>,
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
init {
delegatesManager.addDelegate(shelfCategoryAD(listener))
}
class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
return oldItem.isVisibleInLibrary == newItem.isVisibleInLibrary && oldItem.title == newItem.title
}
override fun getChangePayload(oldItem: FavouriteCategory, newItem: FavouriteCategory): Any? {
return if (oldItem.isVisibleInLibrary == newItem.isVisibleInLibrary) {
super.getChangePayload(oldItem, newItem)
} else Unit
}
}
}

@ -1,54 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.SheetBaseBinding
@AndroidEntryPoint
class ShelfCategoriesConfigSheet :
BaseBottomSheet<SheetBaseBinding>(),
OnListItemClickListener<FavouriteCategory>,
View.OnClickListener {
private val viewModel by viewModels<ShelfCategoriesConfigViewModel>()
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding {
return SheetBaseBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.headerBar.toolbar.setTitle(R.string.favourites_categories)
binding.buttonDone.isVisible = true
binding.buttonDone.setOnClickListener(this)
val adapter = ShelfCategoriesConfigAdapter(this)
binding.recyclerView.adapter = adapter
viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
}
override fun onItemClick(item: FavouriteCategory, view: View) {
viewModel.toggleItem(item)
}
override fun onClick(v: View?) {
dismiss()
}
companion object {
private const val TAG = "ShelfCategoriesConfigSheet"
fun show(fm: FragmentManager) = ShelfCategoriesConfigSheet().show(fm, TAG)
}
}

@ -1,30 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@HiltViewModel
class ShelfCategoriesConfigViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val content = favouritesRepository.observeCategories()
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
private var updateJob: Job? = null
fun toggleItem(category: FavouriteCategory) {
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob?.join()
favouritesRepository.updateCategory(category.id, !category.isVisibleInLibrary)
}
}
}

@ -1,21 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding
fun shelfCategoryAD(
listener: OnListItemClickListener<FavouriteCategory>,
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
itemView.setOnClickListener(eventListener)
bind {
binding.root.text = item.title
binding.root.isChecked = item.isVisibleInLibrary
}
}

@ -8,13 +8,13 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetShelfSizeBinding import org.koitharu.kotatsu.databinding.SheetShelfSizeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShelfSizeBottomSheet : class ShelfSizeBottomSheet :
@ -51,9 +51,10 @@ class ShelfSizeBottomSheet :
} }
override fun onClick(v: View) { override fun onClick(v: View) {
val slider = binding.sliderGrid
when (v.id) { when (v.id) {
R.id.button_small -> binding.sliderGrid.value -= binding.sliderGrid.stepSize R.id.button_small -> slider.setValueRounded(slider.value - slider.stepSize)
R.id.button_large -> binding.sliderGrid.value += binding.sliderGrid.stepSize R.id.button_large -> slider.setValueRounded(slider.value + slider.stepSize)
} }
} }

@ -43,6 +43,9 @@ abstract class TracksDao {
@Query("DELETE FROM tracks") @Query("DELETE FROM tracks")
abstract suspend fun clear() abstract suspend fun clear()
@Query("UPDATE tracks SET chapters_new = 0")
abstract suspend fun clearCounters()
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: TrackEntity): Long abstract suspend fun insert(entity: TrackEntity): Long

@ -98,6 +98,8 @@ class TrackingRepository @Inject constructor(
suspend fun clearLogs() = db.trackLogsDao.clear() suspend fun clearLogs() = db.trackLogsDao.clear()
suspend fun clearCounters() = db.tracksDao.clearCounters()
suspend fun gc() { suspend fun gc() {
db.withTransaction { db.withTransaction {
db.tracksDao.gc() db.tracksDao.gc()

@ -6,9 +6,9 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
@ -39,13 +39,15 @@ class FeedMenuProvider(
} }
R.id.action_clear_feed -> { R.id.action_clear_feed -> {
MaterialAlertDialogBuilder(context) CheckBoxAlertDialog.Builder(context)
.setTitle(R.string.clear_updates_feed) .setTitle(R.string.clear_updates_feed)
.setMessage(R.string.text_clear_updates_feed_prompt) .setMessage(R.string.text_clear_updates_feed_prompt)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ -> .setCheckBoxChecked(true)
viewModel.clearFeed() .setCheckBoxText(R.string.clear_new_chapters_counters)
}.show() .setPositiveButton(R.string.clear) { _, isChecked ->
viewModel.clearFeed(isChecked)
}.create().show()
true true
} }

@ -50,9 +50,12 @@ class FeedViewModel @Inject constructor(
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun clearFeed() { fun clearFeed(clearCounters: Boolean) {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
repository.clearLogs() repository.clearLogs()
if (clearCounters) {
repository.clearCounters()
}
onFeedCleared.postCall(Unit) onFeedCleared.postCall(Unit)
} }
} }

@ -0,0 +1,46 @@
package org.koitharu.kotatsu.utils
import android.app.Activity
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.utils.ext.getThemeColor
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class IncognitoModeIndicator @Inject constructor(
private val settings: AppSettings,
) : DefaultActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity !is AppCompatActivity) {
return
}
settings.observeAsFlow(
key = AppSettings.KEY_INCOGNITO_MODE,
valueProducer = { isIncognitoModeEnabled },
).flowOn(Dispatchers.IO)
.flowWithLifecycle(activity.lifecycle)
.onEach { updateStatusBar(activity, it) }
.launchIn(activity.lifecycleScope)
}
private fun updateStatusBar(activity: AppCompatActivity, isIncognitoModeEnabled: Boolean) {
activity.window.statusBarColor = if (isIncognitoModeEnabled) {
ContextCompat.getColor(activity, R.color.status_bar_incognito)
} else {
activity.getThemeColor(android.R.attr.statusBarColor)
}
}
}

@ -0,0 +1,34 @@
package org.koitharu.kotatsu.utils.ext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import org.koitharu.kotatsu.utils.progress.ProgressResponseBody
import java.io.InputStream
import java.io.OutputStream
suspend fun InputStream.copyToSuspending(
out: OutputStream,
bufferSize: Int = DEFAULT_BUFFER_SIZE
): Long = withContext(Dispatchers.IO) {
val job = currentCoroutineContext()[Job]
var bytesCopied: Long = 0
val buffer = ByteArray(bufferSize)
var bytes = read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
job?.ensureActive()
bytes = read(buffer)
job?.ensureActive()
}
bytesCopied
}
fun ResponseBody.withProgress(progressState: MutableStateFlow<Float>): ResponseBody {
return ProgressResponseBody(this, progressState)
}

@ -8,6 +8,7 @@ import okio.FileNotFoundException
import okio.IOException import okio.IOException
import org.acra.ktx.sendWithAcra import org.acra.ktx.sendWithAcra
import org.json.JSONException import org.json.JSONException
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CaughtException import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
@ -45,6 +46,12 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is WrongPasswordException -> resources.getString(R.string.wrong_password) is WrongPasswordException -> resources.getString(R.string.wrong_password)
is NotFoundException -> resources.getString(R.string.not_found_404) is NotFoundException -> resources.getString(R.string.not_found_404)
is HttpStatusException -> when (statusCode) {
in 500..599 -> resources.getString(R.string.server_error, statusCode)
else -> localizedMessage
}
is IOException -> getDisplayMessage(message, resources) ?: localizedMessage is IOException -> getDisplayMessage(message, resources) ?: localizedMessage
else -> localizedMessage else -> localizedMessage
} ?: resources.getString(R.string.error_occurred) } ?: resources.getString(R.string.error_occurred)

@ -127,7 +127,11 @@ fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? {
fun Slider.setValueRounded(newValue: Float) { fun Slider.setValueRounded(newValue: Float) {
val step = stepSize val step = stepSize
val roundedValue = (newValue / step).roundToInt() * step val roundedValue = if (step <= 0f) {
newValue
} else {
(newValue / step).roundToInt() * step
}
value = roundedValue.coerceIn(valueFrom, valueTo) value = roundedValue.coerceIn(valueFrom, valueTo)
} }

@ -0,0 +1,51 @@
package org.koitharu.kotatsu.utils.progress
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.Source
import okio.buffer
class ProgressResponseBody(
private val delegate: ResponseBody,
private val progressState: MutableStateFlow<Float>,
) : ResponseBody() {
private var bufferedSource: BufferedSource? = null
override fun close() {
super.close()
delegate.close()
}
override fun contentLength(): Long = delegate.contentLength()
override fun contentType(): MediaType? = delegate.contentType()
override fun source(): BufferedSource {
return bufferedSource ?: ProgressSource(delegate.source(), contentLength(), progressState).buffer().also {
bufferedSource = it
}
}
private class ProgressSource(
delegate: Source,
private val contentLength: Long,
private val progressState: MutableStateFlow<Float>,
) : ForwardingSource(delegate) {
private var totalBytesRead = 0L
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
if (contentLength > 0) {
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressState.value = (totalBytesRead.toDouble() / contentLength.toDouble()).toFloat()
}
return bytesRead
}
}
}

@ -1,81 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:orientation="vertical">
<org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup
android:id="@+id/checkableGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:orientation="horizontal"
android:weightSum="3">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_list"
style="@style/Widget.Kotatsu.ToggleButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:singleLine="true"
android:ellipsize="end"
android:text="@string/list"
app:icon="@drawable/ic_list" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_list_detailed"
style="@style/Widget.Kotatsu.ToggleButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_weight="1"
android:singleLine="true"
android:ellipsize="end"
android:text="@string/detailed_list"
app:icon="@drawable/ic_list_detailed" />
<com.google.android.material.button.MaterialButton
android:id="@+id/button_grid"
style="@style/Widget.Kotatsu.ToggleButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:singleLine="true"
android:ellipsize="end"
android:text="@string/grid"
app:icon="@drawable/ic_grid" />
</org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup>
<TextView
android:id="@+id/textView_grid_title"
style="?materialAlertDialogTitleTextStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="?attr/dialogPreferredPadding"
android:paddingRight="?attr/dialogPreferredPadding"
android:singleLine="true"
android:text="@string/grid_size"
android:visibility="gone"
tools:visibility="visible" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_grid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:stepSize="5"
android:valueFrom="50"
android:valueTo="150"
android:visibility="gone"
app:labelBehavior="floating"
app:tickVisible="false"
tools:value="100"
tools:visibility="visible" />
</LinearLayout>

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
tools:navigationIcon="@drawable/abc_ic_clear_material"
tools:title="@string/settings">
<Button
android:id="@+id/button_done"
style="@style/Widget.Material3.Button.UnelevatedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginHorizontal="@dimen/toolbar_button_margin"
android:text="@string/done" />
</com.google.android.material.appbar.MaterialToolbar>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:overScrollMode="ifContentScrolls"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>

@ -1,59 +1,86 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:orientation="vertical">
<org.koitharu.kotatsu.base.ui.widgets.BottomSheetHeaderBar
android:id="@+id/headerBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/options" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:animateLayoutChanges="true" android:orientation="vertical"
android:orientation="vertical"> android:paddingBottom="@dimen/margin_normal">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:text="@string/list_mode"
android:textAppearance="?textAppearanceTitleSmall" />
<org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup <com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/checkableGroup" android:id="@+id/checkableGroup"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginVertical="16dp" android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginHorizontal="?attr/dialogPreferredPadding" android:layout_marginTop="@dimen/margin_small"
android:orientation="vertical"> android:baselineAligned="false"
android:orientation="horizontal"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/button_list" android:id="@+id/button_list"
style="@style/Widget.Kotatsu.ToggleButton" style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/list" android:layout_weight="1"
android:text="@string/compact"
app:icon="@drawable/ic_list" /> app:icon="@drawable/ic_list" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/button_list_detailed" android:id="@+id/button_list_detailed"
style="@style/Widget.Kotatsu.ToggleButton" style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/detailed_list" android:layout_weight="1"
android:text="@string/details"
app:icon="@drawable/ic_list_detailed" /> app:icon="@drawable/ic_list_detailed" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/button_grid" android:id="@+id/button_grid"
style="@style/Widget.Kotatsu.ToggleButton" style="@style/Widget.Kotatsu.ToggleButton.Vertical"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/grid" android:text="@string/grid"
app:icon="@drawable/ic_grid" /> app:icon="@drawable/ic_grid" />
</org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup> </com.google.android.material.button.MaterialButtonToggleGroup>
<TextView <TextView
android:id="@+id/textView_grid_title" android:id="@+id/textView_grid_title"
style="?materialAlertDialogTitleTextStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="?attr/dialogPreferredPadding" android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:singleLine="true" android:singleLine="true"
android:text="@string/grid_size" android:text="@string/grid_size"
android:textAppearance="?textAppearanceTitleSmall"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />
@ -72,4 +99,5 @@
tools:visibility="visible" /> tools:visibility="visible" />
</LinearLayout> </LinearLayout>
</ScrollView> </androidx.core.widget.NestedScrollView>
</LinearLayout>

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:windowBackground"
android:gravity="center_vertical"
android:minHeight="58dp"
android:orientation="horizontal">
<ImageView
android:id="@+id/imageView_handle"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:paddingHorizontal="?listPreferredItemPaddingStart"
android:scaleType="center"
android:src="@drawable/ic_reorder_handle" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:text="@tools:sample/lorem[15]" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="?listPreferredItemPaddingEnd" />
</LinearLayout>

@ -12,7 +12,7 @@
<item <item
android:id="@+id/action_categories" android:id="@+id/action_categories"
android:orderInCategory="50" android:orderInCategory="50"
android:title="@string/categories_" android:title="@string/settings"
app:showAsAction="never" /> app:showAsAction="never" />
<item <item

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="new_chapters">
<item quantity="zero">%1$d فصل جديد</item>
<item quantity="one"></item>
<item quantity="two"></item>
<item quantity="few"></item>
<item quantity="many">%1$d فصول جديدة</item>
<item quantity="other"></item>
</plurals>
<plurals name="chapters">
<item quantity="zero"></item>
<item quantity="one">%1$d فصل</item>
<item quantity="two">%1$d فصلين</item>
<item quantity="few">%1$d بعض فصول</item>
<item quantity="many">%1$d عدة فصول</item>
<item quantity="other"></item>
</plurals>
</resources>

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="detailed_list">تفاصيل القائمة</string>
<string name="error_occurred">حدث خطأ</string>
<string name="details">تفاصيل</string>
<string name="grid">شبكة</string>
<string name="list_mode">وضع القائمة</string>
<string name="settings">إعدادات</string>
<string name="remote_sources">المصادر البعيدة</string>
<string name="close_menu">غلق قائمة</string>
<string name="open_menu">فتح قائمة</string>
<string name="chapters">فصول</string>
<string name="favourites">المفضلة</string>
<string name="network_error">تعذر الاتصال بالإنترنت</string>
<string name="loading_">جار التحميل…</string>
<string name="chapter_d_of_d">فصل %1$d في %2$d</string>
<string name="close">غلق</string>
<string name="try_again">حاول مجدداً</string>
<string name="computing_">جاري الحوسبة …</string>
<string name="local_storage">التخزين المحلي</string>
<string name="history">سجل</string>
<string name="list">قائمة</string>
<string name="clear_history">محو سجل</string>
<string name="add_to_favourites">ضع هذا في المفضلة</string>
<string name="add">أضف</string>
<string name="enter_category_name">أدخل اسم القائمة</string>
<string name="save">حفظ</string>
<string name="history_is_empty">لا سجل بعد</string>
<string name="downloads">التحميلات</string>
<string name="by_name">اسم</string>
<string name="newest">الأحدث</string>
<string name="by_rating">تقييم</string>
<string name="pages">صفحات</string>
<string name="read">اقرأ</string>
<string name="share">شارك</string>
<string name="nothing_found">لم يتم عثور على اي شيء</string>
<string name="you_have_not_favourites_yet">لا مفضلة بعد</string>
<string name="search">بحث</string>
<string name="search_manga">البحث في المانجا</string>
<string name="manga_downloading_">جاري التنزيل…</string>
<string name="create_shortcut">انشاء اختصار…</string>
<string name="theme">مظهر</string>
<string name="automatic">حسب النظام</string>
<string name="share_s">شارك %s</string>
<string name="processing_">في طور معالجة…</string>
<string name="updated">محدث</string>
<string name="filter">فلتر</string>
<string name="sort_order">ترتيب الفرز</string>
<string name="light">ضوء</string>
<string name="dark">داكن</string>
<string name="clear">أزل</string>
<string name="remove">ازالة</string>
<string name="popular">شائع</string>
<string name="add_new_category">قائمة جديدة</string>
<string name="download_complete">تم التنزيل</string>
<string name="text_clear_history_prompt">هل تريد محو سجل القراءة بالكامل بشكل دائم؟</string>
</resources>

@ -266,7 +266,7 @@
<string name="suggestions_excluded_genres">Genres ausschließen</string> <string name="suggestions_excluded_genres">Genres ausschließen</string>
<string name="suggestions_excluded_genres_summary">Geben Sie Genres an, die Sie nicht in den Vorschlägen sehen möchten</string> <string name="suggestions_excluded_genres_summary">Geben Sie Genres an, die Sie nicht in den Vorschlägen sehen möchten</string>
<string name="text_delete_local_manga_batch">Ausgewählte Elemente dauerhaft vom Gerät löschen\?</string> <string name="text_delete_local_manga_batch">Ausgewählte Elemente dauerhaft vom Gerät löschen\?</string>
<string name="batch_manga_save_confirm">Sind Sie sicher, dass Sie alle ausgewählten Mangas mit allen Kapiteln herunterladen möchten\? Diese Aktion kann eine Menge Datenverkehr und Speicherplatz verbrauchen</string> <string name="batch_manga_save_confirm">Alle ausgewählten Mangas und ihre Kapitel herunterladen\? Das kann eine Menge Datenverkehr und Speicherplatz verbrauchen.</string>
<string name="removal_completed">Entfernung abgeschlossen</string> <string name="removal_completed">Entfernung abgeschlossen</string>
<string name="download_slowdown">Download-Verzögerung</string> <string name="download_slowdown">Download-Verzögerung</string>
<string name="parallel_downloads">Parallele Downloads</string> <string name="parallel_downloads">Parallele Downloads</string>

@ -386,4 +386,8 @@
<string name="reset">Restablecer</string> <string name="reset">Restablecer</string>
<string name="text_unsaved_changes_prompt">Tienes cambios sin guardar. ¿Quieres guardarlos o descartarlos\?</string> <string name="text_unsaved_changes_prompt">Tienes cambios sin guardar. ¿Quieres guardarlos o descartarlos\?</string>
<string name="discard">Descartar</string> <string name="discard">Descartar</string>
<string name="error_no_space_left">Sin espacio en dispositivo</string>
<string name="webtoon_zoom">Zoom de webtoon</string>
<string name="webtoon_zoom_summary">Permitir el gesto de acercamiento/alejamiento en modo webtoon (beta)</string>
<string name="reader_slider">Mostrar el deslizador de cambio de página</string>
</resources> </resources>

@ -267,7 +267,7 @@
<string name="suggestions_excluded_genres_summary">Spécifiez les genres que vous ne voulez pas voir apparaître dans les suggestions</string> <string name="suggestions_excluded_genres_summary">Spécifiez les genres que vous ne voulez pas voir apparaître dans les suggestions</string>
<string name="text_delete_local_manga_batch">Supprimer définitivement les éléments sélectionnés de l\'appareil \?</string> <string name="text_delete_local_manga_batch">Supprimer définitivement les éléments sélectionnés de l\'appareil \?</string>
<string name="removal_completed">Suppression terminée</string> <string name="removal_completed">Suppression terminée</string>
<string name="batch_manga_save_confirm">Voulez-vous vraiment télécharger tous les mangas sélectionnés avec tous leurs chapitres \? Cette action peut consommer beaucoup de trafic et de stockage</string> <string name="batch_manga_save_confirm">Télécharger tous les mangas sélectionnés et leurs chapitres \? Ceci peut consommer beaucoup de trafic et de stockage.</string>
<string name="parallel_downloads">Téléchargements parallèles</string> <string name="parallel_downloads">Téléchargements parallèles</string>
<string name="download_slowdown">Ralentissement du téléchargement</string> <string name="download_slowdown">Ralentissement du téléchargement</string>
<string name="download_slowdown_summary">Permet d\'éviter le blocage de votre adresse IP</string> <string name="download_slowdown_summary">Permet d\'éviter le blocage de votre adresse IP</string>
@ -374,7 +374,7 @@
<string name="import_completed">Importation terminée</string> <string name="import_completed">Importation terminée</string>
<string name="import_completed_hint">Vous pouvez supprimer le fichier original du stockage pour gagner de l\'espace</string> <string name="import_completed_hint">Vous pouvez supprimer le fichier original du stockage pour gagner de l\'espace</string>
<string name="import_will_start_soon">L\'importation va bientôt commencer</string> <string name="import_will_start_soon">L\'importation va bientôt commencer</string>
<string name="text_unsaved_changes_prompt">Vous avez des modifications non sauvegardées, voulez-vous les sauvegarder ou les abandonner \?</string> <string name="text_unsaved_changes_prompt">Sauvegarde ou abandon des modifications non sauvegardées \?</string>
<string name="discard">Abandonner</string> <string name="discard">Abandonner</string>
<string name="history_shortcuts_summary">Rendre les mangas récents disponibles en appuyant longuement sur l\'icône de l\'application</string> <string name="history_shortcuts_summary">Rendre les mangas récents disponibles en appuyant longuement sur l\'icône de l\'application</string>
<string name="reader_control_ltr_summary">Taper sur le bord droit ou appuyer sur la touche droite permet toujours de passer à la page suivante</string> <string name="reader_control_ltr_summary">Taper sur le bord droit ou appuyer sur la touche droite permet toujours de passer à la page suivante</string>
@ -387,4 +387,12 @@
<string name="color_correction_hint">Les paramètres de couleurs choisis seront sauvegardés pour ce manga</string> <string name="color_correction_hint">Les paramètres de couleurs choisis seront sauvegardés pour ce manga</string>
<string name="error_no_space_left">Il n\'y a plus d\'espace sur l\'appareil</string> <string name="error_no_space_left">Il n\'y a plus d\'espace sur l\'appareil</string>
<string name="reader_slider">Afficher le curseur de changement de page</string> <string name="reader_slider">Afficher le curseur de changement de page</string>
<string name="webtoon_zoom_summary">Autoriser les gestes de zoom avant/arrière en mode webtoon (bêta)</string>
<string name="webtoon_zoom">Zoom Webtoon</string>
<string name="network_unavailable_hint">Activez le Wi-Fi ou le réseau mobile pour lire les mangas en ligne</string>
<string name="different_languages">Différentes langues</string>
<string name="network_unavailable">Le réseau n\'est pas disponible</string>
<string name="compact">Compact</string>
<string name="server_error">Erreur côté serveur (%1$d). Veuillez réessayer plus tard</string>
<string name="clear_new_chapters_counters">Effacer aussi les informations sur les nouveaux chapitres</string>
</resources> </resources>

@ -267,7 +267,7 @@
<string name="suggestions_excluded_genres_summary">サジェストで表示したくないジャンルを指定</string> <string name="suggestions_excluded_genres_summary">サジェストで表示したくないジャンルを指定</string>
<string name="text_delete_local_manga_batch">選択した項目をデバイスから完全に削除しますか?</string> <string name="text_delete_local_manga_batch">選択した項目をデバイスから完全に削除しますか?</string>
<string name="removal_completed">削除が完了しました</string> <string name="removal_completed">削除が完了しました</string>
<string name="batch_manga_save_confirm">本当に選択したマンガを全編ダウンロードしますか?この動作は多くのトラフィックとストレージを消費する可能性があります</string> <string name="batch_manga_save_confirm">選択したマンガとそのチャプターをすべてダウンロードしますか?これは、多くのトラフィックとストレージを消費する可能性があります</string>
<string name="download_slowdown_summary">IPアドレスのブロックを回避することができます</string> <string name="download_slowdown_summary">IPアドレスのブロックを回避することができます</string>
<string name="local_manga_processing">保存されたマンガの処理</string> <string name="local_manga_processing">保存されたマンガの処理</string>
<string name="download_slowdown">ダウンロードの速度低下</string> <string name="download_slowdown">ダウンロードの速度低下</string>
@ -383,9 +383,16 @@
<string name="brightness">輝度</string> <string name="brightness">輝度</string>
<string name="contrast">コントラスト</string> <string name="contrast">コントラスト</string>
<string name="reset">リセット</string> <string name="reset">リセット</string>
<string name="text_unsaved_changes_prompt">未保存の変更がありますが、保存しますか、それとも破棄しますか?</string> <string name="text_unsaved_changes_prompt">未保存の変更を保存または破棄しますか\?</string>
<string name="color_correction_hint">選択した色の設定は、この漫画のために記憶されます</string> <string name="color_correction_hint">選択した色の設定は、この漫画のために記憶されます</string>
<string name="discard">破棄</string> <string name="discard">破棄</string>
<string name="error_no_space_left">デバイスに空き容量がありません</string> <string name="error_no_space_left">デバイスに空き容量がありません</string>
<string name="reader_slider">ページ切り替えスライダーを表示</string> <string name="reader_slider">ページ切り替えスライダーを表示</string>
<string name="server_error">サーバーサイドエラー (%1$d) です。後で再試行してください</string>
<string name="clear_new_chapters_counters">新しいチャプターの情報も明確に</string>
<string name="different_languages">さまざまな言語</string>
<string name="network_unavailable">ネットワークが利用できません</string>
<string name="network_unavailable_hint">Wi-Fiまたはモバイルネットワークをオンにして、オンラインでマンガを読むことができます</string>
<string name="webtoon_zoom">Webtoonズーム</string>
<string name="webtoon_zoom_summary">ウェブトゥーンモードでズームイン/ズームアウトのジェスチャーを可能にする(ベータ版)</string>
</resources> </resources>

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Kotatsu.DialogWhenLarge">
<item name="windowFixedWidthMajor">@dimen/abc_dialog_fixed_width_major</item>
<item name="windowFixedWidthMinor">@dimen/abc_dialog_fixed_width_minor</item>
<item name="windowFixedHeightMajor">@dimen/abc_dialog_fixed_height_major</item>
<item name="windowFixedHeightMinor">@dimen/abc_dialog_fixed_height_minor</item>
<item name="android:windowElevation">@dimen/abc_floating_window_z</item>
<item name="android:colorBackground">?attr/colorBackgroundFloating</item>
<item name="android:colorBackgroundCacheHint">@null</item>
<item name="android:windowFrame">@null</item>
<item name="android:windowTitleStyle">@style/RtlOverlay.DialogWindowTitle.AppCompat</item>
<item name="android:windowTitleBackgroundStyle">@style/Base.DialogWindowTitleBackground.AppCompat</item>
<item name="android:windowBackground">@drawable/abc_dialog_material_background</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowAnimationStyle">@style/Animation.AppCompat.Dialog</item>
<item name="android:windowSoftInputMode">stateUnspecified|adjustResize</item>
<item name="windowActionBar">false</item>
<item name="windowActionModeOverlay">true</item>
<item name="listPreferredItemPaddingLeft">24dip</item>
<item name="listPreferredItemPaddingRight">24dip</item>
<item name="android:listDivider">@null</item>
<item name="android:buttonBarStyle">@style/Widget.AppCompat.ButtonBar.AlertDialog</item>
<item name="android:borderlessButtonStyle">@style/Widget.AppCompat.Button.Borderless</item>
<item name="android:windowCloseOnTouchOutside">true</item>
</style>
</resources>

@ -331,4 +331,12 @@
<string name="brightness">Lysstyrke</string> <string name="brightness">Lysstyrke</string>
<string name="color_correction">Fargekorrigering</string> <string name="color_correction">Fargekorrigering</string>
<string name="text_unsaved_changes_prompt">Lagre eller forkast ulagrede endringer\?</string> <string name="text_unsaved_changes_prompt">Lagre eller forkast ulagrede endringer\?</string>
<string name="compact">Kompakt</string>
<string name="disable_all">Skru av alle</string>
<string name="use_fingerprint">Bruk fingeravtrykk hvis tilgjengelig</string>
<string name="network_unavailable_hint">Skru på Wi-Fi eller mobilnettverk for å lese manga på nett</string>
<string name="notifications_enable">Skru på merknader</string>
<string name="show_reading_indicators">Vis indikatorer for leseforløp</string>
<string name="data_deletion">Datasletting</string>
<string name="exit_confirmation">Avsluttingsbekreftelse</string>
</resources> </resources>

@ -8,5 +8,6 @@
<color name="scrollbar">#66FFFFFF</color> <color name="scrollbar">#66FFFFFF</color>
<color name="selector_foreground">#29FFFFFF</color> <color name="selector_foreground">#29FFFFFF</color>
<color name="divider_default">#1FFFFFFF</color> <color name="divider_default">#1FFFFFFF</color>
<color name="status_bar_incognito">#260052</color>
</resources> </resources>

@ -266,7 +266,7 @@
<string name="suggestions_excluded_genres_summary">Укажите жанры, которые Вы не хотите видеть в рекомендациях</string> <string name="suggestions_excluded_genres_summary">Укажите жанры, которые Вы не хотите видеть в рекомендациях</string>
<string name="text_delete_local_manga_batch">Удалить выбранную мангу с накопителя?</string> <string name="text_delete_local_manga_batch">Удалить выбранную мангу с накопителя?</string>
<string name="removal_completed">Удаление завершено</string> <string name="removal_completed">Удаление завершено</string>
<string name="batch_manga_save_confirm">Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе</string> <string name="batch_manga_save_confirm">Загрузить выбранную мангу со всеми главами\? Это может привести к большому расходу трафика и места на накопителе.</string>
<string name="parallel_downloads">Загружать параллельно</string> <string name="parallel_downloads">Загружать параллельно</string>
<string name="download_slowdown">Замедление загрузки</string> <string name="download_slowdown">Замедление загрузки</string>
<string name="download_slowdown_summary">Помогает избежать блокировки IP-адреса</string> <string name="download_slowdown_summary">Помогает избежать блокировки IP-адреса</string>
@ -376,14 +376,24 @@
<string name="downloading_manga">Загрузка манги</string> <string name="downloading_manga">Загрузка манги</string>
<string name="manga_error_description_pattern">Информация об ошибке:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Попробуйте &lt;a href=%2$s&gt;открыть мангу в веб-браузере&lt;/a&gt;, чтобы убедиться, что она доступна в источнике&lt;br&gt;2. Если она доступна, отправьте отчёт об ошибке разработчикам.</string> <string name="manga_error_description_pattern">Информация об ошибке:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Попробуйте &lt;a href=%2$s&gt;открыть мангу в веб-браузере&lt;/a&gt;, чтобы убедиться, что она доступна в источнике&lt;br&gt;2. Если она доступна, отправьте отчёт об ошибке разработчикам.</string>
<string name="history_shortcuts">Показывать ярлыки последней прочитанной манги</string> <string name="history_shortcuts">Показывать ярлыки последней прочитанной манги</string>
<string name="history_shortcuts_summary">Сделать недавно прочитанную доступной по долгому нажатию на иконку приложения</string> <string name="history_shortcuts_summary">Сделать недавно прочитанную мангу доступной по долгому нажатию на иконку приложения</string>
<string name="reader_control_ltr_summary">Нажатие на правый край или нажатие правой клавиши всегда переключает на следующую страницу</string> <string name="reader_control_ltr_summary">Нажатие на правый край или нажатие правой клавиши всегда переключает на следующую страницу</string>
<string name="reader_control_ltr">Эргономичное управление режимом чтения</string> <string name="reader_control_ltr">Эргономичное управление режимом чтения</string>
<string name="reset">Сбросить</string> <string name="reset">Сбросить</string>
<string name="discard">Отменить</string> <string name="discard">Отменить</string>
<string name="text_unsaved_changes_prompt">У вас есть несохраненные изменения, вы хотите сохранить или отменить их\?</string> <string name="text_unsaved_changes_prompt">Сохранить или отменить несохранённые изменения\?</string>
<string name="contrast">Контрастность</string> <string name="contrast">Контрастность</string>
<string name="brightness">Яркость</string> <string name="brightness">Яркость</string>
<string name="color_correction">Цветокоррекция</string> <string name="color_correction">Цветокоррекция</string>
<string name="color_correction_hint">Выбранные настройки цвета запомнятся для этой манги</string> <string name="color_correction_hint">Выбранные настройки цвета будут сохранены для этой манги</string>
<string name="error_no_space_left">Не осталось свободного места на накопителе</string>
<string name="webtoon_zoom">Масштабирование в режиме манхвы</string>
<string name="webtoon_zoom_summary">Позволяет масштабировать страницы в режиме манхвы (бета)</string>
<string name="reader_slider">Показывать слайдер переключения страниц</string>
<string name="network_unavailable_hint">Включите Wi-Fi или передачу данных, чтобы читать мангу онлайн</string>
<string name="different_languages">Разные языки</string>
<string name="network_unavailable">Сеть недоступна</string>
<string name="clear_new_chapters_counters">Также очистить информацию о новых главах</string>
<string name="server_error">Внутренняя ошибка сервера (%1$d). Повторите попытку позже</string>
<string name="compact">Компактно</string>
</resources> </resources>

@ -266,7 +266,7 @@
<string name="suggestions_excluded_genres">Türleri hariç tut</string> <string name="suggestions_excluded_genres">Türleri hariç tut</string>
<string name="suggestions_excluded_genres_summary">Önerilerde görmek istemediğiniz türleri belirtin</string> <string name="suggestions_excluded_genres_summary">Önerilerde görmek istemediğiniz türleri belirtin</string>
<string name="text_delete_local_manga_batch">Seçilen ögeler aygıttan kalıcı olarak silinsin mi\?</string> <string name="text_delete_local_manga_batch">Seçilen ögeler aygıttan kalıcı olarak silinsin mi\?</string>
<string name="batch_manga_save_confirm">Seçilen tüm mangaları tüm bölümleriyle birlikte indirmek istediğinizden emin misiniz\? Bu işlem çok fazla trafik ve depolama alanı tüketebilir</string> <string name="batch_manga_save_confirm">Seçilen tüm manga ve bölümleri indirilsin mi\? Bu, çok fazla trafik ve depolama tüketebilir.</string>
<string name="removal_completed">Kaldırma tamamlandı</string> <string name="removal_completed">Kaldırma tamamlandı</string>
<string name="chapters_will_removed_background">Bölümler arka planda kaldırılacaktır. Bu biraz zaman alabilir</string> <string name="chapters_will_removed_background">Bölümler arka planda kaldırılacaktır. Bu biraz zaman alabilir</string>
<string name="parallel_downloads">Paralel indirmeler</string> <string name="parallel_downloads">Paralel indirmeler</string>
@ -372,7 +372,7 @@
<string name="import_completed">İçe aktarım tamamlandı</string> <string name="import_completed">İçe aktarım tamamlandı</string>
<string name="import_completed_hint">Yer açmak için orijinal dosyayı depolamadan silebilirsiniz</string> <string name="import_completed_hint">Yer açmak için orijinal dosyayı depolamadan silebilirsiniz</string>
<string name="import_will_start_soon">İçe aktarım birazdan başlayacak</string> <string name="import_will_start_soon">İçe aktarım birazdan başlayacak</string>
<string name="feed">Feed</string> <string name="feed">Akış</string>
<string name="history_shortcuts">En son manga kısayollarını göster</string> <string name="history_shortcuts">En son manga kısayollarını göster</string>
<string name="reader_control_ltr">Ergonomik okuyucu kontrol</string> <string name="reader_control_ltr">Ergonomik okuyucu kontrol</string>
<string name="color_correction">Renk düzeltme</string> <string name="color_correction">Renk düzeltme</string>
@ -380,6 +380,19 @@
<string name="contrast">Kontrast</string> <string name="contrast">Kontrast</string>
<string name="reset">Sıfırla</string> <string name="reset">Sıfırla</string>
<string name="color_correction_hint">Seçilen renk ayarları bu manga için hatırlanacaktır</string> <string name="color_correction_hint">Seçilen renk ayarları bu manga için hatırlanacaktır</string>
<string name="text_unsaved_changes_prompt">Kaydedilmemiş değişiklikleriniz var, kaydetmek mi istersiniz yoksa, yoksaymak mı istersiniz\?</string> <string name="text_unsaved_changes_prompt">Kaydedilmeyen değişiklikler kaydedilsin mi yoksa atılsın mı\?</string>
<string name="discard">Yoksay</string> <string name="discard">Yoksay</string>
<string name="error_no_space_left">Cihazda yer yok</string>
<string name="webtoon_zoom">Webtoon yakınlaştırma</string>
<string name="webtoon_zoom_summary">Webtoon modunda yakınlaştırma hareketine izin ver (beta)</string>
<string name="reader_slider">Sayfa değiştirme kaydırıcısını göster</string>
<string name="clear_new_chapters_counters">Ayrıca yeni bölümler hakkındaki bilgileri temizle</string>
<string name="compact">Sıkı</string>
<string name="different_languages">Farklı diller</string>
<string name="network_unavailable">Ağ kullanılamıyor</string>
<string name="network_unavailable_hint">Çevrim içi manga okumak için Wi-Fi veya mobil ağıın</string>
<string name="server_error">Sunucu tarafı hatası (%1$d). Lütfen daha sonra tekrar deneyin</string>
<string name="saved_manga">Kaydedilen mangalar</string>
<string name="history_shortcuts_summary">Uygulama simgesine uzun basarak son mangaları kullanılabilir hale getirin</string>
<string name="reader_control_ltr_summary">Sağ kenara dokunulduğunda veya sağ tuşa basıldığında her zaman bir sonraki sayfaya geçilir</string>
</resources> </resources>

@ -2,7 +2,7 @@
<resources> <resources>
<plurals name="new_chapters"> <plurals name="new_chapters">
<item quantity="one">%1$d новий розділ</item> <item quantity="one">%1$d новий розділ</item>
<item quantity="few">%1$d нові розділи</item> <item quantity="few">%1$d нових розділи</item>
<item quantity="many">%1$d нових розділів</item> <item quantity="many">%1$d нових розділів</item>
<item quantity="other">%1$d нових розділів</item> <item quantity="other">%1$d нових розділів</item>
</plurals> </plurals>

@ -211,7 +211,7 @@
<string name="suggestions_updating">Оновлення пропозицій</string> <string name="suggestions_updating">Оновлення пропозицій</string>
<string name="text_delete_local_manga_batch">Видалити вибрані елементи з пристрою назавжди\?</string> <string name="text_delete_local_manga_batch">Видалити вибрані елементи з пристрою назавжди\?</string>
<string name="removal_completed">Видалення завершено</string> <string name="removal_completed">Видалення завершено</string>
<string name="batch_manga_save_confirm">Ви впевнені, що хочете завантажити всю вибрану манґу з усіма її розділами\? Це може споживати багато трафіку та пам’яті</string> <string name="batch_manga_save_confirm">Завантажити всю вибрану манґу з усіма її розділами\? Це може споживати багато трафіку та пам’яті.</string>
<string name="parallel_downloads">Завантажувати паралельно</string> <string name="parallel_downloads">Завантажувати паралельно</string>
<string name="download_slowdown">Сповільнення завантаження</string> <string name="download_slowdown">Сповільнення завантаження</string>
<string name="local_manga_processing">Обробка збереженої манґи</string> <string name="local_manga_processing">Обробка збереженої манґи</string>
@ -292,7 +292,7 @@
<string name="removed_from_history">Видалено з історії</string> <string name="removed_from_history">Видалено з історії</string>
<string name="dns_over_https">DNS через HTTPS</string> <string name="dns_over_https">DNS через HTTPS</string>
<string name="default_mode">Режим за замовчуванням</string> <string name="default_mode">Режим за замовчуванням</string>
<string name="detect_reader_mode_summary">Автоматично визначати, чи є манга вебтуном</string> <string name="detect_reader_mode_summary">Автоматично визначати, чи є манґа вебтуном</string>
<string name="detect_reader_mode">Автовизначення режиму читання</string> <string name="detect_reader_mode">Автовизначення режиму читання</string>
<string name="disable_battery_optimization">Вимкнути оптимізацію акумулятора</string> <string name="disable_battery_optimization">Вимкнути оптимізацію акумулятора</string>
<string name="disable_battery_optimization_summary">Допомагає з перевірками фонових оновлень</string> <string name="disable_battery_optimization_summary">Допомагає з перевірками фонових оновлень</string>
@ -383,6 +383,16 @@
<string name="contrast">Контрастність</string> <string name="contrast">Контрастність</string>
<string name="reset">Скинути</string> <string name="reset">Скинути</string>
<string name="color_correction_hint">Вибрані параметри кольору будуть запам\'ятовані для цієї манґи</string> <string name="color_correction_hint">Вибрані параметри кольору будуть запам\'ятовані для цієї манґи</string>
<string name="text_unsaved_changes_prompt">У вас є незбережені зміни, ви хочете зберегти чи скасувати їх\?</string> <string name="text_unsaved_changes_prompt">Зберегти чи скасувати незбережені зміни\?</string>
<string name="discard">Скасувати</string> <string name="discard">Скасувати</string>
<string name="server_error">Помилка на стороні сервера (%1$d). Будь ласка спробуйте пізніше</string>
<string name="clear_new_chapters_counters">Також очистити інформацію про нові розділи</string>
<string name="error_no_space_left">На пристрої не залишилося вільного місця</string>
<string name="different_languages">Різні мови</string>
<string name="network_unavailable">Мережа недоступна</string>
<string name="network_unavailable_hint">Увімкніть Wi-Fi або мобільну мережу, щоб читати манґу онлайн</string>
<string name="webtoon_zoom">Масштабування в режимі вебтуну</string>
<string name="webtoon_zoom_summary">Дозволити жести збільшення/зменшення масштабу в режимі вебтуну (бета)</string>
<string name="reader_slider">Відображати повзунок перемикання сторінок</string>
<string name="compact">Компактно</string>
</resources> </resources>

@ -383,8 +383,16 @@
<string name="brightness">亮度</string> <string name="brightness">亮度</string>
<string name="contrast">对比度</string> <string name="contrast">对比度</string>
<string name="color_correction_hint">所选颜色设置将会应用于此漫画</string> <string name="color_correction_hint">所选颜色设置将会应用于此漫画</string>
<string name="text_unsaved_changes_prompt">您有未保存的更改,您希望存储还是放弃更改?</string> <string name="text_unsaved_changes_prompt">保存还是放弃未保存的更改?</string>
<string name="discard">放弃</string> <string name="discard">放弃</string>
<string name="error_no_space_left">设备上没有剩余空间</string> <string name="error_no_space_left">设备上没有剩余空间</string>
<string name="reader_slider">显示换页滑块</string> <string name="reader_slider">显示换页滑块</string>
<string name="webtoon_zoom">Webtoon 缩放</string>
<string name="webtoon_zoom_summary">在 webtoon 模式下允许缩小/放大(测试)</string>
<string name="different_languages">不同语言</string>
<string name="network_unavailable">网络不可用</string>
<string name="network_unavailable_hint">打开 Wi-Fi 或移动网络在线阅读漫画</string>
<string name="clear_new_chapters_counters">同样清除新章节信息</string>
<string name="server_error">服务器端错误 (%1$d)。请稍后再试</string>
<string name="compact">紧凑</string>
</resources> </resources>

@ -21,5 +21,5 @@
<color name="scrollbar">#66000000</color> <color name="scrollbar">#66000000</color>
<color name="selector_foreground">#29000000</color> <color name="selector_foreground">#29000000</color>
<color name="divider_default">#1F000000</color> <color name="divider_default">#1F000000</color>
<color name="status_bar_incognito">#334800E0</color>
</resources> </resources>

@ -395,5 +395,8 @@
<string name="different_languages">Different languages</string> <string name="different_languages">Different languages</string>
<string name="network_unavailable">Network is not available</string> <string name="network_unavailable">Network is not available</string>
<string name="network_unavailable_hint">Turn on Wi-Fi or mobile network to read manga online</string> <string name="network_unavailable_hint">Turn on Wi-Fi or mobile network to read manga online</string>
<string name="server_error">Server side error (%1$d). Please try again later</string>
<string name="clear_new_chapters_counters">Also clear information about new chapters</string>
<string name="compact">Compact</string>
<string name="mal" translatable="false">MyAnimeList</string> <string name="mal" translatable="false">MyAnimeList</string>
</resources> </resources>

@ -86,8 +86,6 @@
<!--== Default Theme ==--> <!--== Default Theme ==-->
<style name="Theme.Kotatsu" parent="Base.Theme.Kotatsu" /> <style name="Theme.Kotatsu" parent="Base.Theme.Kotatsu" />
<style name="Theme.Kotatsu.DialogWhenLarge" />
<!-- Monet theme only support S+ --> <!-- Monet theme only support S+ -->
<style name="Theme.Kotatsu.Monet" /> <style name="Theme.Kotatsu.Monet" />

@ -0,0 +1,39 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost", null)
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga = stub()
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> = stub()
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub()
override suspend fun getTags(): Set<MangaTag> = stub()
private fun stub(): Nothing {
throw NotFoundException("Usage of Dummy parser in release build", "")
}
}

@ -1,10 +0,0 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.newParser
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
return source.newParser(loaderContext)
}

@ -5,8 +5,8 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.3.0' classpath 'com.android.tools.build:gradle:7.3.1'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20'
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.44' classpath 'com.google.dagger:hilt-android-gradle-plugin:2.44'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong

Loading…
Cancel
Save