Merge branch 'devel' into feature/shikimori

pull/172/head
Koitharu 4 years ago
commit 7f3c46942d
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: ⚠️ Source issue
url: https://github.com/nv95/kotatsu-parsers/issues/new
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead

@ -0,0 +1,93 @@
name: 🐞 Issue report
description: Report an issue in Kotatsu
labels: [bug]
body:
- type: textarea
id: reproduce-steps
attributes:
label: Steps to reproduce
description: Provide an example of the issue.
placeholder: |
Example:
1. First step
2. Second step
3. Issue here
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: Explain what you should expect to happen.
placeholder: |
Example:
"This should happen..."
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior
description: Explain what actually happens.
placeholder: |
Example:
"This happened instead..."
validations:
required: true
- type: input
id: kotatsu-version
attributes:
label: Kotatsu version
description: You can find your Kotatsu version in **Settings → About**.
placeholder: |
Example: "3.2.2"
validations:
required: true
- type: input
id: android-version
attributes:
label: Android version
description: You can find this somewhere in your Android settings.
placeholder: |
Example: "Android 12"
validations:
required: true
- type: input
id: device
attributes:
label: Device
description: List your device and model.
placeholder: |
Example: "LG Nexus 5X"
validations:
required: true
- type: textarea
id: other-details
attributes:
label: Other details
placeholder: |
Additional details and attachments.
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true
- label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true

@ -0,0 +1,39 @@
name: ⭐ Feature request
description: Suggest a feature to improve Kotatsu
labels: [feature request]
body:
- type: textarea
id: feature-description
attributes:
label: Describe your suggested feature
description: How can Kotatsu be improved?
placeholder: |
Example:
"It should work like this..."
validations:
required: true
- type: textarea
id: other-details
attributes:
label: Other details
placeholder: |
Additional details and attachments.
- type: checkboxes
id: acknowledgements
attributes:
label: Acknowledgements
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
required: true
- label: I have written a short but informative title.
required: true
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
required: true
- label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true

2
.gitignore vendored

@ -9,6 +9,8 @@
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml
/.idea/deploymentTargetDropDown.xml
.DS_Store .DS_Store
/build /build
/captures /captures

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="$USER_HOME$/.android/avd/Pixel_API_S.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2021-02-19T19:02:37.198775Z" />
</component>
</project>

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

@ -1,8 +1,10 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" /> <inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" /> <inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
</profile> </profile>

@ -1,11 +0,0 @@
language: android
dist: trusty
android:
components:
- android-30
- build-tools-30.0.3
- platform-tools-30.0.5
- tools
before_install:
- yes | sdkmanager "platforms;android-30"
script: ./gradlew -Dorg.gradle.jvmargs=-Xmx1536m assembleDebug lintDebug

@ -2,7 +2,7 @@
Kotatsu is a free and open source manga reader for Android. Kotatsu is a free and open source manga reader for Android.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) [![Build Status](https://travis-ci.org/nv95/Kotatsu.svg?branch=master)](https://travis-ci.org/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/nv95/Kotatsu) ![License](https://img.shields.io/github/license/nv95/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![4pda](https://img.shields.io/badge/discuss-4pda-2982CC)](http://4pda.ru/forum/index.php?showtopic=697669) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
### Download ### Download

@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 32 targetSdkVersion 32
versionCode 400 versionCode 406
versionName '3.0' versionName '3.2.2'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -52,6 +52,7 @@ android {
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi', '-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
'-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlinx.coroutines.FlowPreview',
'-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=kotlin.contracts.ExperimentalContracts',
'-opt-in=coil.annotation.ExperimentalCoilApi',
] ]
} }
lint { lint {
@ -65,7 +66,7 @@ android {
} }
dependencies { dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:0ee689cd2f') { implementation('com.github.nv95:kotatsu-parsers:b495e5e457') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@ -86,7 +87,7 @@ dependencies {
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
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 'com.google.android.material:material:1.6.0-beta01' implementation 'com.google.android.material:material:1.7.0-alpha01'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1' kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
@ -101,11 +102,11 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'io.insert-koin:koin-android:3.1.6' implementation 'io.insert-koin:koin-android:3.1.6'
implementation 'io.coil-kt:coil-base:1.4.0' implementation 'io.coil-kt:coil-base:2.0.0-rc03'
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'

@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.parser
import java.util.*
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.*
/**
* This parser is just for parser development, it should not be used in releases
*/
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("", null)
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga {
TODO("Not yet implemented")
}
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
TODO("Not yet implemented")
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
TODO("Not yet implemented")
}
override suspend fun getTags(): Set<MangaTag> {
TODO("Not yet implemented")
}
}

@ -0,0 +1,14 @@
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 if (source == MangaSource.DUMMY) {
DummyParser(loaderContext)
} else {
source.newParser(loaderContext)
}
}

@ -53,7 +53,8 @@
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.SearchActivity" android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
android:label="@string/search" /> android:label="@string/search" />
<activity android:name="org.koitharu.kotatsu.search.ui.MangaListActivity" <activity
android:name="org.koitharu.kotatsu.search.ui.MangaListActivity"
android:label="@string/search_manga" /> android:label="@string/search_manga" />
<activity <activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity" android:name="org.koitharu.kotatsu.settings.SettingsActivity"
@ -86,7 +87,8 @@
<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>
@ -103,13 +105,18 @@
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity" android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads"
android:launchMode="singleTop" android:launchMode="singleTop"
android:label="@string/downloads" /> 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
android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />

@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.medianOrNull import org.koitharu.kotatsu.parsers.util.medianOrNull
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile import java.util.zip.ZipFile
@ -59,6 +60,14 @@ object MangaUtils : KoinComponent {
} }
} }
suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.path, options)?.recycle()
options.outMimeType
}
private fun getBitmapSize(input: InputStream?): Size { private fun getBitmapSize(input: InputStream?): Size {
val options = BitmapFactory.Options().apply { val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true inJustDecodeBounds = true

@ -26,7 +26,8 @@ import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(), abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
protected lateinit var binding: B protected lateinit var binding: B

@ -9,11 +9,12 @@ import android.view.ViewGroup.LayoutParams
import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialog
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() { abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@ -43,7 +44,9 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return if (resources.getBoolean(R.bool.is_tablet)) { return if (resources.getBoolean(R.bool.is_tablet)) {
AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog) AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
} else super.onCreateDialog(savedInstanceState) } else {
AppBottomSheetDialog(requireContext(), theme)
}
} }
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B

@ -1,18 +1,18 @@
package org.koitharu.kotatsu.base.ui package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
abstract class BaseViewModel : ViewModel() { abstract class BaseViewModel : ViewModel() {
val onError = SingleLiveEvent<Throwable>() val onError = SingleLiveEvent<Throwable>()
val isLoading = MutableLiveData(false) val isLoading = CountedBooleanLiveData()
protected fun launchJob( protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext, context: CoroutineContext = EmptyCoroutineContext,

@ -0,0 +1,37 @@
package org.koitharu.kotatsu.base.ui
import android.app.Service
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex()
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
launchCoroutine(intent, startId)
return Service.START_REDELIVER_INTENT
}
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
mutex.withLock {
try {
withContext(dispatcher) {
processIntent(intent)
}
} finally {
stopSelf(startId)
}
}
}
protected abstract suspend fun processIntent(intent: Intent?)
}

@ -0,0 +1,29 @@
package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context
import android.graphics.Color
import android.view.View
import com.google.android.material.bottomsheet.BottomSheetDialog
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
/**
* https://github.com/material-components/material-components-android/issues/2582
*/
@Suppress("DEPRECATION")
override fun onAttachedToWindow() {
val window = window
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
super.onAttachedToWindow()
if (window != null) {
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
if (drawEdgeToEdge) {
// Copied from super.onAttachedToWindow:
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
// Fix super-class's window flag bug by respecting the intial system UI visibility:
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
}
}
}
}

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.dialog
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
@ -12,7 +13,6 @@ import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemStorageBinding import org.koitharu.kotatsu.databinding.ItemStorageBinding
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.utils.ext.inflate
import java.io.File import java.io.File
class StorageSelectDialog private constructor(private val delegate: AlertDialog) : class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
@ -66,7 +66,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
val volumes = getAvailableVolumes(storageManager) val volumes = getAvailableVolumes(storageManager)
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: parent.inflate(R.layout.item_storage) val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also { val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
view.tag = it view.tag = it
} }

@ -0,0 +1,35 @@
package org.koitharu.kotatsu.base.ui.list.decor
import android.graphics.Rect
import android.util.SparseIntArray
import android.view.View
import androidx.core.util.getOrDefault
import androidx.core.util.set
import androidx.recyclerview.widget.RecyclerView
class TypedSpacingItemDecoration(
vararg spacingMapping: Pair<Int, Int>,
private val fallbackSpacing: Int = 0,
) : RecyclerView.ItemDecoration() {
private val mapping = SparseIntArray(spacingMapping.size)
init {
spacingMapping.forEach { (k, v) -> mapping[k] = v }
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
val itemType = parent.getChildViewHolder(view)?.itemViewType
val spacing = if (itemType == null) {
fallbackSpacing
} else {
mapping.getOrDefault(itemType, fallbackSpacing)
}
outRect.set(spacing, spacing, spacing, spacing)
}
}

@ -0,0 +1,20 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.lifecycle.MutableLiveData
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) {
private var counter = 0
override fun setValue(value: Boolean) {
if (value) {
counter++
} else {
counter--
}
val newValue = counter > 0
if (newValue != this.value) {
super.setValue(newValue)
}
}
}

@ -5,6 +5,7 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import android.os.Parcelable.Creator import android.os.Parcelable.Creator
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View
import android.widget.Checkable import android.widget.Checkable
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
@ -61,6 +62,12 @@ class CheckableImageView @JvmOverloads constructor(
} }
} }
class ToggleOnClickListener : OnClickListener {
override fun onClick(view: View) {
(view as? Checkable)?.toggle()
}
}
fun interface OnCheckedChangeListener { fun interface OnCheckedChangeListener {
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean) fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)

@ -13,12 +13,12 @@ import android.graphics.drawable.shapes.RectShape
import android.util.AttributeSet import android.util.AttributeSet
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatCheckedTextView import androidx.appcompat.widget.AppCompatCheckedTextView
import androidx.core.content.res.use
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import com.google.android.material.ripple.RippleUtils import com.google.android.material.ripple.RippleUtils
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class ListItemTextView @JvmOverloads constructor( class ListItemTextView @JvmOverloads constructor(
@ -119,8 +119,7 @@ class ListItemTextView @JvmOverloads constructor(
} }
private fun getRippleColorFallback(context: Context): ColorStateList { private fun getRippleColorFallback(context: Context): ColorStateList {
return context.obtainStyledAttributes(intArrayOf(android.R.attr.colorControlHighlight)).use { return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
it.getColorStateList(0) ?: ColorStateList.valueOf(Color.TRANSPARENT)
} ?: ColorStateList.valueOf(Color.TRANSPARENT)
} }
} }

@ -0,0 +1,72 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.WindowInsets
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.WindowInsetsCompat
class WindowInsetHolder @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private var desiredHeight = 0
private var desiredWidth = 0
@SuppressLint("RtlHardcoded")
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
.getInsets(WindowInsetsCompat.Type.systemBars())
val gravity = getLayoutGravity()
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
Gravity.LEFT -> barsInsets.left
Gravity.RIGHT -> barsInsets.right
else -> 0
}
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {
Gravity.TOP -> barsInsets.top
Gravity.BOTTOM -> barsInsets.bottom
else -> 0
}
if (newWidth != desiredWidth || newHeight != desiredHeight) {
desiredWidth = newWidth
desiredHeight = newHeight
requestLayout()
}
return super.dispatchApplyWindowInsets(insets)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
super.onMeasure(
if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) {
widthMeasureSpec
} else {
MeasureSpec.makeMeasureSpec(desiredWidth, widthMode)
},
if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) {
heightMeasureSpec
} else {
MeasureSpec.makeMeasureSpec(desiredHeight, heightMode)
},
)
}
private fun getLayoutGravity(): Int {
return when (val lp = layoutParams) {
is FrameLayout.LayoutParams -> lp.gravity
is LinearLayout.LayoutParams -> lp.gravity
is CoordinatorLayout.LayoutParams -> lp.gravity
else -> Gravity.NO_GRAVITY
}
}
}

@ -11,10 +11,11 @@ import android.view.MenuItem
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback { class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
@ -28,6 +29,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
with(binding.webView.settings) { with(binding.webView.settings) {
javaScriptEnabled = true javaScriptEnabled = true
userAgentString = UserAgentInterceptor.userAgent
} }
binding.webView.webViewClient = BrowserClient(this) binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar) binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)

@ -2,15 +2,11 @@ package org.koitharu.kotatsu.browser
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import okhttp3.OkHttpClient
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koitharu.kotatsu.core.network.WebViewClientCompat import org.koitharu.kotatsu.core.network.WebViewClientCompat
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent { class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
private val okHttp by inject<OkHttpClient>(mode = LazyThreadSafetyMode.SYNCHRONIZED)
override fun onPageFinished(webView: WebView, url: String) { override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url) super.onPageFinished(webView, url)
callback.onLoadingStateChanged(isLoading = false) callback.onLoadingStateChanged(isLoading = false)

@ -1,51 +0,0 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import java.io.File
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.MutableZipFile
import org.koitharu.kotatsu.utils.ext.format
class BackupArchive(file: File) : MutableZipFile(file) {
init {
if (!dir.exists()) {
dir.mkdirs()
}
}
suspend fun put(entry: BackupEntry) {
put(entry.name, entry.data.toString(2))
}
suspend fun getEntry(name: String): BackupEntry {
val json = withContext(Dispatchers.Default) {
JSONArray(getContent(name))
}
return BackupEntry(name, json)
}
companion object {
private const val DIR_BACKUPS = "backups"
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
append('_')
append(Date().format("ddMMyyyy"))
append(".bak")
}
BackupArchive(File(dir, filename))
}
}
}

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

@ -0,0 +1,25 @@
package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.json.JSONArray
import java.io.File
import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name)
val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText())
}
BackupEntry(name, json)
}
override fun close() {
zipFile.close()
}
}

@ -0,0 +1,45 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.utils.ext.format
import java.io.File
import java.util.*
import java.util.zip.Deflater
class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) {
output.put(entry.name, entry.data.toString(2))
}
suspend fun finish() {
output.finish()
}
override fun close() {
output.close()
}
}
private const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(Date().format("ddMMyyyy"))
append(".bk.zip")
}
BackupZipOutput(File(dir, filename))
}

@ -104,6 +104,7 @@ class RestoreRepository(private val db: MangaDatabase) {
sortKey = json.getInt("sort_key"), sortKey = json.getInt("sort_key"),
title = json.getString("title"), title = json.getString("title"),
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name, order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
track = json.getBooleanOrDefault("track", true),
) )
private fun parseFavourite(json: JSONObject) = FavouriteEntity( private fun parseFavourite(json: JSONObject) = FavouriteEntity(

@ -5,5 +5,5 @@ import org.koin.dsl.module
val databaseModule val databaseModule
get() = module { get() = module {
single { MangaDatabase.create(androidContext()) } single { MangaDatabase(androidContext()) }
} }

@ -10,8 +10,8 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
override fun onCreate(db: SupportSQLiteDatabase) { override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL( db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)", "INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track) VALUES (?,?,?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name) arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1)
) )
} }
} }

@ -22,7 +22,7 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class
], ],
version = 9 version = 10
) )
abstract class MangaDatabase : RoomDatabase() { abstract class MangaDatabase : RoomDatabase() {
@ -43,14 +43,13 @@ abstract class MangaDatabase : RoomDatabase() {
abstract val trackLogsDao: TrackLogsDao abstract val trackLogsDao: TrackLogsDao
abstract val suggestionDao: SuggestionDao abstract val suggestionDao: SuggestionDao
}
companion object { fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
fun create(context: Context): MangaDatabase = Room.databaseBuilder(
context, context,
MangaDatabase::class.java, MangaDatabase::class.java,
"kotatsu-db" "kotatsu-db"
).addMigrations( ).addMigrations(
Migration1To2(), Migration1To2(),
Migration2To3(), Migration2To3(),
Migration3To4(), Migration3To4(),
@ -59,8 +58,7 @@ abstract class MangaDatabase : RoomDatabase() {
Migration6To7(), Migration6To7(),
Migration7To8(), Migration7To8(),
Migration8To9(), Migration8To9(),
).addCallback( Migration9To10(),
).addCallback(
DatabasePrePopulateCallback(context.resources) DatabasePrePopulateCallback(context.resources)
).build() ).build()
}
}

@ -10,6 +10,9 @@ abstract class TracksDao {
@Query("SELECT * FROM tracks") @Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List<TrackEntity> abstract suspend fun findAll(): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId") @Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): TrackEntity? abstract suspend fun find(mangaId: Long): TrackEntity?

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration9To10 : Migration(9, 10) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
}
}

@ -4,7 +4,7 @@ import org.koin.dsl.module
val githubModule val githubModule
get() = module { get() = module {
single { factory {
GithubRepository(get()) GithubRepository(get())
} }
} }

@ -1,9 +1,9 @@
package org.koitharu.kotatsu.core.model package org.koitharu.kotatsu.core.model
import android.os.Parcelable import android.os.Parcelable
import java.util.*
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.*
@Parcelize @Parcelize
data class FavouriteCategory( data class FavouriteCategory(
@ -12,4 +12,5 @@ data class FavouriteCategory(
val sortKey: Int, val sortKey: Int,
val order: SortOrder, val order: SortOrder,
val createdAt: Date, val createdAt: Date,
val isTrackingEnabled: Boolean,
) : Parcelable ) : Parcelable

@ -3,26 +3,4 @@ package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
this
} else {
Manga(
id = id,
title = title,
altTitle = altTitle,
url = url,
publicUrl = publicUrl,
rating = rating,
isNsfw = isNsfw,
coverUrl = coverUrl,
tags = tags,
state = state,
author = author,
largeCoverUrl = largeCoverUrl,
description = description,
chapters = null,
source = source,
)
}
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }

@ -10,13 +10,18 @@ private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
class ParcelableManga( class ParcelableManga(
val manga: Manga, val manga: Manga,
private val withChapters: Boolean,
) : Parcelable { ) : Parcelable {
constructor(parcel: Parcel) : this(parcel.readManga()) constructor(parcel: Parcel) : this(parcel.readManga(), true)
override fun writeToParcel(parcel: Parcel, flags: Int) { override fun writeToParcel(parcel: Parcel, flags: Int) {
val chapters = manga.chapters val chapters = manga.chapters
if (chapters == null || chapters.size <= MAX_SAFE_CHAPTERS_COUNT) { if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path // fast path
manga.writeToParcel(parcel, flags, withChapters = true) manga.writeToParcel(parcel, flags, withChapters = true)
return return

@ -1,10 +1,10 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import android.os.Build import android.os.Build
import java.util.*
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import java.util.*
class UserAgentInterceptor : Interceptor { class UserAgentInterceptor : Interceptor {
@ -30,5 +30,14 @@ class UserAgentInterceptor : Interceptor {
Build.DEVICE, Build.DEVICE,
Locale.getDefault().language Locale.getDefault().language
) )
val userAgentChrome
get() = (
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/100.0.4896.127 Mobile Safari/537.36"
).format(
Build.VERSION.RELEASE,
Build.MODEL,
)
} }
} }

@ -5,12 +5,12 @@ import android.content.Context
import android.content.pm.ShortcutManager import android.content.pm.ShortcutManager
import android.media.ThumbnailUtils import android.media.ThumbnailUtils
import android.os.Build import android.os.Build
import android.util.Size
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.size.PixelSize
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@ -54,7 +54,7 @@ class ShortcutsRepository(
val bmp = coil.execute( val bmp = coil.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.size(iconSize) .size(iconSize.width, iconSize.height)
.build() .build()
).requireBitmap() ).requireBitmap()
ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0) ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
@ -74,14 +74,14 @@ class ShortcutsRepository(
) )
} }
private fun getIconSize(context: Context): PixelSize { private fun getIconSize(context: Context): Size {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
(context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let { (context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
PixelSize(it.iconMaxWidth, it.iconMaxHeight) Size(it.iconMaxWidth, it.iconMaxHeight)
} }
} else { } else {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let { (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let {
PixelSize(it, it) Size(it, it)
} }
} }
} }

@ -2,17 +2,19 @@ package org.koitharu.kotatsu.core.parser
import android.net.Uri import android.net.Uri
import coil.map.Mapper import coil.map.Mapper
import coil.request.Options
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
class FaviconMapper() : Mapper<Uri, HttpUrl> { class FaviconMapper : Mapper<Uri, HttpUrl> {
override fun map(data: Uri): HttpUrl { override fun map(data: Uri, options: Options): HttpUrl? {
if (data.scheme != "favicon") {
return null
}
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart) val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
val repo = MangaRepository(mangaSource) as RemoteMangaRepository val repo = MangaRepository(mangaSource) as RemoteMangaRepository
return repo.getFaviconUrl().toHttpUrl() return repo.getFaviconUrl().toHttpUrl()
} }
override fun handles(data: Uri) = data.scheme == "favicon"
} }

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import java.lang.ref.WeakReference
import java.util.*
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.local.domain.LocalMangaRepository
@ -28,11 +30,18 @@ interface MangaRepository {
companion object : KoinComponent { companion object : KoinComponent {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
operator fun invoke(source: MangaSource): MangaRepository { operator fun invoke(source: MangaSource): MangaRepository {
return if (source == MangaSource.LOCAL) { if (source == MangaSource.LOCAL) {
get<LocalMangaRepository>() return get<LocalMangaRepository>()
} else { }
RemoteMangaRepository(source, get()) cache[source]?.get()?.let { return it }
return synchronized(cache) {
cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(MangaParser(source, get()))
cache[source] = WeakReference(repository)
repository
} }
} }
} }

@ -1,19 +1,15 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.newParser
class RemoteMangaRepository( class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
override val source: MangaSource,
loaderContext: MangaLoaderContext,
) : MangaRepository {
private val parser: MangaParser = source.newParser(loaderContext) override val source: MangaSource
get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>
get() = parser.sortOrders get() = parser.sortOrders
@ -28,7 +24,7 @@ class RemoteMangaRepository(
offset: Int, offset: Int,
query: String?, query: String?,
tags: Set<MangaTag>?, tags: Set<MangaTag>?,
sortOrder: SortOrder? sortOrder: SortOrder?,
): List<Manga> = parser.getList(offset, query, tags, sortOrder) ): List<Manga> = parser.getList(offset, query, tags, sortOrder)
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga) override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)

@ -11,23 +11,34 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class AppSettings(context: Context) { class AppSettings(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY)
}
}
val remoteMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
var listMode: ListMode var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST) get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
@ -56,6 +67,10 @@ class AppSettings(context: Context) {
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true) get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) } set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
var isAllFavouritesVisible: Boolean
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
val isUpdateCheckingEnabled: Boolean val isUpdateCheckingEnabled: Boolean
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true) get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
@ -63,7 +78,10 @@ class AppSettings(context: Context) {
get() = prefs.getLong(KEY_APP_UPDATE, 0L) get() = prefs.getLong(KEY_APP_UPDATE, 0L)
set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) } set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
val trackerNotifications: Boolean val isTrackerEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
val isTrackerNotificationsEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true) get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
var notificationSound: Uri var notificationSound: Uri
@ -104,10 +122,9 @@ class AppSettings(context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null) get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) } set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
var sourcesOrder: List<Int> var sourcesOrder: List<String>
get() = prefs.getString(KEY_SOURCES_ORDER, null) get() = prefs.getString(KEY_SOURCES_ORDER, null)
?.split('|') ?.split('|')
?.mapNotNull(String::toIntOrNull)
.orEmpty() .orEmpty()
set(value) = prefs.edit { set(value) = prefs.edit {
putString(KEY_SOURCES_ORDER, value.joinToString("|")) putString(KEY_SOURCES_ORDER, value.joinToString("|"))
@ -120,6 +137,20 @@ class AppSettings(context: Context) {
val isSourcesSelected: Boolean val isSourcesSelected: Boolean
get() = KEY_SOURCES_HIDDEN in prefs get() = KEY_SOURCES_HIDDEN in prefs
val newSources: Set<MangaSource>
get() {
val known = sourcesOrder.toSet()
val hidden = hiddenSources
return remoteMangaSources
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
x.name in known || x.name in hidden
}
}
fun markKnownSources(sources: Collection<MangaSource>) {
sourcesOrder = sourcesOrder + sources.map { it.name }
}
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@ -141,6 +172,12 @@ class AppSettings(context: Context) {
} }
} }
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val downloadsParallelism: Int
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
val isSuggestionsEnabled: Boolean val isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false) get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
@ -178,11 +215,10 @@ class AppSettings(context: Context) {
} }
fun getMangaSources(includeHidden: Boolean): List<MangaSource> { fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = MangaSource.values().toMutableList() val list = remoteSources.toMutableList()
list.remove(MangaSource.LOCAL)
val order = sourcesOrder val order = sourcesOrder
list.sortBy { x -> list.sortBy { x ->
val e = order.indexOf(x.ordinal) val e = order.indexOf(x.name)
if (e == -1) order.size + x.ordinal else e if (e == -1) order.size + x.ordinal else e
} }
if (!includeHidden) { if (!includeHidden) {
@ -224,7 +260,7 @@ class AppSettings(context: Context) {
const val KEY_DYNAMIC_THEME = "dynamic_theme" const val KEY_DYNAMIC_THEME = "dynamic_theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_DATE_FORMAT = "date_format" const val KEY_DATE_FORMAT = "date_format"
const val KEY_SOURCES_ORDER = "sources_order" const val KEY_SOURCES_ORDER = "sources_order_2"
const val KEY_SOURCES_HIDDEN = "sources_hidden" const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning" const val KEY_TRAFFIC_WARNING = "traffic_warning"
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
@ -236,13 +272,16 @@ class AppSettings(context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers" const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_SOURCES = "track_sources"
const val KEY_TRACK_CATEGORIES = "track_categories"
const val KEY_TRACK_WARNING = "track_warning" const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light" const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
const val KEY_READER_ANIMATION = "reader_animation" const val KEY_READER_ANIMATION = "reader_animation"
const val KEY_READER_PREFER_RTL = "reader_prefer_rtl" const val KEY_READER_PREFER_RTL = "reader_prefer_rtl"
const val KEY_APP_PASSWORD = "app_password" const val KEY_APP_PASSWORD = "app_password"
@ -262,6 +301,9 @@ class AppSettings(context: Context) {
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source" const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

@ -3,6 +3,9 @@ package org.koitharu.kotatsu.core.ui
import android.content.res.Resources import android.content.res.Resources
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.format
import java.util.*
sealed class DateTimeAgo : ListModel { sealed class DateTimeAgo : ListModel {
@ -72,6 +75,30 @@ sealed class DateTimeAgo : ListModel {
override fun hashCode(): Int = days override fun hashCode(): Int = days
} }
class Absolute(private val date: Date) : DateTimeAgo() {
private val day = date.daysDiff(0)
override fun format(resources: Resources): String {
return date.format("d MMMM")
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Absolute
if (day != other.day) return false
return true
}
override fun hashCode(): Int {
return day
}
}
object LongAgo : DateTimeAgo() { object LongAgo : DateTimeAgo() {
override fun format(resources: Resources): String { override fun format(resources: Resources): String {
return resources.getString(R.string.long_ago) return resources.getString(R.string.long_ago)

@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.ui
import coil.ComponentRegistry import coil.ComponentRegistry
import coil.ImageLoader import coil.ImageLoader
import coil.util.CoilUtils import coil.disk.DiskCache
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.core.parser.FaviconMapper import org.koitharu.kotatsu.core.parser.FaviconMapper
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher import org.koitharu.kotatsu.local.data.CbzFetcher
val uiModule val uiModule
@ -14,15 +16,23 @@ val uiModule
single { single {
val httpClientFactory = { val httpClientFactory = {
get<OkHttpClient>().newBuilder() get<OkHttpClient>().newBuilder()
.cache(CoilUtils.createDefaultCache(androidContext())) .cache(null)
.build()
}
val diskCacheFactory = {
val context = androidContext()
val rootDir = context.externalCacheDir ?: context.cacheDir
DiskCache.Builder()
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
.build() .build()
} }
ImageLoader.Builder(androidContext()) ImageLoader.Builder(androidContext())
.okHttpClient(httpClientFactory) .okHttpClient(httpClientFactory)
.launchInterceptorChainOnMainThread(false) .interceptorDispatcher(Dispatchers.Default)
.componentRegistry( .diskCache(diskCacheFactory)
.components(
ComponentRegistry.Builder() ComponentRegistry.Builder()
.add(CbzFetcher()) .add(CbzFetcher.Factory())
.add(FaviconMapper()) .add(FaviconMapper())
.build() .build()
).build() ).build()

@ -0,0 +1,118 @@
package org.koitharu.kotatsu.core.zip
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import okio.Closeable
import java.io.File
import java.io.FileInputStream
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
class ZipOutput(
val file: File,
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
) : Closeable {
private val entryNames = ArraySet<String>()
private var isClosed = false
private val output = ZipOutputStream(file.outputStream()).apply {
setLevel(compressionLevel)
}
@WorkerThread
fun put(name: String, file: File): Boolean {
return output.appendFile(file, name)
}
@WorkerThread
fun put(name: String, content: String): Boolean {
return output.appendText(content, name)
}
@WorkerThread
fun addDirectory(name: String): Boolean {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
} else {
ZipEntry("$name/")
}
return if (entryNames.add(entry.name)) {
output.putNextEntry(entry)
output.closeEntry()
true
} else {
false
}
}
@WorkerThread
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry)
other.getInputStream(entry).use { input ->
input.copyTo(output)
}
output.closeEntry()
true
} else {
false
}
}
fun finish() {
output.finish()
output.flush()
}
override fun close() {
if (!isClosed) {
output.close()
isClosed = true
}
}
@WorkerThread
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
if (fileToZip.isDirectory) {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
} else {
ZipEntry("$name/")
}
if (!entryNames.add(entry.name)) {
return false
}
putNextEntry(entry)
closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
appendFile(childFile, "$name/${childFile.name}")
}
} else {
FileInputStream(fileToZip).use { fis ->
if (!entryNames.add(name)) {
return false
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
fis.copyTo(this)
closeEntry()
}
}
return true
}
@WorkerThread
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
if (!entryNames.add(name)) {
return false
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
content.byteInputStream().copyTo(this)
closeEntry()
return true
}
}

@ -9,9 +9,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
@ -22,6 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
@ -67,8 +68,8 @@ class ChaptersFragment :
viewModel.isChaptersReversed.observe(viewLifecycleOwner) { viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
viewModel.hasChapters.observe(viewLifecycleOwner) { viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isGone = it binding.textViewHolder.isVisible = it
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
} }
@ -94,7 +95,7 @@ class ChaptersFragment :
override fun onPrepareOptionsMenu(menu: Menu) { override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu) super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
} }
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
@ -154,11 +155,29 @@ class ChaptersFragment :
DownloadService.start( DownloadService.start(
context ?: return false, context ?: return false,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false, viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds selectionDecoration?.checkedItemsIds?.toSet()
) )
mode.finish() mode.finish()
true true
} }
R.id.action_delete -> {
val ids = selectionDecoration?.checkedItemsIds
val manga = viewModel.manga.value
when {
ids.isNullOrEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> {
LocalChaptersRemoveService.start(requireContext(), manga, ids)
Snackbar.make(
binding.recyclerViewChapters,
R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG
).show()
}
}
mode.finish()
true
}
R.id.action_select_all -> { R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionDecoration?.checkAll(ids) selectionDecoration?.checkAll(ids)
@ -188,6 +207,9 @@ class ChaptersFragment :
menu.findItem(R.id.action_save).isVisible = items.none { x -> menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL x.chapter.source == MangaSource.LOCAL
} }
menu.findItem(R.id.action_delete).isVisible = items.all { x ->
x.chapter.source == MangaSource.LOCAL
}
mode.title = items.size.toString() mode.title = items.size.toString()
return true return true
} }

@ -41,13 +41,16 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.download.ui.service.DownloadService import org.koitharu.kotatsu.download.ui.service.DownloadService
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.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy, class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
TabLayoutMediator.TabConfigurationStrategy,
AdapterView.OnItemSelectedListener { AdapterView.OnItemSelectedListener {
private val viewModel by viewModel<DetailsViewModel> { private val viewModel by viewModel<DetailsViewModel> {
@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
true true
} }
R.id.action_delete -> { R.id.action_delete -> {
viewModel.manga.value?.let { m -> val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_manga) .setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title)) .setMessage(getString(R.string.text_delete_local_manga, title))
.setPositiveButton(R.string.delete) { _, _ -> .setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal(m) viewModel.deleteLocal()
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
}
true true
} }
R.id.action_save -> { R.id.action_save -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0 val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) { val branches = viewModel.branches.value.orEmpty()
MaterialAlertDialogBuilder(this) if (chaptersCount > 5 || branches.size > 1) {
.setTitle(R.string.save_manga) showSaveConfirmation(it, chaptersCount, branches)
.setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(
R.plurals.chapters,
chaptersCount,
chaptersCount
)
)
)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it)
}.show()
} else { } else {
DownloadService.start(this, it) DownloadService.start(this, it)
} }
@ -262,7 +250,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
fun showChapterMissingDialog(chapterId: Long) { fun showChapterMissingDialog(chapterId: Long) {
val remoteManga = viewModel.getRemoteManga() val remoteManga = viewModel.getRemoteManga()
if (remoteManga == null) { if (remoteManga == null) {
binding.snackbar.show(getString( R.string.chapter_is_missing)) binding.snackbar.show(getString(R.string.chapter_is_missing))
return return
} }
MaterialAlertDialogBuilder(this).apply { MaterialAlertDialogBuilder(this).apply {
@ -328,11 +316,41 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
} }
} }
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) {
val dialogBuilder = MaterialAlertDialogBuilder(this)
.setTitle(R.string.save_manga)
.setNegativeButton(android.R.string.cancel, null)
if (branches.size > 1) {
val items = Array(branches.size) { i -> branches[i].orEmpty() }
val currentBranch = viewModel.selectedBranchIndex.value ?: -1
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] }
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
DownloadService.start(this, manga, chaptersIds)
}
} else {
dialogBuilder.setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
)
).setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, manga)
}
}
dialogBuilder.show()
}
companion object { companion object {
fun newIntent(context: Context, manga: Manga): Intent { fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java) return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
} }
fun newIntent(context: Context, mangaId: Long): Intent { fun newIntent(context: Context, mangaId: Long): Intent {

@ -5,6 +5,7 @@ import android.os.Bundle
import android.text.Spanned import android.text.Spanned
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import android.view.* import android.view.*
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.net.toUri import androidx.core.net.toUri
@ -224,14 +225,16 @@ class DetailsFragment :
if (viewModel.readingHistory.value == null) { if (viewModel.readingHistory.value == null) {
return false return false
} }
v.showPopupMenu(R.menu.popup_read) { val menu = PopupMenu(v.context, v)
when (it.itemId) { menu.inflate(R.menu.popup_read)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_read -> { R.id.action_read -> {
val branch = viewModel.selectedBranchValue val branch = viewModel.selectedBranchValue
startActivity( startActivity(
ReaderActivity.newIntent( ReaderActivity.newIntent(
context = context ?: return@showPopupMenu false, context = context ?: return@setOnMenuItemClickListener false,
manga = viewModel.manga.value ?: return@showPopupMenu false, manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
state = viewModel.chapters.value?.firstOrNull { c -> state = viewModel.chapters.value?.firstOrNull { c ->
c.chapter.branch == branch c.chapter.branch == branch
}?.let { c -> }?.let { c ->
@ -244,6 +247,7 @@ class DetailsFragment :
else -> false else -> false
} }
} }
menu.show()
return true return true
} }
else -> return false else -> return false
@ -279,7 +283,7 @@ class DetailsFragment :
.target(binding.imageViewCover) .target(binding.imageViewCover)
if (currentCover != null) { if (currentCover != null) {
request.data(manga.largeCoverUrl ?: return) request.data(manga.largeCoverUrl ?: return)
.placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey) .placeholderMemoryCacheKey(CoilUtils.result(binding.imageViewCover)?.request?.memoryCacheKey)
.fallback(currentCover) .fallback(currentCover)
} else { } else {
request.crossfade(true) request.crossfade(true)

@ -31,6 +31,7 @@ import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo import org.koitharu.kotatsu.shikimori.data.model.ShikimoriMangaInfo
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
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.iterator import org.koitharu.kotatsu.utils.ext.iterator
import java.io.IOException import java.io.IOException
@ -93,18 +94,18 @@ class DetailsViewModel(
val branches = mangaData.map { val branches = mangaData.map {
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty() it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchIndex = combine( val selectedBranchIndex = combine(
branches.asFlow(), branches.asFlow(),
selectedBranch selectedBranch
) { branches, selected -> ) { branches, selected ->
branches.indexOf(selected) branches.indexOf(selected)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val hasChapters = mangaData.map { val isChaptersEmpty = mangaData.mapNotNull { m ->
!(it?.chapters.isNullOrEmpty()) m?.run { chapters.isNullOrEmpty() }
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default) }.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
val chapters = combine( val chapters = combine(
combine( combine(
@ -139,8 +140,11 @@ class DetailsViewModel(
loadingJob = doLoad() loadingJob = doLoad()
} }
fun deleteLocal(manga: Manga) { fun deleteLocal() {
val m = mangaData.value ?: return
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga) val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file") localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatching { runCatching {
@ -196,7 +200,8 @@ class DetailsViewModel(
// find default branch // find default branch
val hist = historyRepository.getOne(manga) val hist = historyRepository.getOne(manga)
selectedBranch.value = if (hist != null) { selectedBranch.value = if (hist != null) {
manga.chapters?.find { it.id == hist.chapterId }?.branch val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
} else { } else {
predictBranch(manga.chapters) predictBranch(manga.chapters)
} }
@ -208,6 +213,8 @@ class DetailsViewModel(
} else { } else {
localMangaRepository.findSavedManga(manga) localMangaRepository.findSavedManga(manga)
} }
}.onFailure { error ->
if (BuildConfig.DEBUG) error.printStackTrace()
}.getOrNull() }.getOrNull()
findShikimoriManga(manga) findShikimoriManga(manga)
} }
@ -255,10 +262,10 @@ class DetailsViewModel(
val dateFormat = settings.getDateFormat() val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) { for (i in sourceChapters.indices) {
val chapter = sourceChapters[i] val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
if (chapter.branch != branch) { if (chapter.branch != branch) {
continue continue
} }
val localChapter = chaptersMap.remove(chapter.id)
result += localChapter?.toListItem( result += localChapter?.toListItem(
isCurrent = i == currentIndex, isCurrent = i == currentIndex,
isUnread = i > currentIndex, isUnread = i > currentIndex,
@ -277,7 +284,8 @@ class DetailsViewModel(
} }
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size) result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapTo(result) { chaptersMap.values.mapNotNullTo(result) {
if (it.branch == branch) {
it.toListItem( it.toListItem(
isCurrent = false, isCurrent = false,
isUnread = true, isUnread = true,
@ -286,6 +294,9 @@ class DetailsViewModel(
isDownloaded = false, isDownloaded = false,
dateFormat = dateFormat, dateFormat = dateFormat,
) )
} else {
null
}
} }
result.sortBy { it.chapter.number } result.sortBy { it.chapter.number }
} }

@ -40,11 +40,10 @@ class ChapterListItem(
override fun hashCode(): Int { override fun hashCode(): Int {
var result = chapter.hashCode() var result = chapter.hashCode()
result = 31 * result + flags result = 31 * result + flags
result = 31 * result + uploadDate.hashCode() result = 31 * result + (uploadDate?.hashCode() ?: 0)
return result return result
} }
companion object { companion object {
const val FLAG_UNREAD = 2 const val FLAG_UNREAD = 2

@ -1,7 +1,6 @@
package org.koitharu.kotatsu.download.domain package org.koitharu.kotatsu.download.domain
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.ImageLoader import coil.ImageLoader
@ -17,10 +16,12 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.MangaZip import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository import org.koitharu.kotatsu.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.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.referer import org.koitharu.kotatsu.utils.ext.referer
@ -29,9 +30,8 @@ import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File import java.io.File
private const val MAX_DOWNLOAD_ATTEMPTS = 3 private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_PARALLEL_DOWNLOADS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L private const val DOWNLOAD_ERROR_DELAY = 500L
private const val TEMP_PAGE_FILE = "page.tmp" private const val SLOWDOWN_DELAY = 200L
class DownloadManager( class DownloadManager(
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
@ -40,9 +40,10 @@ class DownloadManager(
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
private val cache: PagesCache, private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
) { ) {
private val connectivityManager = context.applicationContext.getSystemService( private val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE Context.CONNECTIVITY_SERVICE
) as ConnectivityManager ) as ConnectivityManager
private val coverWidth = context.resources.getDimensionPixelSize( private val coverWidth = context.resources.getDimensionPixelSize(
@ -51,79 +52,87 @@ class DownloadManager(
private val coverHeight = context.resources.getDimensionPixelSize( private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height androidx.core.R.dimen.compat_notification_large_icon_max_height
) )
private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS) private val semaphore = Semaphore(settings.downloadsParallelism)
fun downloadManga( fun downloadManga(
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: LongArray?,
startId: Int, startId: Int,
): ProgressJob<DownloadState> { ): ProgressJob<DownloadState> {
val stateFlow = MutableStateFlow<DownloadState>( val stateFlow = MutableStateFlow<DownloadState>(
DownloadState.Queued(startId = startId, manga = manga, cover = null) DownloadState.Queued(startId = startId, manga = manga, cover = null)
) )
val job = downloadMangaImpl(manga, chaptersIds, stateFlow, startId) val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
return ProgressJob(job, stateFlow) return ProgressJob(job, stateFlow)
} }
private fun downloadMangaImpl( private fun downloadMangaImpl(
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: LongArray?,
outState: MutableStateFlow<DownloadState>, outState: MutableStateFlow<DownloadState>,
startId: Int, startId: Int,
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) { ): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
@Suppress("NAME_SHADOWING") var manga = manga
val chaptersIdsSet = chaptersIds?.toMutableSet()
val cover = loadCover(manga)
outState.value = DownloadState.Queued(startId, manga, cover)
localMangaRepository.lockManga(manga.id)
semaphore.acquire() semaphore.acquire()
coroutineContext[WakeLockNode]?.acquire() coroutineContext[WakeLockNode]?.acquire()
outState.value = DownloadState.Preparing(startId, manga, null) outState.value = DownloadState.Preparing(startId, manga, null)
var cover: Drawable? = null
val destination = localMangaRepository.getOutputDir() val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null val tempFileName = "${manga.id}_$startId.tmp"
var output: CbzMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
}
val repo = MangaRepository(manga.source) val repo = MangaRepository(manga.source)
cover = runCatching {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.referer(manga.publicUrl)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
).drawable
}.getOrNull()
outState.value = DownloadState.Preparing(startId, manga, cover) outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters == null) repo.getDetails(manga) else manga val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data) output = CbzMangaOutput.get(destination, data)
output.prepare(data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination).let { file -> downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
} }
val chapters = if (chaptersIds == null) { val chapters = checkNotNull(
data.chapters.orEmpty() if (chaptersIdsSet == null) {
data.chapters
} else { } else {
data.chapters.orEmpty().filter { x -> x.id in chaptersIds } data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
}
) { "Chapters list must not be null" }
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
check(chaptersIdsSet.isNullOrEmpty()) {
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
} }
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersIds == null || chapter.id in chaptersIds) {
val pages = repo.getPages(chapter) val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) { for ((pageIndex, page) in pages.withIndex()) {
failsafe@ do { var retryCounter = 0
failsafe@ while (true) {
try { try {
val url = repo.getPageUrl(page) val url = repo.getPageUrl(page)
val file = val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
cache[url] ?: downloadFile(url, page.referer, destination)
output.addPage( output.addPage(
chapter, chapter = chapter,
file, file = file,
pageIndex, pageNumber = pageIndex,
MimeTypeMap.getFileExtensionFromUrl(url) ext = MimeTypeMap.getFileExtensionFromUrl(url),
) )
break@failsafe
} catch (e: IOException) { } catch (e: IOException) {
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
outState.value = DownloadState.WaitingForNetwork(startId, data, cover) outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
delay(DOWNLOAD_ERROR_DELAY)
connectivityManager.waitForNetwork() connectivityManager.waitForNetwork()
continue@failsafe retryCounter++
} else {
throw e
}
}
} }
} while (false)
outState.value = DownloadState.Progress( outState.value = DownloadState.Progress(
startId, data, cover, startId, data, cover,
@ -132,17 +141,20 @@ class DownloadManager(
totalPages = pages.size, totalPages = pages.size,
currentPage = pageIndex, currentPage = pageIndex,
) )
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
} }
} }
} }
outState.value = DownloadState.PostProcessing(startId, data, cover) outState.value = DownloadState.PostProcessing(startId, data, cover)
if (!output.compress()) { output.mergeWithExisting()
throw RuntimeException("Cannot create target file") output.finalize()
}
val localManga = localMangaRepository.getFromFile(output.file) val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga) outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (_: CancellationException) { } catch (e: CancellationException) {
outState.value = DownloadState.Cancelled(startId, manga, cover) outState.value = DownloadState.Cancelled(startId, manga, cover)
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
e.printStackTrace() e.printStackTrace()
@ -151,14 +163,15 @@ class DownloadManager(
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
output?.cleanup() output?.cleanup()
File(destination, TEMP_PAGE_FILE).deleteAwait() File(destination, tempFileName).deleteAwait()
} }
coroutineContext[WakeLockNode]?.release() coroutineContext[WakeLockNode]?.release()
semaphore.release() semaphore.release()
localMangaRepository.unlockManga(manga.id)
} }
} }
private suspend fun downloadFile(url: String, referer: String, destination: File): File { private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.header(CommonHeaders.REFERER, referer) .header(CommonHeaders.REFERER, referer)
@ -166,10 +179,7 @@ class DownloadManager(
.get() .get()
.build() .build()
val call = okHttp.newCall(request) val call = okHttp.newCall(request)
var attempts = MAX_DOWNLOAD_ATTEMPTS val file = File(destination, tempFileName)
val file = File(destination, TEMP_PAGE_FILE)
while (true) {
try {
val response = call.clone().await() val response = call.clone().await()
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
file.outputStream().use { out -> file.outputStream().use { out ->
@ -177,18 +187,10 @@ class DownloadManager(
} }
} }
return file return file
} catch (e: IOException) {
attempts--
if (attempts <= 0) {
throw e
} else {
delay(DOWNLOAD_ERROR_DELAY)
}
}
}
} }
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable -> private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
CoroutineExceptionHandler { _, throwable ->
val prevValue = outState.value val prevValue = outState.value
outState.value = DownloadState.Error( outState.value = DownloadState.Error(
startId = prevValue.startId, startId = prevValue.startId,
@ -197,4 +199,35 @@ class DownloadManager(
error = throwable, error = throwable,
) )
} }
private suspend fun loadCover(manga: Manga) = runCatching {
imageLoader.execute(
ImageRequest.Builder(context)
.data(manga.coverUrl)
.referer(manga.publicUrl)
.size(coverWidth, coverHeight)
.scale(Scale.FILL)
.build()
).drawable
}.getOrNull()
class Factory(
private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
) {
fun create(coroutineScope: CoroutineScope) = DownloadManager(
coroutineScope = coroutineScope,
context = context,
imageLoader = imageLoader,
okHttp = okHttp,
cache = cache,
localMangaRepository = localMangaRepository,
settings = settings,
)
}
} }

@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.text.format.DateUtils
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@ -49,7 +50,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setSilent(true) builder.setSilent(true)
} }
fun create(state: DownloadState): Notification { fun create(state: DownloadState, timeLeft: Long): Notification {
builder.setContentTitle(state.manga.title) builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_)) builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true) builder.setProgress(1, 0, true)
@ -117,7 +118,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
} }
is DownloadState.Progress -> { is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false) builder.setProgress(state.max, state.progress, false)
builder.setContentText((state.percent * 100).format() + "%") if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
} else {
val percent = (state.percent * 100).format()
builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS) builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null) builder.setStyle(null)
builder.setOngoing(true) builder.setOngoing(true)

@ -11,10 +11,7 @@ import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
@ -24,7 +21,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseService import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.withoutChapters
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
@ -32,8 +28,8 @@ import org.koitharu.kotatsu.download.domain.WakeLockNode
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.throttle import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.ext.toArraySet
import org.koitharu.kotatsu.utils.progress.ProgressJob import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class DownloadService : BaseService() { class DownloadService : BaseService() {
@ -48,16 +44,12 @@ class DownloadService : BaseService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
isRunning = true
notificationSwitcher = ForegroundNotificationSwitcher(this) notificationSwitcher = ForegroundNotificationSwitcher(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = DownloadManager( downloadManager = get<DownloadManager.Factory>().create(
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)), coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
context = this,
imageLoader = get(),
okHttp = get(),
cache = get(),
localMangaRepository = get(),
) )
DownloadNotification.createChannel(this) DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL)) registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
@ -66,7 +58,7 @@ class DownloadService : BaseService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toArraySet() val chapters = intent?.getLongArrayExtra(EXTRA_CHAPTERS_IDS)
return if (manga != null) { return if (manga != null) {
jobs[startId] = downloadManga(startId, manga, chapters) jobs[startId] = downloadManga(startId, manga, chapters)
jobCount.value = jobs.size jobCount.value = jobs.size
@ -90,13 +82,14 @@ class DownloadService : BaseService() {
override fun onDestroy() { override fun onDestroy() {
unregisterReceiver(controlReceiver) unregisterReceiver(controlReceiver)
binder = null binder = null
isRunning = false
super.onDestroy() super.onDestroy()
} }
private fun downloadManga( private fun downloadManga(
startId: Int, startId: Int,
manga: Manga, manga: Manga,
chaptersIds: Set<Long>?, chaptersIds: LongArray?,
): ProgressJob<DownloadState> { ): ProgressJob<DownloadState> {
val job = downloadManager.downloadManga(manga, chaptersIds, startId) val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job) listenJob(job)
@ -106,19 +99,28 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) { private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch { lifecycleScope.launch {
val startId = job.progressValue.startId val startId = job.progressValue.startId
val timeLeftEstimator = TimeLeftEstimator()
val notification = DownloadNotification(this@DownloadService, startId) val notification = DownloadNotification(this@DownloadService, startId)
notificationSwitcher.notify(startId, notification.create(job.progressValue)) notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
job.progressAsFlow() job.progressAsFlow()
.onEach { state ->
if (state is DownloadState.Progress) {
timeLeftEstimator.tick(value = state.progress, total = state.max)
} else {
timeLeftEstimator.emptyTick()
}
}
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L } .throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
.whileActive() .whileActive()
.collect { state -> .collect { state ->
notificationSwitcher.notify(startId, notification.create(state)) val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
} }
job.join() job.join()
(job.progressValue as? DownloadState.Done)?.let { (job.progressValue as? DownloadState.Done)?.let {
sendBroadcast( sendBroadcast(
Intent(ACTION_DOWNLOAD_COMPLETE) Intent(ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters())) .putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
) )
} }
notificationSwitcher.detach( notificationSwitcher.detach(
@ -126,7 +128,7 @@ class DownloadService : BaseService() {
if (job.isCancelled) { if (job.isCancelled) {
null null
} else { } else {
notification.create(job.progressValue) notification.create(job.progressValue, -1L)
} }
) )
stopSelf(startId) stopSelf(startId)
@ -162,11 +164,12 @@ class DownloadService : BaseService() {
companion object { companion object {
const val ACTION_DOWNLOAD_COMPLETE = var isRunning: Boolean = false
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE" private set
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL = private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val EXTRA_MANGA = "manga" private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids" private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
@ -178,7 +181,7 @@ class DownloadService : BaseService() {
} }
confirmDataTransfer(context) { confirmDataTransfer(context) {
val intent = Intent(context, DownloadService::class.java) val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
if (chaptersIds != null) { if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
} }
@ -194,7 +197,7 @@ class DownloadService : BaseService() {
confirmDataTransfer(context) { confirmDataTransfer(context) {
for (item in manga) { for (item in manga) {
val intent = Intent(context, DownloadService::class.java) val intent = Intent(context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(item)) intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} }
} }

@ -24,7 +24,7 @@ class ForegroundNotificationSwitcher(
@Synchronized @Synchronized
fun notify(startId: Int, notification: Notification) { fun notify(startId: Int, notification: Notification) {
if (notifications.isEmpty()) { if (notifications.isEmpty()) {
StartForegroundRunnable(startId, notification) service.startForeground(startId, notification)
} else { } else {
notificationManager.notify(startId, notification) notificationManager.notify(startId, notification)
} }
@ -46,16 +46,6 @@ class ForegroundNotificationSwitcher(
handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY) handler.postDelayed(NotifyRunnable(startId, notification), DEFAULT_DELAY)
} }
private inner class StartForegroundRunnable(
private val startId: Int,
private val notification: Notification,
) : Runnable {
override fun run() {
service.startForeground(startId, notification)
}
}
private inner class NotifyRunnable( private inner class NotifyRunnable(
private val startId: Int, private val startId: Int,
private val notification: Notification?, private val notification: Notification?,

@ -4,19 +4,21 @@ import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditViewModel
import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.select.MangaCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel import org.koitharu.kotatsu.favourites.ui.list.FavouritesListViewModel
val favouritesModule val favouritesModule
get() = module { get() = module {
single { FavouritesRepository(get()) } factory { FavouritesRepository(get(), get()) }
viewModel { categoryId -> viewModel { categoryId ->
FavouritesListViewModel(categoryId.get(), get(), get(), get()) FavouritesListViewModel(categoryId.get(), get(), get(), get())
} }
viewModel { FavouritesCategoriesViewModel(get()) } viewModel { FavouritesCategoriesViewModel(get(), get()) }
viewModel { manga -> viewModel { manga ->
MangaCategoriesViewModel(manga.get(), get()) MangaCategoriesViewModel(manga.get(), get())
} }
viewModel { params -> FavouritesCategoryEditViewModel(params[0], get(), get()) }
} }

@ -11,4 +11,5 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
sortKey = sortKey, sortKey = sortKey,
order = SortOrder(order, SortOrder.NEWEST), order = SortOrder(order, SortOrder.NEWEST),
createdAt = Date(createdAt), createdAt = Date(createdAt),
isTrackingEnabled = track,
) )

@ -6,6 +6,9 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
abstract class FavouriteCategoriesDao { abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories WHERE category_id = :id")
abstract suspend fun find(id: Int): FavouriteCategoryEntity
@Query("SELECT * FROM favourite_categories ORDER BY sort_key") @Query("SELECT * FROM favourite_categories ORDER BY sort_key")
abstract suspend fun findAll(): List<FavouriteCategoryEntity> abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@ -27,9 +30,15 @@ abstract class FavouriteCategoriesDao {
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id") @Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun updateTitle(id: Long, title: String) abstract suspend fun updateTitle(id: Long, title: String)
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean)
@Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id") @Query("UPDATE favourite_categories SET `order` = :order WHERE category_id = :id")
abstract suspend fun updateOrder(id: Long, order: String) abstract suspend fun updateOrder(id: Long, order: String)
@Query("UPDATE favourite_categories SET `track` = :isEnabled WHERE category_id = :id")
abstract suspend fun updateTracking(id: Long, isEnabled: Boolean)
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id") @Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int) abstract suspend fun updateSortKey(id: Long, sortKey: Int)

@ -12,4 +12,5 @@ class FavouriteCategoryEntity(
@ColumnInfo(name = "sort_key") val sortKey: Int, @ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "order") val order: String, @ColumnInfo(name = "order") val order: String,
@ColumnInfo(name = "track") val track: Boolean,
) )

@ -43,6 +43,9 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga> abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE category_id = :categoryId)")
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)") @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
abstract suspend fun findAllManga(): List<MangaEntity> abstract suspend fun findAllManga(): List<MangaEntity>

@ -13,9 +13,13 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
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.work.TrackerNotificationChannels
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
class FavouritesRepository(private val db: MangaDatabase) { class FavouritesRepository(
private val db: MangaDatabase,
private val channels: TrackerNotificationChannels,
) {
suspend fun getAllManga(): List<Manga> { suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll() val entities = db.favouritesDao.findAll()
@ -48,6 +52,11 @@ class FavouritesRepository(private val db: MangaDatabase) {
}.distinctUntilChanged() }.distinctUntilChanged()
} }
fun observeCategory(id: Long): Flow<FavouriteCategory> {
return db.favouriteCategoriesDao.observe(id)
.map { it.toFavouriteCategory() }
}
fun observeCategories(mangaId: Long): Flow<List<FavouriteCategory>> { fun observeCategories(mangaId: Long): Flow<List<FavouriteCategory>> {
return db.favouritesDao.observe(mangaId).map { entity -> return db.favouritesDao.observe(mangaId).map { entity ->
entity?.categories?.map { it.toFavouriteCategory() }.orEmpty() entity?.categories?.map { it.toFavouriteCategory() }.orEmpty()
@ -58,30 +67,62 @@ class FavouritesRepository(private val db: MangaDatabase) {
return db.favouritesDao.observeIds(mangaId).map { it.toSet() } return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
} }
suspend fun getCategory(id: Long): FavouriteCategory {
return db.favouriteCategoriesDao.find(id.toInt()).toFavouriteCategory()
}
suspend fun createCategory(title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean): FavouriteCategory {
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0,
order = sortOrder.name,
track = isTrackerEnabled,
)
val id = db.favouriteCategoriesDao.insert(entity)
val category = entity.toFavouriteCategory(id)
channels.createChannel(category)
return category
}
suspend fun updateCategory(id: Long, title: String, sortOrder: SortOrder, isTrackerEnabled: Boolean) {
db.favouriteCategoriesDao.update(id, title, sortOrder.name, isTrackerEnabled)
}
suspend fun addCategory(title: String): FavouriteCategory { suspend fun addCategory(title: String): FavouriteCategory {
val entity = FavouriteCategoryEntity( val entity = FavouriteCategoryEntity(
title = title, title = title,
createdAt = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(), sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0, categoryId = 0,
order = SortOrder.UPDATED.name, order = SortOrder.NEWEST.name,
track = true,
) )
val id = db.favouriteCategoriesDao.insert(entity) val id = db.favouriteCategoriesDao.insert(entity)
return entity.toFavouriteCategory(id) val category = entity.toFavouriteCategory(id)
channels.createChannel(category)
return category
} }
suspend fun renameCategory(id: Long, title: String) { suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.updateTitle(id, title) db.favouriteCategoriesDao.updateTitle(id, title)
channels.renameChannel(id, title)
} }
suspend fun removeCategory(id: Long) { suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id) db.favouriteCategoriesDao.delete(id)
channels.deleteChannel(id)
} }
suspend fun setCategoryOrder(id: Long, order: SortOrder) { suspend fun setCategoryOrder(id: Long, order: SortOrder) {
db.favouriteCategoriesDao.updateOrder(id, order.name) db.favouriteCategoriesDao.updateOrder(id, order.name)
} }
suspend fun setCategoryTracking(id: Long, isEnabled: Boolean) {
db.favouriteCategoriesDao.updateTracking(id, isEnabled)
}
suspend fun reorderCategories(orderedIds: List<Long>) { suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao val dao = db.favouriteCategoriesDao
db.withTransaction { db.withTransaction {

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.favourites.ui
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.*
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@ -10,22 +11,21 @@ import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import java.util.*
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.util.ActionModeListener import org.koitharu.kotatsu.base.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.main.ui.AppBarOwner import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu import org.koitharu.kotatsu.utils.ext.resolveDp
class FavouritesContainerFragment : class FavouritesContainerFragment :
BaseFragment<FragmentFavouritesBinding>(), BaseFragment<FragmentFavouritesBinding>(),
@ -52,15 +52,15 @@ class FavouritesContainerFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val adapter = FavouritesPagerAdapter(this, this) val adapter = FavouritesPagerAdapter(this, this)
viewModel.categories.value?.let { viewModel.visibleCategories.value?.let {
adapter.replaceData(wrapCategories(it)) adapter.replaceData(it)
} }
binding.pager.adapter = adapter binding.pager.adapter = adapter
pagerAdapter = adapter pagerAdapter = adapter
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
actionModeDelegate.addListener(this, viewLifecycleOwner) actionModeDelegate.addListener(this, viewLifecycleOwner)
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged) viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
} }
@ -85,7 +85,8 @@ class FavouritesContainerFragment :
top = headerHeight - insets.top top = headerHeight - insets.top
) )
binding.pager.updatePadding( binding.pager.updatePadding(
top = -headerHeight // 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active)
top = -headerHeight + resources.resolveDp(8)
) )
binding.tabs.apply { binding.tabs.apply {
updatePadding( updatePadding(
@ -98,8 +99,8 @@ class FavouritesContainerFragment :
} }
} }
private fun onCategoriesChanged(categories: List<FavouriteCategory>) { private fun onCategoriesChanged(categories: List<CategoryListModel>) {
pagerAdapter?.replaceData(wrapCategories(categories)) pagerAdapter?.replaceData(categories)
} }
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
@ -121,23 +122,10 @@ class FavouritesContainerFragment :
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
} }
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean { override fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean {
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category when (item) {
tabView.showPopupMenu(menuRes, { menu -> is CategoryListModel.All -> showAllCategoriesMenu(tabView)
createOrderSubmenu(menu, category) is CategoryListModel.CategoryItem -> showCategoryMenu(tabView, item.category)
}) {
when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_rename -> editDelegate.renameCategory(category)
R.id.action_create -> editDelegate.createCategory()
R.id.action_order -> return@showPopupMenu false
else -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
?: return@showPopupMenu false
viewModel.setCategoryOrder(category.id, order)
}
}
true
} }
return true return true
} }
@ -146,36 +134,38 @@ class FavouritesContainerFragment :
viewModel.deleteCategory(category.id) viewModel.deleteCategory(category.id)
} }
override fun onRenameCategory(category: FavouriteCategory, newName: String) { private fun TabLayout.setTabsEnabled(enabled: Boolean) {
viewModel.renameCategory(category.id, newName) val tabStrip = getChildAt(0) as? ViewGroup ?: return
for (tab in tabStrip.children) {
tab.isEnabled = enabled
} }
override fun onCreateCategory(name: String) {
viewModel.createCategory(name)
} }
private fun wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> { private fun showCategoryMenu(tabView: View, category: FavouriteCategory) {
val data = ArrayList<FavouriteCategory>(categories.size + 1) val menu = PopupMenu(tabView.context, tabView)
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date()) menu.inflate(R.menu.popup_category)
data += categories menu.setOnMenuItemClickListener {
return data when (it.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(category)
R.id.action_edit -> FavouritesCategoryEditActivity.newIntent(tabView.context, category.id)
else -> return@setOnMenuItemClickListener false
} }
true
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
menuItem.isCheckable = true
menuItem.isChecked = item == category.order
} }
submenu.setGroupCheckable(R.id.group_order, true, true) menu.show()
} }
private fun TabLayout.setTabsEnabled(enabled: Boolean) { private fun showAllCategoriesMenu(tabView: View) {
val tabStrip = getChildAt(0) as? ViewGroup ?: return val menu = PopupMenu(tabView.context, tabView)
for (tab in tabStrip.children) { menu.inflate(R.menu.popup_category_all)
tab.isEnabled = enabled menu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_create -> FavouritesCategoryEditActivity.newIntent(requireContext())
R.id.action_hide -> viewModel.setAllCategoriesVisible(false)
}
true
} }
menu.show()
} }
companion object { companion object {

@ -7,14 +7,16 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
class FavouritesPagerAdapter( class FavouritesPagerAdapter(
fragment: Fragment, fragment: Fragment,
private val longClickListener: FavouritesTabLongClickListener private val longClickListener: FavouritesTabLongClickListener
) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle), ) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
TabLayoutMediator.TabConfigurationStrategy, View.OnLongClickListener { TabLayoutMediator.TabConfigurationStrategy,
View.OnLongClickListener {
private val differ = AsyncListDiffer(this, DiffCallback()) private val differ = AsyncListDiffer(this, DiffCallback())
@ -35,12 +37,15 @@ class FavouritesPagerAdapter(
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position] val item = differ.currentList[position]
tab.text = item.title tab.text = when (item) {
is CategoryListModel.All -> tab.view.context.getString(R.string.all_favourites)
is CategoryListModel.CategoryItem -> item.category.title
}
tab.view.tag = item.id tab.view.tag = item.id
tab.view.setOnLongClickListener(this) tab.view.setOnLongClickListener(this)
} }
fun replaceData(data: List<FavouriteCategory>) { fun replaceData(data: List<CategoryListModel>) {
differ.submitList(data) differ.submitList(data)
} }
@ -50,16 +55,22 @@ class FavouritesPagerAdapter(
return longClickListener.onTabLongClick(v, item) return longClickListener.onTabLongClick(v, item)
} }
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() { private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
override fun areItemsTheSame( override fun areItemsTheSame(
oldItem: FavouriteCategory, oldItem: CategoryListModel,
newItem: FavouriteCategory newItem: CategoryListModel
): Boolean = oldItem.id == newItem.id ): Boolean = when {
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> true
oldItem is CategoryListModel.CategoryItem && newItem is CategoryListModel.CategoryItem -> {
oldItem.category.id == newItem.category.id
}
else -> false
}
override fun areContentsTheSame( override fun areContentsTheSame(
oldItem: FavouriteCategory, oldItem: CategoryListModel,
newItem: FavouriteCategory newItem: CategoryListModel
): Boolean = oldItem.id == newItem.id && oldItem.title == newItem.title ): Boolean = oldItem == newItem
} }
} }

@ -1,9 +1,9 @@
package org.koitharu.kotatsu.favourites.ui package org.koitharu.kotatsu.favourites.ui
import android.view.View import android.view.View
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
fun interface FavouritesTabLongClickListener { fun interface FavouritesTabLongClickListener {
fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean
} }

@ -0,0 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.categories
interface AllCategoriesToggleListener {
fun onAllCategoriesToggle(isVisible: Boolean)
}

@ -3,9 +3,9 @@ package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@ -18,16 +18,19 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.showPopupMenu
class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(), class CategoriesActivity :
BaseActivity<ActivityCategoriesBinding>(),
OnListItemClickListener<FavouriteCategory>, OnListItemClickListener<FavouriteCategory>,
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback { View.OnClickListener,
CategoriesEditDelegate.CategoriesEditCallback,
AllCategoriesToggleListener {
private val viewModel by viewModel<FavouritesCategoriesViewModel>() private val viewModel by viewModel<FavouritesCategoriesViewModel>()
@ -39,7 +42,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
adapter = CategoriesAdapter(this) adapter = CategoriesAdapter(this, this)
editDelegate = CategoriesEditDelegate(this, this) editDelegate = CategoriesEditDelegate(this, this)
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
@ -47,40 +50,39 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
reorderHelper = ItemTouchHelper(ReorderHelperCallback()) reorderHelper = ItemTouchHelper(ReorderHelperCallback())
reorderHelper.attachToRecyclerView(binding.recyclerView) reorderHelper.attachToRecyclerView(binding.recyclerView)
viewModel.categories.observe(this, ::onCategoriesChanged) viewModel.allCategories.observe(this, ::onCategoriesChanged)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.fab_add -> editDelegate.createCategory() R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this))
} }
} }
override fun onItemClick(item: FavouriteCategory, view: View) { override fun onItemClick(item: FavouriteCategory, view: View) {
view.showPopupMenu(R.menu.popup_category, { menu -> val menu = PopupMenu(view.context, view)
createOrderSubmenu(menu, item) menu.inflate(R.menu.popup_category)
}) { menu.setOnMenuItemClickListener { menuItem ->
when (it.itemId) { when (menuItem.itemId) {
R.id.action_remove -> editDelegate.deleteCategory(item) R.id.action_remove -> editDelegate.deleteCategory(item)
R.id.action_rename -> editDelegate.renameCategory(item) R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(this, item.id))
R.id.action_order -> return@showPopupMenu false
else -> {
val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false
viewModel.setCategoryOrder(item.id, order)
}
} }
true true
} }
menu.show()
} }
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean { override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
reorderHelper.startDrag( val viewHolder = binding.recyclerView.findContainingViewHolder(view) ?: return false
binding.recyclerView.findContainingViewHolder(view) ?: return false reorderHelper.startDrag(viewHolder)
)
return true return true
} }
override fun onAllCategoriesToggle(isVisible: Boolean) {
viewModel.setAllCategoriesVisible(isVisible)
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> { binding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
rightMargin = topMargin + insets.right rightMargin = topMargin + insets.right
@ -90,11 +92,11 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
left = insets.left, left = insets.left,
right = insets.right, right = insets.right,
bottom = 2 * insets.bottom + binding.fabAdd.measureHeight() bottom = 2 * insets.bottom + binding.fabAdd.measureHeight(),
) )
} }
private fun onCategoriesChanged(categories: List<FavouriteCategory>) { private fun onCategoriesChanged(categories: List<CategoryListModel>) {
adapter.items = categories adapter.items = categories
binding.textViewHolder.isVisible = categories.isEmpty() binding.textViewHolder.isVisible = categories.isEmpty()
} }
@ -108,40 +110,23 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
viewModel.deleteCategory(category.id) viewModel.deleteCategory(category.id)
} }
override fun onRenameCategory(category: FavouriteCategory, newName: String) {
viewModel.renameCategory(category.id, newName)
}
override fun onCreateCategory(name: String) {
viewModel.createCategory(name)
}
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
for ((i, item) in SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(
R.id.group_order,
Menu.NONE,
i,
item.titleRes
)
menuItem.isCheckable = true
menuItem.isChecked = item == category.order
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback( private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0 ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
) { ) {
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun onMove( override fun onMove(
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
): Boolean = true ): Boolean = viewHolder.itemViewType == target.itemViewType
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit override fun canDropOver(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType
override fun onMoved( override fun onMoved(
recyclerView: RecyclerView, recyclerView: RecyclerView,
@ -155,6 +140,8 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderCategories(fromPos, toPos) viewModel.reorderCategories(fromPos, toPos)
} }
override fun isLongPressDragEnabled(): Boolean = false
} }
companion object { companion object {

@ -4,13 +4,18 @@ import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.allCategoriesAD
import org.koitharu.kotatsu.favourites.ui.categories.adapter.categoryAD
class CategoriesAdapter( class CategoriesAdapter(
onItemClickListener: OnListItemClickListener<FavouriteCategory>, onItemClickListener: OnListItemClickListener<FavouriteCategory>,
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) { allCategoriesToggleListener: AllCategoriesToggleListener,
) : AsyncListDifferDelegationAdapter<CategoryListModel>(DiffCallback()) {
init { init {
delegatesManager.addDelegate(categoryAD(onItemClickListener)) delegatesManager.addDelegate(categoryAD(onItemClickListener))
.addDelegate(allCategoriesAD(allCategoriesToggleListener))
setHasStableIds(true) setHasStableIds(true)
} }
@ -18,29 +23,27 @@ class CategoriesAdapter(
return items[position].id return items[position].id
} }
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() { private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
override fun areItemsTheSame( override fun areItemsTheSame(
oldItem: FavouriteCategory, oldItem: CategoryListModel,
newItem: FavouriteCategory, newItem: CategoryListModel,
): Boolean { ): Boolean = oldItem.id == newItem.id
return oldItem.id == newItem.id
}
override fun areContentsTheSame( override fun areContentsTheSame(
oldItem: FavouriteCategory, oldItem: CategoryListModel,
newItem: FavouriteCategory, newItem: CategoryListModel,
): Boolean { ): Boolean = oldItem == newItem
return oldItem.id == newItem.id && oldItem.title == newItem.title
&& oldItem.order == newItem.order
}
override fun getChangePayload( override fun getChangePayload(
oldItem: FavouriteCategory, oldItem: CategoryListModel,
newItem: FavouriteCategory, newItem: CategoryListModel,
): Any? = when { ): Any? = when {
oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit
else -> super.getChangePayload(oldItem, newItem) oldItem is CategoryListModel.CategoryItem &&
newItem is CategoryListModel.CategoryItem &&
oldItem.category.title != newItem.category.title -> null
else -> Unit
} }
} }
} }

@ -1,15 +1,10 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories
import android.content.Context import android.content.Context
import android.text.InputType
import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
private const val MAX_TITLE_LENGTH = 24
class CategoriesEditDelegate( class CategoriesEditDelegate(
private val context: Context, private val context: Context,
private val callback: CategoriesEditCallback private val callback: CategoriesEditCallback
@ -26,49 +21,8 @@ class CategoriesEditDelegate(
.show() .show()
} }
fun renameCategory(category: FavouriteCategory) {
TextInputDialog.Builder(context)
.setTitle(R.string.rename)
.setText(category.title)
.setHint(R.string.enter_category_name)
.setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
.setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.rename) { _, name ->
val trimmed = name.trim()
if (trimmed.isEmpty()) {
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
} else {
callback.onRenameCategory(category, name)
}
}.create()
.show()
}
fun createCategory() {
TextInputDialog.Builder(context)
.setTitle(R.string.add_new_category)
.setHint(R.string.enter_category_name)
.setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES)
.setNegativeButton(android.R.string.cancel)
.setMaxLength(MAX_TITLE_LENGTH, false)
.setPositiveButton(R.string.add) { _, name ->
val trimmed = name.trim()
if (trimmed.isEmpty()) {
Toast.makeText(context, R.string.error_empty_name, Toast.LENGTH_SHORT).show()
} else {
callback.onCreateCategory(trimmed)
}
}.create()
.show()
}
interface CategoriesEditCallback { interface CategoriesEditCallback {
fun onDeleteCategory(category: FavouriteCategory) fun onDeleteCategory(category: FavouriteCategory)
fun onRenameCategory(category: FavouriteCategory, newName: String)
fun onCreateCategory(name: String)
} }
} }

@ -3,32 +3,35 @@ package org.koitharu.kotatsu.favourites.ui.categories
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.ui.BaseViewModel 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.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import java.util.* import java.util.*
class FavouritesCategoriesViewModel( class FavouritesCategoriesViewModel(
private val repository: FavouritesRepository private val repository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
private var reorderJob: Job? = null private var reorderJob: Job? = null
val categories = repository.observeCategories() val allCategories = combine(
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default) repository.observeCategories(),
observeAllCategoriesVisible(),
) { list, showAll ->
mapCategories(list, showAll, true)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
fun createCategory(name: String) { val visibleCategories = combine(
launchJob { repository.observeCategories(),
repository.addCategory(name) observeAllCategoriesVisible(),
} ) { list, showAll ->
} mapCategories(list, showAll, showAll)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
fun renameCategory(id: Long, name: String) {
launchJob {
repository.renameCategory(id, name)
}
}
fun deleteCategory(id: Long) { fun deleteCategory(id: Long) {
launchJob { launchJob {
@ -36,20 +39,40 @@ class FavouritesCategoriesViewModel(
} }
} }
fun setCategoryOrder(id: Long, order: SortOrder) { fun setAllCategoriesVisible(isVisible: Boolean) {
launchJob { settings.isAllFavouritesVisible = isVisible
repository.setCategoryOrder(id, order)
}
} }
fun reorderCategories(oldPos: Int, newPos: Int) { fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) { reorderJob = launchJob(Dispatchers.Default) {
prevJob?.join() prevJob?.join()
val items = categories.value ?: error("This should not happen") val items = allCategories.value ?: error("This should not happen")
val ids = items.mapTo(ArrayList(items.size)) { it.id } val ids = items.mapTo(ArrayList(items.size)) { it.id }
Collections.swap(ids, oldPos, newPos) Collections.swap(ids, oldPos, newPos)
ids.remove(0L)
repository.reorderCategories(ids) repository.reorderCategories(ids)
} }
} }
private fun mapCategories(
categories: List<FavouriteCategory>,
isAllCategoriesVisible: Boolean,
withAllCategoriesItem: Boolean,
): List<CategoryListModel> {
val result = ArrayList<CategoryListModel>(categories.size + 1)
if (withAllCategoriesItem) {
result.add(CategoryListModel.All(isAllCategoriesVisible))
}
categories.mapTo(result) {
CategoryListModel.CategoryItem(it)
}
return result
}
private fun observeAllCategoriesVisible() = settings.observe()
.filter { it == AppSettings.KEY_ALL_FAVOURITES_VISIBLE }
.map { settings.isAllFavouritesVisible }
.onStart { emit(settings.isAllFavouritesVisible) }
.distinctUntilChanged()
} }

@ -0,0 +1,20 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
import org.koitharu.kotatsu.favourites.ui.categories.AllCategoriesToggleListener
fun allCategoriesAD(
allCategoriesToggleListener: AllCategoriesToggleListener,
) = adapterDelegateViewBinding<CategoryListModel.All, CategoryListModel, ItemCategoriesAllBinding>(
{ inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) }
) {
binding.imageViewToggle.setOnClickListener {
allCategoriesToggleListener.onAllCategoriesToggle(!item.isVisible)
}
bind {
binding.imageViewToggle.isChecked = item.isVisible
}
}

@ -1,4 +1,4 @@
package org.koitharu.kotatsu.favourites.ui.categories package org.koitharu.kotatsu.favourites.ui.categories.adapter
import android.view.MotionEvent import android.view.MotionEvent
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -8,23 +8,23 @@ import org.koitharu.kotatsu.databinding.ItemCategoryBinding
fun categoryAD( fun categoryAD(
clickListener: OnListItemClickListener<FavouriteCategory> clickListener: OnListItemClickListener<FavouriteCategory>
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryBinding>( ) = adapterDelegateViewBinding<CategoryListModel.CategoryItem, CategoryListModel, ItemCategoryBinding>(
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }
) { ) {
binding.imageViewMore.setOnClickListener { binding.imageViewMore.setOnClickListener {
clickListener.onItemClick(item, it) clickListener.onItemClick(item.category, it)
} }
@Suppress("ClickableViewAccessibility") @Suppress("ClickableViewAccessibility")
binding.imageViewHandle.setOnTouchListener { v, event -> binding.imageViewHandle.setOnTouchListener { v, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) { if (event.actionMasked == MotionEvent.ACTION_DOWN) {
clickListener.onItemLongClick(item, itemView) clickListener.onItemLongClick(item.category, itemView)
} else { } else {
false false
} }
} }
bind { bind {
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.category.title
} }
} }

@ -0,0 +1,61 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.list.ui.model.ListModel
sealed interface CategoryListModel : ListModel {
val id: Long
class All(
val isVisible: Boolean,
) : CategoryListModel {
override val id: Long = 0L
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as All
if (isVisible != other.isVisible) return false
return true
}
override fun hashCode(): Int {
return isVisible.hashCode()
}
}
class CategoryItem(
val category: FavouriteCategory,
) : CategoryListModel {
override val id: Long
get() = category.id
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as CategoryItem
if (category.id != other.category.id) return false
if (category.title != other.category.title) return false
if (category.order != other.category.order) return false
if (category.isTrackingEnabled != other.category.isTrackingEnabled) return false
return true
}
override fun hashCode(): Int {
var result = category.id.hashCode()
result = 31 * result + category.title.hashCode()
result = 31 * result + category.order.hashCode()
result = 31 * result + category.isTrackingEnabled.hashCode()
return result
}
}
}

@ -0,0 +1,147 @@
package org.koitharu.kotatsu.favourites.ui.categories.edit
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class FavouritesCategoryEditActivity : BaseActivity<ActivityCategoryEditBinding>(), AdapterView.OnItemClickListener {
private val viewModel by viewModel<FavouritesCategoryEditViewModel> {
parametersOf(intent.getLongExtra(EXTRA_ID, NO_ID))
}
private var selectedSortOrder: SortOrder? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityCategoryEditBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
}
initSortSpinner()
viewModel.onSaved.observe(this) { finishAfterTransition() }
viewModel.category.observe(this, ::onCategoryChanged)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.onError.observe(this, ::onError)
viewModel.isTrackerEnabled.observe(this) {
binding.switchTracker.isVisible = it
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putSerializable(KEY_SORT_ORDER, selectedSortOrder)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
val order = savedInstanceState.getSerializable(KEY_SORT_ORDER)
if (order != null && order is SortOrder) {
selectedSortOrder = order
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_config, menu)
menu.findItem(R.id.action_done)?.setTitle(R.string.save)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.action_done -> {
viewModel.save(
title = binding.editName.text?.toString().orEmpty(),
sortOrder = getSelectedSortOrder(),
isTrackerEnabled = binding.switchTracker.isChecked,
)
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.scrollView.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
binding.toolbar.updatePadding(
top = insets.top,
)
}
override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedSortOrder = CategoriesActivity.SORT_ORDERS.getOrNull(position)
}
private fun onCategoryChanged(category: FavouriteCategory?) {
setTitle(if (category == null) R.string.create_category else R.string.edit_category)
if (selectedSortOrder != null) {
return
}
binding.editName.setText(category?.title)
selectedSortOrder = category?.order
val sortText = getString((category?.order ?: SortOrder.NEWEST).titleRes)
binding.editSort.setText(sortText, false)
binding.switchTracker.isChecked = category?.isTrackingEnabled ?: true
}
private fun onError(e: Throwable) {
binding.textViewError.text = e.getDisplayMessage(resources)
binding.textViewError.isVisible = true
}
private fun onLoadingStateChanged(isLoading: Boolean) {
binding.editSort.isEnabled = !isLoading
binding.editName.isEnabled = !isLoading
binding.switchTracker.isEnabled = !isLoading
if (isLoading) {
binding.textViewError.isVisible = false
}
}
private fun initSortSpinner() {
val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, entries)
binding.editSort.setAdapter(adapter)
binding.editSort.onItemClickListener = this
}
private fun getSelectedSortOrder(): SortOrder {
selectedSortOrder?.let { return it }
val entries = CategoriesActivity.SORT_ORDERS.map { getString(it.titleRes) }
val index = entries.indexOf(binding.editSort.text.toString())
return CategoriesActivity.SORT_ORDERS.getOrNull(index) ?: SortOrder.NEWEST
}
companion object {
private const val EXTRA_ID = "id"
private const val KEY_SORT_ORDER = "sort"
private const val NO_ID = -1L
fun newIntent(context: Context, id: Long = NO_ID): Intent {
return Intent(context, FavouritesCategoryEditActivity::class.java)
.putExtra(EXTRA_ID, id)
}
}
}

@ -0,0 +1,53 @@
package org.koitharu.kotatsu.favourites.ui.categories.edit
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
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.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.SingleLiveEvent
private const val NO_ID = -1L
class FavouritesCategoryEditViewModel(
private val categoryId: Long,
private val repository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val onSaved = SingleLiveEvent<Unit>()
val category = MutableLiveData<FavouriteCategory?>()
val isTrackerEnabled = liveData(viewModelScope.coroutineContext + Dispatchers.Default) {
emit(settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources)
}
init {
launchLoadingJob {
category.value = if (categoryId != NO_ID) {
repository.getCategory(categoryId)
} else {
null
}
}
}
fun save(
title: String,
sortOrder: SortOrder,
isTrackerEnabled: Boolean,
) {
launchLoadingJob {
if (categoryId == NO_ID) {
repository.createCategory(title, sortOrder, isTrackerEnabled)
} else {
repository.updateCategory(categoryId, title, sortOrder, isTrackerEnabled)
}
onSaved.call(Unit)
}
}
}

@ -15,9 +15,9 @@ import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.withoutChapters
import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding import org.koitharu.kotatsu.databinding.DialogFavoriteCategoriesBinding
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@ -35,9 +35,6 @@ class FavouriteCategoriesDialog :
} }
private var adapter: MangaCategoriesAdapter? = null private var adapter: MangaCategoriesAdapter? = null
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
CategoriesEditDelegate(requireContext(), this@FavouriteCategoriesDialog)
}
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -62,7 +59,7 @@ class FavouriteCategoriesDialog :
override fun onMenuItemClick(item: MenuItem): Boolean { override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_create -> { R.id.action_create -> {
editDelegate.createCategory() FavouritesCategoryEditActivity.newIntent(requireContext())
true true
} }
else -> false else -> false
@ -75,12 +72,6 @@ class FavouriteCategoriesDialog :
override fun onDeleteCategory(category: FavouriteCategory) = Unit override fun onDeleteCategory(category: FavouriteCategory) = Unit
override fun onRenameCategory(category: FavouriteCategory, newName: String) = Unit
override fun onCreateCategory(name: String) {
viewModel.createCategory(name)
}
private fun onContentChanged(categories: List<MangaCategoryItem>) { private fun onContentChanged(categories: List<MangaCategoryItem>) {
adapter?.items = categories adapter?.items = categories
} }
@ -99,7 +90,7 @@ class FavouriteCategoriesDialog :
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) { fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
putParcelableArrayList( putParcelableArrayList(
KEY_MANGA_LIST, KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it.withoutChapters()) } manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }
) )
}.show(fm, TAG) }.show(fm, TAG)
} }

@ -38,12 +38,6 @@ class MangaCategoriesViewModel(
} }
} }
fun createCategory(name: String) {
launchJob(Dispatchers.Default) {
favouritesRepository.addCategory(name)
}
}
private fun observeCategoriesIds() = if (manga.size == 1) { private fun observeCategoriesIds() = if (manga.size == 1) {
// Fast path // Fast path
favouritesRepository.observeCategoriesIds(manga[0].id) favouritesRepository.observeCategoriesIds(manga[0].id)

@ -1,12 +1,19 @@
package org.koitharu.kotatsu.favourites.ui.list package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.core.view.iterator
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
class FavouritesListFragment : MangaListFragment() { class FavouritesListFragment : MangaListFragment() {
@ -16,17 +23,66 @@ class FavouritesListFragment : MangaListFragment() {
} }
private val categoryId: Long private val categoryId: Long
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: 0L get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID
override val isSwipeRefreshEnabled = false override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() }
}
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = Unit
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
if (categoryId != NO_ID) {
inflater.inflate(R.menu.opt_favourites_list, menu)
menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
menuItem.isCheckable = true
}
submenu.setGroupCheckable(R.id.group_order, true, true)
}
}
}
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_order)?.subMenu?.let { submenu ->
val selectedOrder = viewModel.sortOrder.value
for (item in submenu) {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order)
item.isChecked = order == selectedOrder
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when {
item.itemId == R.id.action_order -> false
item.groupId == R.id.group_order -> {
val order = CategoriesActivity.SORT_ORDERS.getOrNull(item.order) ?: return false
viewModel.setSortOrder(order)
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_favourites, menu) mode.menuInflater.inflate(R.menu.mode_favourites, menu)
return super.onCreateActionMode(mode, menu) return super.onCreateActionMode(mode, menu)
} }
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_remove -> { R.id.action_remove -> {
@ -40,6 +96,7 @@ class FavouritesListFragment : MangaListFragment() {
companion object { companion object {
const val NO_ID = 0L
private const val ARG_CATEGORY_ID = "category_id" private const val ARG_CATEGORY_ID = "category_id"
fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) { fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) {

@ -1,12 +1,16 @@
package org.koitharu.kotatsu.favourites.ui.list package org.koitharu.kotatsu.favourites.ui.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.list.domain.CountersProvider import org.koitharu.kotatsu.list.domain.CountersProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@ -24,8 +28,16 @@ class FavouritesListViewModel(
settings: AppSettings, settings: AppSettings,
) : MangaListViewModel(settings), CountersProvider { ) : MangaListViewModel(settings), CountersProvider {
var sortOrder: LiveData<SortOrder> = if (categoryId == NO_ID) {
MutableLiveData(null)
} else {
repository.observeCategory(categoryId)
.map { it.order }
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
}
override val content = combine( override val content = combine(
if (categoryId == 0L) { if (categoryId == NO_ID) {
repository.observeAll(SortOrder.NEWEST) repository.observeAll(SortOrder.NEWEST)
} else { } else {
repository.observeAll(categoryId) repository.observeAll(categoryId)
@ -37,7 +49,7 @@ class FavouritesListViewModel(
EmptyState( EmptyState(
icon = R.drawable.ic_heart_outline, icon = R.drawable.ic_heart_outline,
textPrimary = R.string.text_empty_holder_primary, textPrimary = R.string.text_empty_holder_primary,
textSecondary = if (categoryId == 0L) { textSecondary = if (categoryId == NO_ID) {
R.string.you_have_not_favourites_yet R.string.you_have_not_favourites_yet
} else { } else {
R.string.favourites_category_empty R.string.favourites_category_empty
@ -60,7 +72,7 @@ class FavouritesListViewModel(
return return
} }
launchJob { launchJob {
if (categoryId == 0L) { if (categoryId == NO_ID) {
repository.removeFromFavourites(ids) repository.removeFromFavourites(ids)
} else { } else {
repository.removeFromCategory(categoryId, ids) repository.removeFromCategory(categoryId, ids)
@ -68,6 +80,15 @@ class FavouritesListViewModel(
} }
} }
fun setSortOrder(order: SortOrder) {
if (categoryId == NO_ID) {
return
}
launchJob {
repository.setCategoryOrder(categoryId, order)
}
}
override suspend fun getCounter(mangaId: Long): Int { override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId) return trackingRepository.getNewChaptersCount(mangaId)
} }

@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
val historyModule val historyModule
get() = module { get() = module {
single { HistoryRepository(get(), get(), get()) } factory { HistoryRepository(get(), get(), get()) }
viewModel { HistoryListViewModel(get(), get(), get(), get()) } viewModel { HistoryListViewModel(get(), get(), get(), get()) }
} }

@ -10,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
class HistoryListFragment : MangaListFragment() { class HistoryListFragment : MangaListFragment() {
@ -61,6 +62,13 @@ class HistoryListFragment : MangaListFragment() {
return super.onCreateActionMode(mode, menu) return super.onCreateActionMode(mode, menu)
} }
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none {
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(mode, menu)
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.action_remove -> { R.id.action_remove -> {

@ -6,7 +6,6 @@ import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
@ -14,7 +13,7 @@ import androidx.core.view.updatePadding
import coil.ImageLoader import coil.ImageLoader
import coil.request.CachePolicy import coil.request.CachePolicy
import coil.request.ImageRequest import coil.request.ImageRequest
import coil.target.PoolableViewTarget import coil.target.ViewTarget
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@ -61,16 +60,12 @@ class ImageActivity : BaseActivity<ActivityImageBinding>() {
private class SsivTarget( private class SsivTarget(
override val view: SubsamplingScaleImageView, override val view: SubsamplingScaleImageView,
) : PoolableViewTarget<SubsamplingScaleImageView> { ) : ViewTarget<SubsamplingScaleImageView> {
override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
override fun onError(error: Drawable?) = setDrawable(error) override fun onError(error: Drawable?) = setDrawable(error)
override fun onSuccess(result: Drawable) = setDrawable(result) override fun onSuccess(result: Drawable) = setDrawable(result)
override fun onClear() = setDrawable(null)
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
return (this === other) || (other is SsivTarget && view == other.view) return (this === other) || (other is SsivTarget && view == other.view)
} }

@ -43,7 +43,7 @@ class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(),
binding.textViewGridTitle.isVisible = mode == ListMode.GRID binding.textViewGridTitle.isVisible = mode == ListMode.GRID
binding.sliderGrid.isVisible = mode == ListMode.GRID binding.sliderGrid.isVisible = mode == ListMode.GRID
binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter()) binding.sliderGrid.setLabelFormatter(IntPercentLabelFormatter(view.context))
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat()) binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
binding.sliderGrid.addOnSliderTouchListener(this) binding.sliderGrid.addOnSliderTouchListener(this)

@ -20,6 +20,7 @@ import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@ -243,8 +244,11 @@ abstract class MangaListFragment :
ListMode.LIST -> { ListMode.LIST -> {
layoutManager = FitHeightLinearLayoutManager(context) layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
addItemDecoration(SpacingItemDecoration(spacing)) val decoration = TypedSpacingItemDecoration(
updatePadding(left = spacing, right = spacing) MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0,
fallbackSpacing = spacing
)
addItemDecoration(decoration)
} }
ListMode.DETAILED_LIST -> { ListMode.DETAILED_LIST -> {
layoutManager = FitHeightLinearLayoutManager(context) layoutManager = FitHeightLinearLayoutManager(context)

@ -28,6 +28,7 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f), ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74 0x74
) )
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init { init {
hasBackground = false hasBackground = false
@ -40,7 +41,7 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
override fun getItemId(parent: RecyclerView, child: View): Long { override fun getItemId(parent: RecyclerView, child: View): Long {
val holder = parent.getChildViewHolder(child) ?: return NO_ID val holder = parent.getChildViewHolder(child) ?: return NO_ID
val item = holder.getItem<MangaItemModel>() ?: return NO_ID val item = holder.getItem(MangaItemModel::class.java) ?: return NO_ID
return item.id return item.id
} }
@ -51,13 +52,15 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
bounds: RectF, bounds: RectF,
state: RecyclerView.State, state: RecyclerView.State,
) { ) {
val radius = (child as? CardView)?.radius ?: 0f val isCard = child is CardView
val radius = (child as? CardView)?.radius ?: defaultRadius
paint.color = fillColor paint.color = fillColor
paint.style = Paint.Style.FILL paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint) canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor paint.color = strokeColor
paint.style = Paint.Style.STROKE paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint) canvas.drawRoundRect(bounds, radius, radius, paint)
if (isCard) {
checkIcon?.run { checkIcon?.run {
setBounds( setBounds(
(bounds.left + iconOffset).toInt(), (bounds.left + iconOffset).toInt(),
@ -68,4 +71,5 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
draw(canvas) draw(canvas)
} }
} }
}
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -43,6 +44,7 @@ fun mangaGridItemAD(
.fallback(R.drawable.ic_placeholder) .fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder)
.allowRgb565(true) .allowRgb565(true)
.scale(Scale.FILL)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.enqueueWith(coil) .enqueueWith(coil)
badge = itemView.bindBadge(badge, item.counter) badge = itemView.bindBadge(badge, item.counter)
@ -53,7 +55,7 @@ fun mangaGridItemAD(
badge = null badge = null
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = null imageRequest = null
CoilUtils.clear(binding.imageViewCover) CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null) binding.imageViewCover.setImageDrawable(null)
} }
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -44,6 +45,7 @@ fun mangaListDetailedItemAD(
.placeholder(R.drawable.ic_placeholder) .placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) .fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.allowRgb565(true) .allowRgb565(true)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.enqueueWith(coil) .enqueueWith(coil)
@ -57,7 +59,7 @@ fun mangaListDetailedItemAD(
badge = null badge = null
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = null imageRequest = null
CoilUtils.clear(binding.imageViewCover) CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null) binding.imageViewCover.setImageDrawable(null)
} }
} }

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import coil.request.Disposable import coil.request.Disposable
import coil.size.Scale
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@ -44,6 +45,7 @@ fun mangaListItemAD(
.placeholder(R.drawable.ic_placeholder) .placeholder(R.drawable.ic_placeholder)
.fallback(R.drawable.ic_placeholder) .fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder) .error(R.drawable.ic_placeholder)
.scale(Scale.FILL)
.allowRgb565(true) .allowRgb565(true)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)
.enqueueWith(coil) .enqueueWith(coil)
@ -55,7 +57,7 @@ fun mangaListItemAD(
badge = null badge = null
imageRequest?.dispose() imageRequest?.dispose()
imageRequest = null imageRequest = null
CoilUtils.clear(binding.imageViewCover) CoilUtils.dispose(binding.imageViewCover)
binding.imageViewCover.setImageDrawable(null) binding.imageViewCover.setImageDrawable(null)
} }
} }

@ -3,18 +3,18 @@ package org.koitharu.kotatsu.local
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
import org.koitharu.kotatsu.download.domain.DownloadManager
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.local.ui.LocalListViewModel import org.koitharu.kotatsu.local.ui.LocalListViewModel
import org.koitharu.kotatsu.utils.ExternalStorageHelper
val localModule val localModule
get() = module { get() = module {
single { LocalStorageManager(androidContext(), get()) } factory { LocalStorageManager(androidContext(), get()) }
single { LocalMangaRepository(get()) } single { LocalMangaRepository(get()) }
factory { ExternalStorageHelper(androidContext()) } factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) }
viewModel { LocalListViewModel(get(), get(), get(), get()) } viewModel { LocalListViewModel(get(), get(), get(), get()) }
} }

@ -2,41 +2,52 @@ package org.koitharu.kotatsu.local.data
import android.net.Uri import android.net.Uri
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import coil.bitmap.BitmapPool import coil.ImageLoader
import coil.decode.DataSource import coil.decode.DataSource
import coil.decode.Options import coil.decode.ImageSource
import coil.fetch.FetchResult
import coil.fetch.Fetcher import coil.fetch.Fetcher
import coil.fetch.SourceResult import coil.fetch.SourceResult
import coil.size.Size import coil.request.Options
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.buffer import okio.buffer
import okio.source import okio.source
import java.util.zip.ZipFile import java.util.zip.ZipFile
class CbzFetcher : Fetcher<Uri> { class CbzFetcher(
private val uri: Uri,
private val options: Options
) : Fetcher {
override suspend fun fetch( override suspend fun fetch() = runInterruptible(Dispatchers.IO) {
pool: BitmapPool, val zip = ZipFile(uri.schemeSpecificPart)
data: Uri, val entry = zip.getEntry(uri.fragment)
size: Size,
options: Options,
): FetchResult = runInterruptible(Dispatchers.IO) {
val zip = ZipFile(data.schemeSpecificPart)
val entry = zip.getEntry(data.fragment)
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name) val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
SourceResult( val bufferedSource = ExtraCloseableBufferedSource(
source = ExtraCloseableBufferedSource(
zip.getInputStream(entry).source().buffer(), zip.getInputStream(entry).source().buffer(),
zip, zip,
)
SourceResult(
source = ImageSource(
source = bufferedSource,
context = options.context,
metadata = CbzMetadata(uri),
), ),
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext), mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext),
dataSource = DataSource.DISK dataSource = DataSource.DISK,
) )
} }
override fun key(data: Uri) = data.toString() class Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
return if (data.scheme == "cbz") {
CbzFetcher(data, options)
} else {
null
}
}
}
override fun handles(data: Uri) = data.scheme == "cbz" class CbzMetadata(val uri: Uri) : ImageSource.Metadata()
} }

@ -7,6 +7,10 @@ import java.util.*
class CbzFilter : FilenameFilter { class CbzFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean { override fun accept(dir: File, name: String): Boolean {
return isFileSupported(name)
}
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip" return ext == "cbz" || ext == "zip"
} }

@ -4,6 +4,7 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.os.StatFs import android.os.StatFs
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import java.io.File
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 +12,6 @@ import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.utils.ext.computeSize import org.koitharu.kotatsu.utils.ext.computeSize
import org.koitharu.kotatsu.utils.ext.getStorageName import org.koitharu.kotatsu.utils.ext.getStorageName
import java.io.File
private const val DIR_NAME = "manga" private const val DIR_NAME = "manga"
private const val CACHE_DISK_PERCENTAGE = 0.02 private const val CACHE_DISK_PERCENTAGE = 0.02
@ -71,7 +71,7 @@ class LocalStorageManager(
private fun getAvailableStorageDirs(): MutableSet<File> { private fun getAvailableStorageDirs(): MutableSet<File> {
val result = LinkedHashSet<File>() val result = LinkedHashSet<File>()
result += File(context.filesDir, DIR_NAME) result += File(context.filesDir, DIR_NAME)
result += context.getExternalFilesDirs(DIR_NAME) context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result)
result.retainAll { it.exists() || it.mkdirs() } result.retainAll { it.exists() || it.mkdirs() }
return result return result
} }
@ -87,8 +87,8 @@ class LocalStorageManager(
private fun getCacheDirs(subDir: String): MutableSet<File> { private fun getCacheDirs(subDir: String): MutableSet<File> {
val result = LinkedHashSet<File>() val result = LinkedHashSet<File>()
result += File(context.cacheDir, subDir) result += File(context.cacheDir, subDir)
context.externalCacheDirs.mapTo(result) { context.externalCacheDirs.mapNotNullTo(result) {
File(it, subDir) File(it ?: return@mapNotNullTo null, subDir)
} }
return result return result
} }

@ -28,14 +28,17 @@ class MangaIndex(source: String?) {
json.put("state", manga.state?.name) json.put("state", manga.state?.name)
json.put("source", manga.source.name) json.put("source", manga.source.name)
json.put("cover_large", manga.largeCoverUrl) json.put("cover_large", manga.largeCoverUrl)
json.put("tags", JSONArray().also { a -> json.put(
"tags",
JSONArray().also { a ->
for (tag in manga.tags) { for (tag in manga.tags) {
val jo = JSONObject() val jo = JSONObject()
jo.put("key", tag.key) jo.put("key", tag.key)
jo.put("title", tag.title) jo.put("title", tag.title)
a.put(jo) a.put(jo)
} }
}) }
)
if (!append || !json.has("chapters")) { if (!append || !json.has("chapters")) {
json.put("chapters", JSONObject()) json.put("chapters", JSONObject())
} }
@ -84,11 +87,15 @@ class MangaIndex(source: String?) {
jo.put("uploadDate", chapter.uploadDate) jo.put("uploadDate", chapter.uploadDate)
jo.put("scanlator", chapter.scanlator) jo.put("scanlator", chapter.scanlator)
jo.put("branch", chapter.branch) jo.put("branch", chapter.branch)
jo.put("entries", "%03d\\d{3}".format(chapter.number)) jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number))
chapters.put(chapter.id.toString(), jo) chapters.put(chapter.id.toString(), jo)
} }
} }
fun removeChapter(id: Long): Boolean {
return json.getJSONObject("chapters").remove(id.toString()) != null
}
fun setCoverEntry(name: String) { fun setCoverEntry(name: String) {
json.put("cover_entry", name) json.put("cover_entry", name)
} }

@ -1,70 +0,0 @@
package org.koitharu.kotatsu.local.data
import androidx.annotation.CheckResult
import androidx.annotation.WorkerThread
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
@WorkerThread
class MangaZip(val file: File) {
private val writableCbz = WritableCbzFile(file)
private var index = MangaIndex(null)
suspend fun prepare(manga: Manga) {
writableCbz.prepare(overwrite = true)
index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText())
index.setMangaInfo(manga, append = true)
}
suspend fun cleanup() {
writableCbz.cleanup()
}
@CheckResult
suspend fun compress(): Boolean {
writableCbz[INDEX_ENTRY].writeText(index.toString())
return writableCbz.flush()
}
suspend fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
writableCbz.put(name, file)
index.setCoverEntry(name)
}
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
writableCbz.put(name, file)
index.addChapter(chapter)
}
companion object {
private const val FILENAME_PATTERN = "%03d%03d"
const val INDEX_ENTRY = "index.json"
fun findInDir(root: File, manga: Manga): MangaZip {
val name = manga.title.toFileNameSafe() + ".cbz"
val file = File(root, name)
return MangaZip(file)
}
}
}

@ -2,13 +2,13 @@ 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 java.io.File
import java.io.InputStream
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
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
class PagesCache(context: Context) { class PagesCache(context: Context) {

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FilenameFilter
class TempFileFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return name.endsWith(".tmp", ignoreCase = true)
}
}

@ -1,99 +0,0 @@
package org.koitharu.kotatsu.local.data
import androidx.annotation.CheckResult
import kotlinx.coroutines.*
import org.koitharu.kotatsu.utils.ext.deleteAwait
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class WritableCbzFile(private val file: File) {
private val dir = File(file.parentFile, file.nameWithoutExtension)
suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
if (!dir.list().isNullOrEmpty()) {
if (overwrite) {
dir.deleteRecursively()
} else {
throw IllegalStateException("Dir ${dir.name} is not empty")
}
}
if (!dir.exists()) {
dir.mkdir()
}
if (!file.exists()) {
return@withContext
}
ZipInputStream(FileInputStream(file)).use { zip ->
var entry = zip.nextEntry
while (entry != null && currentCoroutineContext().isActive) {
val target = File(dir.path + File.separator + entry.name)
runInterruptible {
target.parentFile?.mkdirs()
target.outputStream().use { out ->
zip.copyTo(out)
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
suspend fun cleanup() = withContext(Dispatchers.IO) {
dir.deleteRecursively()
}
@CheckResult
suspend fun flush() = withContext(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {
tempFile.deleteAwait()
}
try {
runInterruptible {
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
dir.listFiles()?.forEach {
zipFile(it, it.name, zip)
}
zip.flush()
}
}
tempFile.renameTo(file)
} finally {
if (tempFile.exists()) {
tempFile.deleteAwait()
}
}
}
operator fun get(name: String) = File(dir, name)
suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) {
file.copyTo(this[name], overwrite = true)
}
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
if (fileToZip.isDirectory) {
if (fileName.endsWith("/")) {
zipOut.putNextEntry(ZipEntry(fileName))
} else {
zipOut.putNextEntry(ZipEntry("$fileName/"))
}
zipOut.closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
}
} else {
FileInputStream(fileToZip).use { fis ->
val zipEntry = ZipEntry(fileName)
zipOut.putNextEntry(zipEntry)
fis.copyTo(zipOut)
}
}
}
}

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

Loading…
Cancel
Save