Merge branch 'devel' into feature/shikimori
This commit is contained in:
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -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
|
||||
93
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
Normal file
93
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
Normal file
@@ -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
|
||||
39
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
Normal file
@@ -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
2
.gitignore
vendored
@@ -9,6 +9,8 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/kotlinScripting.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
17
.idea/deploymentTargetDropDown.xml
generated
17
.idea/deploymentTargetDropDown.xml
generated
@@ -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>
|
||||
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -7,7 +7,7 @@
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="Embedded JDK" />
|
||||
<option name="gradleJvm" value="Android Studio default JDK" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
||||
4
.idea/inspectionProfiles/Project_Default.xml
generated
4
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,8 +1,10 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<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="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="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
|
||||
11
.travis.yml
11
.travis.yml
@@ -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.
|
||||
|
||||
  [](https://travis-ci.org/nv95/Kotatsu)  [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
|
||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
|
||||
|
||||
### Download
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 32
|
||||
versionCode 400
|
||||
versionName '3.0'
|
||||
versionCode 406
|
||||
versionName '3.2.2'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -52,6 +52,7 @@ android {
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||
]
|
||||
}
|
||||
lint {
|
||||
@@ -65,7 +66,7 @@ android {
|
||||
}
|
||||
dependencies {
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ dependencies {
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
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
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
||||
|
||||
@@ -101,11 +102,11 @@ dependencies {
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
|
||||
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.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 '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
|
||||
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
||||
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" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
|
||||
@@ -86,7 +87,8 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/manga_shelf">
|
||||
android:label="@string/manga_shelf"
|
||||
android:theme="@style/Theme.Kotatsu.DialogWhenLarge">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
@@ -103,13 +105,18 @@
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||
android:label="@string/downloads"
|
||||
android:launchMode="singleTop"
|
||||
android:label="@string/downloads" />
|
||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
|
||||
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
|
||||
<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
|
||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||
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.util.await
|
||||
import org.koitharu.kotatsu.parsers.util.medianOrNull
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
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 {
|
||||
val options = BitmapFactory.Options().apply {
|
||||
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.prefs.AppSettings
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
protected lateinit var binding: B
|
||||
@@ -123,4 +124,4 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,12 @@ import android.view.ViewGroup.LayoutParams
|
||||
import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.core.view.updateLayoutParams
|
||||
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.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
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() {
|
||||
|
||||
@@ -43,7 +44,9 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return if (resources.getBoolean(R.bool.is_tablet)) {
|
||||
AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
|
||||
} else super.onCreateDialog(savedInstanceState)
|
||||
} else {
|
||||
AppBottomSheetDialog(requireContext(), theme)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package org.koitharu.kotatsu.base.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlinx.coroutines.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
|
||||
abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
val onError = SingleLiveEvent<Throwable>()
|
||||
val isLoading = MutableLiveData(false)
|
||||
val isLoading = CountedBooleanLiveData()
|
||||
|
||||
protected fun launchJob(
|
||||
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.DialogInterface
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.utils.ext.inflate
|
||||
import java.io.File
|
||||
|
||||
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||
@@ -66,7 +66,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
val volumes = getAvailableVolumes(storageManager)
|
||||
|
||||
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 {
|
||||
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.Creator
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.Checkable
|
||||
import androidx.annotation.AttrRes
|
||||
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 onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
|
||||
|
||||
@@ -13,12 +13,12 @@ import android.graphics.drawable.shapes.RectShape
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.AppCompatCheckedTextView
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import com.google.android.material.ripple.RippleUtils
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
class ListItemTextView @JvmOverloads constructor(
|
||||
@@ -119,8 +119,7 @@ class ListItemTextView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun getRippleColorFallback(context: Context): ColorStateList {
|
||||
return context.obtainStyledAttributes(intArrayOf(android.R.attr.colorControlHighlight)).use {
|
||||
it.getColorStateList(0)
|
||||
} ?: ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
|
||||
?: 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.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.R as materialR
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
@@ -28,6 +29,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
with(binding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = UserAgentInterceptor.userAgent
|
||||
}
|
||||
binding.webView.webViewClient = BrowserClient(this)
|
||||
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
||||
|
||||
@@ -2,15 +2,11 @@ package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
||||
|
||||
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
|
||||
|
||||
private val okHttp by inject<OkHttpClient>(mode = LazyThreadSafetyMode.SYNCHRONIZED)
|
||||
|
||||
override fun onPageFinished(webView: WebView, url: String) {
|
||||
super.onPageFinished(webView, url)
|
||||
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("title", title)
|
||||
jo.put("order", order)
|
||||
jo.put("track", track)
|
||||
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"),
|
||||
title = json.getString("title"),
|
||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
||||
track = json.getBooleanOrDefault("track", true),
|
||||
)
|
||||
|
||||
private fun parseFavourite(json: JSONObject) = FavouriteEntity(
|
||||
|
||||
@@ -5,5 +5,5 @@ import org.koin.dsl.module
|
||||
|
||||
val databaseModule
|
||||
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) {
|
||||
db.execSQL(
|
||||
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)",
|
||||
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name)
|
||||
"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, 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class
|
||||
],
|
||||
version = 9
|
||||
version = 10
|
||||
)
|
||||
abstract class MangaDatabase : RoomDatabase() {
|
||||
|
||||
@@ -43,24 +43,22 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract val trackLogsDao: TrackLogsDao
|
||||
|
||||
abstract val suggestionDao: SuggestionDao
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun create(context: Context): MangaDatabase = Room.databaseBuilder(
|
||||
context,
|
||||
MangaDatabase::class.java,
|
||||
"kotatsu-db"
|
||||
).addMigrations(
|
||||
Migration1To2(),
|
||||
Migration2To3(),
|
||||
Migration3To4(),
|
||||
Migration4To5(),
|
||||
Migration5To6(),
|
||||
Migration6To7(),
|
||||
Migration7To8(),
|
||||
Migration8To9(),
|
||||
).addCallback(
|
||||
DatabasePrePopulateCallback(context.resources)
|
||||
).build()
|
||||
}
|
||||
}
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
|
||||
context,
|
||||
MangaDatabase::class.java,
|
||||
"kotatsu-db"
|
||||
).addMigrations(
|
||||
Migration1To2(),
|
||||
Migration2To3(),
|
||||
Migration3To4(),
|
||||
Migration4To5(),
|
||||
Migration5To6(),
|
||||
Migration6To7(),
|
||||
Migration7To8(),
|
||||
Migration8To9(),
|
||||
Migration9To10(),
|
||||
).addCallback(
|
||||
DatabasePrePopulateCallback(context.resources)
|
||||
).build()
|
||||
@@ -10,6 +10,9 @@ abstract class TracksDao {
|
||||
@Query("SELECT * FROM tracks")
|
||||
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")
|
||||
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
|
||||
get() = module {
|
||||
single {
|
||||
factory {
|
||||
GithubRepository(get())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import java.util.*
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.*
|
||||
|
||||
@Parcelize
|
||||
data class FavouriteCategory(
|
||||
@@ -12,4 +12,5 @@ data class FavouriteCategory(
|
||||
val sortKey: Int,
|
||||
val order: SortOrder,
|
||||
val createdAt: Date,
|
||||
val isTrackingEnabled: Boolean,
|
||||
) : Parcelable
|
||||
@@ -3,26 +3,4 @@ package org.koitharu.kotatsu.core.model
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
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 }
|
||||
@@ -10,13 +10,18 @@ private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
|
||||
|
||||
class ParcelableManga(
|
||||
val manga: Manga,
|
||||
private val withChapters: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(parcel.readManga())
|
||||
constructor(parcel: Parcel) : this(parcel.readManga(), true)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
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
|
||||
manga.writeToParcel(parcel, flags, withChapters = true)
|
||||
return
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.os.Build
|
||||
import java.util.*
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import java.util.*
|
||||
|
||||
class UserAgentInterceptor : Interceptor {
|
||||
|
||||
@@ -30,5 +30,14 @@ class UserAgentInterceptor : Interceptor {
|
||||
Build.DEVICE,
|
||||
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.media.ThumbnailUtils
|
||||
import android.os.Build
|
||||
import android.util.Size
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.PixelSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -54,7 +54,7 @@ class ShortcutsRepository(
|
||||
val bmp = coil.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(manga.coverUrl)
|
||||
.size(iconSize)
|
||||
.size(iconSize.width, iconSize.height)
|
||||
.build()
|
||||
).requireBitmap()
|
||||
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) {
|
||||
(context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
|
||||
PixelSize(it.iconMaxWidth, it.iconMaxHeight)
|
||||
Size(it.iconMaxWidth, it.iconMaxHeight)
|
||||
}
|
||||
} else {
|
||||
(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 coil.map.Mapper
|
||||
import coil.request.Options
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
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 repo = MangaRepository(mangaSource) as RemoteMangaRepository
|
||||
return repo.getFaviconUrl().toHttpUrl()
|
||||
}
|
||||
|
||||
override fun handles(data: Uri) = data.scheme == "favicon"
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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.get
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
@@ -28,11 +30,18 @@ interface MangaRepository {
|
||||
|
||||
companion object : KoinComponent {
|
||||
|
||||
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
|
||||
|
||||
operator fun invoke(source: MangaSource): MangaRepository {
|
||||
return if (source == MangaSource.LOCAL) {
|
||||
get<LocalMangaRepository>()
|
||||
} else {
|
||||
RemoteMangaRepository(source, get())
|
||||
if (source == MangaSource.LOCAL) {
|
||||
return get<LocalMangaRepository>()
|
||||
}
|
||||
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
|
||||
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.newParser
|
||||
|
||||
class RemoteMangaRepository(
|
||||
override val source: MangaSource,
|
||||
loaderContext: MangaLoaderContext,
|
||||
) : MangaRepository {
|
||||
class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
|
||||
|
||||
private val parser: MangaParser = source.newParser(loaderContext)
|
||||
override val source: MangaSource
|
||||
get() = parser.source
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = parser.sortOrders
|
||||
@@ -28,7 +24,7 @@ class RemoteMangaRepository(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> = parser.getList(offset, query, tags, sortOrder)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
|
||||
@@ -48,4 +44,4 @@ class RemoteMangaRepository(
|
||||
}
|
||||
|
||||
private fun getConfig() = parser.config as SourceSettings
|
||||
}
|
||||
}
|
||||
@@ -11,23 +11,34 @@ import androidx.collection.arraySetOf
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
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.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.utils.ext.putEnumValue
|
||||
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) {
|
||||
|
||||
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
|
||||
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
|
||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
|
||||
@@ -56,6 +67,10 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||
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
|
||||
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
|
||||
|
||||
@@ -63,7 +78,10 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getLong(KEY_APP_UPDATE, 0L)
|
||||
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)
|
||||
|
||||
var notificationSound: Uri
|
||||
@@ -104,10 +122,9 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
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)
|
||||
?.split('|')
|
||||
?.mapNotNull(String::toIntOrNull)
|
||||
.orEmpty()
|
||||
set(value) = prefs.edit {
|
||||
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
|
||||
@@ -120,6 +137,20 @@ class AppSettings(context: Context) {
|
||||
val isSourcesSelected: Boolean
|
||||
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
|
||||
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
|
||||
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
||||
|
||||
@@ -178,11 +215,10 @@ class AppSettings(context: Context) {
|
||||
}
|
||||
|
||||
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
||||
val list = MangaSource.values().toMutableList()
|
||||
list.remove(MangaSource.LOCAL)
|
||||
val list = remoteSources.toMutableList()
|
||||
val order = sourcesOrder
|
||||
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 (!includeHidden) {
|
||||
@@ -224,7 +260,7 @@ class AppSettings(context: Context) {
|
||||
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
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_TRAFFIC_WARNING = "traffic_warning"
|
||||
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_LOCAL_STORAGE = "local_storage"
|
||||
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_CATEGORIES = "track_categories"
|
||||
const val KEY_TRACK_WARNING = "track_warning"
|
||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||
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_PREFER_RTL = "reader_prefer_rtl"
|
||||
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_SEARCH_SINGLE_SOURCE = "search_single_source"
|
||||
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
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
@@ -282,4 +324,4 @@ class AppSettings(context: Context) {
|
||||
private val isSamsung
|
||||
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.core.ui
|
||||
import android.content.res.Resources
|
||||
import org.koitharu.kotatsu.R
|
||||
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 {
|
||||
|
||||
@@ -72,9 +75,33 @@ sealed class DateTimeAgo : ListModel {
|
||||
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() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getString(R.string.long_ago)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.util.CoilUtils
|
||||
import coil.disk.DiskCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.parser.FaviconMapper
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||
|
||||
val uiModule
|
||||
@@ -14,15 +16,23 @@ val uiModule
|
||||
single {
|
||||
val httpClientFactory = {
|
||||
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()
|
||||
}
|
||||
ImageLoader.Builder(androidContext())
|
||||
.okHttpClient(httpClientFactory)
|
||||
.launchInterceptorChainOnMainThread(false)
|
||||
.componentRegistry(
|
||||
.interceptorDispatcher(Dispatchers.Default)
|
||||
.diskCache(diskCacheFactory)
|
||||
.components(
|
||||
ComponentRegistry.Builder()
|
||||
.add(CbzFetcher())
|
||||
.add(CbzFetcher.Factory())
|
||||
.add(FaviconMapper())
|
||||
.build()
|
||||
).build()
|
||||
|
||||
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
@@ -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.widget.SearchView
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
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.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
@@ -67,8 +68,8 @@ class ChaptersFragment :
|
||||
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
viewModel.hasChapters.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isGone = it
|
||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isVisible = it
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
@@ -94,7 +95,7 @@ class ChaptersFragment :
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
super.onPrepareOptionsMenu(menu)
|
||||
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) {
|
||||
@@ -154,11 +155,29 @@ class ChaptersFragment :
|
||||
DownloadService.start(
|
||||
context ?: return false,
|
||||
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
|
||||
selectionDecoration?.checkedItemsIds
|
||||
selectionDecoration?.checkedItemsIds?.toSet()
|
||||
)
|
||||
mode.finish()
|
||||
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 -> {
|
||||
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
|
||||
selectionDecoration?.checkAll(ids)
|
||||
@@ -188,6 +207,9 @@ class ChaptersFragment :
|
||||
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
||||
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()
|
||||
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.parsers.model.Manga
|
||||
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.ReaderState
|
||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy,
|
||||
class DetailsActivity :
|
||||
BaseActivity<ActivityDetailsBinding>(),
|
||||
TabLayoutMediator.TabConfigurationStrategy,
|
||||
AdapterView.OnItemSelectedListener {
|
||||
|
||||
private val viewModel by viewModel<DetailsViewModel> {
|
||||
@@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
viewModel.manga.value?.let { m ->
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_manga)
|
||||
.setMessage(getString(R.string.text_delete_local_manga, m.title))
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.deleteLocal(m)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
val title = viewModel.manga.value?.title.orEmpty()
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_manga)
|
||||
.setMessage(getString(R.string.text_delete_local_manga, title))
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.deleteLocal()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
R.id.action_save -> {
|
||||
viewModel.manga.value?.let {
|
||||
val chaptersCount = it.chapters?.size ?: 0
|
||||
if (chaptersCount > 5) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.save_manga)
|
||||
.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()
|
||||
val branches = viewModel.branches.value.orEmpty()
|
||||
if (chaptersCount > 5 || branches.size > 1) {
|
||||
showSaveConfirmation(it, chaptersCount, branches)
|
||||
} else {
|
||||
DownloadService.start(this, it)
|
||||
}
|
||||
@@ -262,7 +250,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
fun showChapterMissingDialog(chapterId: Long) {
|
||||
val remoteManga = viewModel.getRemoteManga()
|
||||
if (remoteManga == null) {
|
||||
binding.snackbar.show(getString( R.string.chapter_is_missing))
|
||||
binding.snackbar.show(getString(R.string.chapter_is_missing))
|
||||
return
|
||||
}
|
||||
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 {
|
||||
|
||||
fun newIntent(context: Context, manga: Manga): Intent {
|
||||
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 {
|
||||
@@ -340,4 +358,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.os.Bundle
|
||||
import android.text.Spanned
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.*
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
@@ -224,14 +225,16 @@ class DetailsFragment :
|
||||
if (viewModel.readingHistory.value == null) {
|
||||
return false
|
||||
}
|
||||
v.showPopupMenu(R.menu.popup_read) {
|
||||
when (it.itemId) {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
menu.inflate(R.menu.popup_read)
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_read -> {
|
||||
val branch = viewModel.selectedBranchValue
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
context = context ?: return@showPopupMenu false,
|
||||
manga = viewModel.manga.value ?: return@showPopupMenu false,
|
||||
context = context ?: return@setOnMenuItemClickListener false,
|
||||
manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
|
||||
state = viewModel.chapters.value?.firstOrNull { c ->
|
||||
c.chapter.branch == branch
|
||||
}?.let { c ->
|
||||
@@ -244,6 +247,7 @@ class DetailsFragment :
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
menu.show()
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
@@ -279,7 +283,7 @@ class DetailsFragment :
|
||||
.target(binding.imageViewCover)
|
||||
if (currentCover != null) {
|
||||
request.data(manga.largeCoverUrl ?: return)
|
||||
.placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey)
|
||||
.placeholderMemoryCacheKey(CoilUtils.result(binding.imageViewCover)?.request?.memoryCacheKey)
|
||||
.fallback(currentCover)
|
||||
} else {
|
||||
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.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.iterator
|
||||
import java.io.IOException
|
||||
|
||||
@@ -93,18 +94,18 @@ class DetailsViewModel(
|
||||
|
||||
val branches = mangaData.map {
|
||||
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val selectedBranchIndex = combine(
|
||||
branches.asFlow(),
|
||||
selectedBranch
|
||||
) { branches, selected ->
|
||||
branches.indexOf(selected)
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val hasChapters = mangaData.map {
|
||||
!(it?.chapters.isNullOrEmpty())
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
val isChaptersEmpty = mangaData.mapNotNull { m ->
|
||||
m?.run { chapters.isNullOrEmpty() }
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
|
||||
|
||||
val chapters = combine(
|
||||
combine(
|
||||
@@ -139,8 +140,11 @@ class DetailsViewModel(
|
||||
loadingJob = doLoad()
|
||||
}
|
||||
|
||||
fun deleteLocal(manga: Manga) {
|
||||
fun deleteLocal() {
|
||||
val m = mangaData.value ?: return
|
||||
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)
|
||||
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
||||
runCatching {
|
||||
@@ -196,7 +200,8 @@ class DetailsViewModel(
|
||||
// find default branch
|
||||
val hist = historyRepository.getOne(manga)
|
||||
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 {
|
||||
predictBranch(manga.chapters)
|
||||
}
|
||||
@@ -208,6 +213,8 @@ class DetailsViewModel(
|
||||
} else {
|
||||
localMangaRepository.findSavedManga(manga)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
if (BuildConfig.DEBUG) error.printStackTrace()
|
||||
}.getOrNull()
|
||||
findShikimoriManga(manga)
|
||||
}
|
||||
@@ -255,10 +262,10 @@ class DetailsViewModel(
|
||||
val dateFormat = settings.getDateFormat()
|
||||
for (i in sourceChapters.indices) {
|
||||
val chapter = sourceChapters[i]
|
||||
val localChapter = chaptersMap.remove(chapter.id)
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
val localChapter = chaptersMap.remove(chapter.id)
|
||||
result += localChapter?.toListItem(
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
@@ -277,15 +284,19 @@ class DetailsViewModel(
|
||||
}
|
||||
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
||||
result.ensureCapacity(result.size + chaptersMap.size)
|
||||
chaptersMap.values.mapTo(result) {
|
||||
it.toListItem(
|
||||
isCurrent = false,
|
||||
isUnread = true,
|
||||
isNew = false,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
chaptersMap.values.mapNotNullTo(result) {
|
||||
if (it.branch == branch) {
|
||||
it.toListItem(
|
||||
isCurrent = false,
|
||||
isUnread = true,
|
||||
isNew = false,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
result.sortBy { it.chapter.number }
|
||||
}
|
||||
|
||||
@@ -40,11 +40,10 @@ class ChapterListItem(
|
||||
override fun hashCode(): Int {
|
||||
var result = chapter.hashCode()
|
||||
result = 31 * result + flags
|
||||
result = 31 * result + uploadDate.hashCode()
|
||||
result = 31 * result + (uploadDate?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val FLAG_UNREAD = 2
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.ConnectivityManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ImageLoader
|
||||
@@ -17,10 +16,12 @@ import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
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.domain.CbzMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.referer
|
||||
@@ -29,9 +30,8 @@ import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import java.io.File
|
||||
|
||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||
private const val MAX_PARALLEL_DOWNLOADS = 2
|
||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
||||
private const val SLOWDOWN_DELAY = 200L
|
||||
|
||||
class DownloadManager(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
@@ -40,9 +40,10 @@ class DownloadManager(
|
||||
private val okHttp: OkHttpClient,
|
||||
private val cache: PagesCache,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val connectivityManager = context.applicationContext.getSystemService(
|
||||
private val connectivityManager = context.getSystemService(
|
||||
Context.CONNECTIVITY_SERVICE
|
||||
) as ConnectivityManager
|
||||
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||
@@ -51,98 +52,109 @@ class DownloadManager(
|
||||
private val coverHeight = context.resources.getDimensionPixelSize(
|
||||
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(
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
chaptersIds: LongArray?,
|
||||
startId: Int,
|
||||
): ProgressJob<DownloadState> {
|
||||
val stateFlow = MutableStateFlow<DownloadState>(
|
||||
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)
|
||||
}
|
||||
|
||||
private fun downloadMangaImpl(
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
chaptersIds: LongArray?,
|
||||
outState: MutableStateFlow<DownloadState>,
|
||||
startId: Int,
|
||||
): 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()
|
||||
coroutineContext[WakeLockNode]?.acquire()
|
||||
outState.value = DownloadState.Preparing(startId, manga, null)
|
||||
var cover: Drawable? = null
|
||||
val destination = localMangaRepository.getOutputDir()
|
||||
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 {
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
|
||||
}
|
||||
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)
|
||||
val data = if (manga.chapters == null) repo.getDetails(manga) else manga
|
||||
output = MangaZip.findInDir(destination, data)
|
||||
output.prepare(data)
|
||||
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||
output = CbzMangaOutput.get(destination, data)
|
||||
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))
|
||||
}
|
||||
val chapters = if (chaptersIds == null) {
|
||||
data.chapters.orEmpty()
|
||||
} else {
|
||||
data.chapters.orEmpty().filter { x -> x.id in chaptersIds }
|
||||
val chapters = checkNotNull(
|
||||
if (chaptersIdsSet == null) {
|
||||
data.chapters
|
||||
} else {
|
||||
data.chapters?.filter { x -> chaptersIdsSet.remove(x.id) }
|
||||
}
|
||||
) { "Chapters list must not be null" }
|
||||
check(chapters.isNotEmpty()) { "Chapters list must not be empty" }
|
||||
check(chaptersIdsSet.isNullOrEmpty()) {
|
||||
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
|
||||
}
|
||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||
if (chaptersIds == null || chapter.id in chaptersIds) {
|
||||
val pages = repo.getPages(chapter)
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
failsafe@ do {
|
||||
try {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file =
|
||||
cache[url] ?: downloadFile(url, page.referer, destination)
|
||||
output.addPage(
|
||||
chapter,
|
||||
file,
|
||||
pageIndex,
|
||||
MimeTypeMap.getFileExtensionFromUrl(url)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
val pages = repo.getPages(chapter)
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
var retryCounter = 0
|
||||
failsafe@ while (true) {
|
||||
try {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
|
||||
output.addPage(
|
||||
chapter = chapter,
|
||||
file = file,
|
||||
pageNumber = pageIndex,
|
||||
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||
)
|
||||
break@failsafe
|
||||
} catch (e: IOException) {
|
||||
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
|
||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
connectivityManager.waitForNetwork()
|
||||
continue@failsafe
|
||||
retryCounter++
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} while (false)
|
||||
}
|
||||
}
|
||||
|
||||
outState.value = DownloadState.Progress(
|
||||
startId, data, cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageIndex,
|
||||
)
|
||||
outState.value = DownloadState.Progress(
|
||||
startId, data, cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageIndex,
|
||||
)
|
||||
|
||||
if (settings.isDownloadsSlowdownEnabled) {
|
||||
delay(SLOWDOWN_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
||||
if (!output.compress()) {
|
||||
throw RuntimeException("Cannot create target file")
|
||||
}
|
||||
output.mergeWithExisting()
|
||||
output.finalize()
|
||||
val localManga = localMangaRepository.getFromFile(output.file)
|
||||
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: CancellationException) {
|
||||
outState.value = DownloadState.Cancelled(startId, manga, cover)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
@@ -151,14 +163,15 @@ class DownloadManager(
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output?.cleanup()
|
||||
File(destination, TEMP_PAGE_FILE).deleteAwait()
|
||||
File(destination, tempFileName).deleteAwait()
|
||||
}
|
||||
coroutineContext[WakeLockNode]?.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()
|
||||
.url(url)
|
||||
.header(CommonHeaders.REFERER, referer)
|
||||
@@ -166,35 +179,55 @@ class DownloadManager(
|
||||
.get()
|
||||
.build()
|
||||
val call = okHttp.newCall(request)
|
||||
var attempts = MAX_DOWNLOAD_ATTEMPTS
|
||||
val file = File(destination, TEMP_PAGE_FILE)
|
||||
while (true) {
|
||||
try {
|
||||
val response = call.clone().await()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
file.outputStream().use { out ->
|
||||
checkNotNull(response.body).byteStream().copyTo(out)
|
||||
}
|
||||
}
|
||||
return file
|
||||
} catch (e: IOException) {
|
||||
attempts--
|
||||
if (attempts <= 0) {
|
||||
throw e
|
||||
} else {
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
}
|
||||
val file = File(destination, tempFileName)
|
||||
val response = call.clone().await()
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
file.outputStream().use { out ->
|
||||
checkNotNull(response.body).byteStream().copyTo(out)
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) = CoroutineExceptionHandler { _, throwable ->
|
||||
val prevValue = outState.value
|
||||
outState.value = DownloadState.Error(
|
||||
startId = prevValue.startId,
|
||||
manga = prevValue.manga,
|
||||
cover = prevValue.cover,
|
||||
error = throwable,
|
||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
val prevValue = outState.value
|
||||
outState.value = DownloadState.Error(
|
||||
startId = prevValue.startId,
|
||||
manga = prevValue.manga,
|
||||
cover = prevValue.cover,
|
||||
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.content.Context
|
||||
import android.os.Build
|
||||
import android.text.format.DateUtils
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -49,7 +50,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
||||
builder.setSilent(true)
|
||||
}
|
||||
|
||||
fun create(state: DownloadState): Notification {
|
||||
fun create(state: DownloadState, timeLeft: Long): Notification {
|
||||
builder.setContentTitle(state.manga.title)
|
||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||
builder.setProgress(1, 0, true)
|
||||
@@ -117,7 +118,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
||||
}
|
||||
is DownloadState.Progress -> {
|
||||
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.setStyle(null)
|
||||
builder.setOngoing(true)
|
||||
|
||||
@@ -11,10 +11,7 @@ import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
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.dialog.CheckBoxAlertDialog
|
||||
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.download.domain.DownloadManager
|
||||
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.utils.ext.connectivityManager
|
||||
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.TimeLeftEstimator
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DownloadService : BaseService() {
|
||||
@@ -48,16 +44,12 @@ class DownloadService : BaseService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
notificationSwitcher = ForegroundNotificationSwitcher(this)
|
||||
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
downloadManager = DownloadManager(
|
||||
downloadManager = get<DownloadManager.Factory>().create(
|
||||
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
||||
context = this,
|
||||
imageLoader = get(),
|
||||
okHttp = get(),
|
||||
cache = get(),
|
||||
localMangaRepository = get(),
|
||||
)
|
||||
DownloadNotification.createChannel(this)
|
||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||
@@ -66,7 +58,7 @@ class DownloadService : BaseService() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
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) {
|
||||
jobs[startId] = downloadManga(startId, manga, chapters)
|
||||
jobCount.value = jobs.size
|
||||
@@ -90,13 +82,14 @@ class DownloadService : BaseService() {
|
||||
override fun onDestroy() {
|
||||
unregisterReceiver(controlReceiver)
|
||||
binder = null
|
||||
isRunning = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun downloadManga(
|
||||
startId: Int,
|
||||
manga: Manga,
|
||||
chaptersIds: Set<Long>?,
|
||||
chaptersIds: LongArray?,
|
||||
): ProgressJob<DownloadState> {
|
||||
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||
listenJob(job)
|
||||
@@ -106,19 +99,28 @@ class DownloadService : BaseService() {
|
||||
private fun listenJob(job: ProgressJob<DownloadState>) {
|
||||
lifecycleScope.launch {
|
||||
val startId = job.progressValue.startId
|
||||
val timeLeftEstimator = TimeLeftEstimator()
|
||||
val notification = DownloadNotification(this@DownloadService, startId)
|
||||
notificationSwitcher.notify(startId, notification.create(job.progressValue))
|
||||
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
|
||||
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 }
|
||||
.whileActive()
|
||||
.collect { state ->
|
||||
notificationSwitcher.notify(startId, notification.create(state))
|
||||
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
|
||||
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
|
||||
}
|
||||
job.join()
|
||||
(job.progressValue as? DownloadState.Done)?.let {
|
||||
sendBroadcast(
|
||||
Intent(ACTION_DOWNLOAD_COMPLETE)
|
||||
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga.withoutChapters()))
|
||||
.putExtra(EXTRA_MANGA, ParcelableManga(it.localManga, withChapters = false))
|
||||
)
|
||||
}
|
||||
notificationSwitcher.detach(
|
||||
@@ -126,7 +128,7 @@ class DownloadService : BaseService() {
|
||||
if (job.isCancelled) {
|
||||
null
|
||||
} else {
|
||||
notification.create(job.progressValue)
|
||||
notification.create(job.progressValue, -1L)
|
||||
}
|
||||
)
|
||||
stopSelf(startId)
|
||||
@@ -162,11 +164,12 @@ class DownloadService : BaseService() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val ACTION_DOWNLOAD_COMPLETE =
|
||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||
var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
private const val ACTION_DOWNLOAD_CANCEL =
|
||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||
|
||||
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||
|
||||
private const val EXTRA_MANGA = "manga"
|
||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||
@@ -178,7 +181,7 @@ class DownloadService : BaseService() {
|
||||
}
|
||||
confirmDataTransfer(context) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
|
||||
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
|
||||
if (chaptersIds != null) {
|
||||
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
|
||||
}
|
||||
@@ -194,7 +197,7 @@ class DownloadService : BaseService() {
|
||||
confirmDataTransfer(context) {
|
||||
for (item in manga) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class ForegroundNotificationSwitcher(
|
||||
@Synchronized
|
||||
fun notify(startId: Int, notification: Notification) {
|
||||
if (notifications.isEmpty()) {
|
||||
StartForegroundRunnable(startId, notification)
|
||||
service.startForeground(startId, notification)
|
||||
} else {
|
||||
notificationManager.notify(startId, notification)
|
||||
}
|
||||
@@ -46,16 +46,6 @@ class ForegroundNotificationSwitcher(
|
||||
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 val startId: Int,
|
||||
private val notification: Notification?,
|
||||
|
||||
@@ -4,19 +4,21 @@ import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
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.list.FavouritesListViewModel
|
||||
|
||||
val favouritesModule
|
||||
get() = module {
|
||||
|
||||
single { FavouritesRepository(get()) }
|
||||
factory { FavouritesRepository(get(), get()) }
|
||||
|
||||
viewModel { categoryId ->
|
||||
FavouritesListViewModel(categoryId.get(), get(), get(), get())
|
||||
}
|
||||
viewModel { FavouritesCategoriesViewModel(get()) }
|
||||
viewModel { FavouritesCategoriesViewModel(get(), get()) }
|
||||
viewModel { manga ->
|
||||
MangaCategoriesViewModel(manga.get(), get())
|
||||
}
|
||||
viewModel { params -> FavouritesCategoryEditViewModel(params[0], get(), get()) }
|
||||
}
|
||||
@@ -11,4 +11,5 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
|
||||
sortKey = sortKey,
|
||||
order = SortOrder(order, SortOrder.NEWEST),
|
||||
createdAt = Date(createdAt),
|
||||
isTrackingEnabled = track,
|
||||
)
|
||||
@@ -6,6 +6,9 @@ import kotlinx.coroutines.flow.Flow
|
||||
@Dao
|
||||
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")
|
||||
abstract suspend fun findAll(): List<FavouriteCategoryEntity>
|
||||
|
||||
@@ -27,9 +30,15 @@ abstract class FavouriteCategoriesDao {
|
||||
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
|
||||
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")
|
||||
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")
|
||||
abstract suspend fun updateSortKey(id: Long, sortKey: Int)
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@ class FavouriteCategoryEntity(
|
||||
@ColumnInfo(name = "sort_key") val sortKey: Int,
|
||||
@ColumnInfo(name = "title") val title: 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")
|
||||
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)")
|
||||
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.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
|
||||
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> {
|
||||
val entities = db.favouritesDao.findAll()
|
||||
@@ -48,6 +52,11 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
fun observeCategory(id: Long): Flow<FavouriteCategory> {
|
||||
return db.favouriteCategoriesDao.observe(id)
|
||||
.map { it.toFavouriteCategory() }
|
||||
}
|
||||
|
||||
fun observeCategories(mangaId: Long): Flow<List<FavouriteCategory>> {
|
||||
return db.favouritesDao.observe(mangaId).map { entity ->
|
||||
entity?.categories?.map { it.toFavouriteCategory() }.orEmpty()
|
||||
@@ -58,30 +67,62 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
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 {
|
||||
val entity = FavouriteCategoryEntity(
|
||||
title = title,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
|
||||
categoryId = 0,
|
||||
order = SortOrder.UPDATED.name,
|
||||
order = SortOrder.NEWEST.name,
|
||||
track = true,
|
||||
)
|
||||
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) {
|
||||
db.favouriteCategoriesDao.updateTitle(id, title)
|
||||
channels.renameChannel(id, title)
|
||||
}
|
||||
|
||||
suspend fun removeCategory(id: Long) {
|
||||
db.favouriteCategoriesDao.delete(id)
|
||||
channels.deleteChannel(id)
|
||||
}
|
||||
|
||||
suspend fun setCategoryOrder(id: Long, order: SortOrder) {
|
||||
db.favouriteCategoriesDao.updateOrder(id, order.name)
|
||||
}
|
||||
|
||||
suspend fun setCategoryTracking(id: Long, isEnabled: Boolean) {
|
||||
db.favouriteCategoriesDao.updateTracking(id, isEnabled)
|
||||
}
|
||||
|
||||
suspend fun reorderCategories(orderedIds: List<Long>) {
|
||||
val dao = db.favouriteCategoriesDao
|
||||
db.withTransaction {
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.favourites.ui
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.children
|
||||
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.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import java.util.*
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.util.ActionModeListener
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.titleRes
|
||||
import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
|
||||
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.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
||||
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||
|
||||
class FavouritesContainerFragment :
|
||||
BaseFragment<FragmentFavouritesBinding>(),
|
||||
@@ -52,15 +52,15 @@ class FavouritesContainerFragment :
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val adapter = FavouritesPagerAdapter(this, this)
|
||||
viewModel.categories.value?.let {
|
||||
adapter.replaceData(wrapCategories(it))
|
||||
viewModel.visibleCategories.value?.let {
|
||||
adapter.replaceData(it)
|
||||
}
|
||||
binding.pager.adapter = adapter
|
||||
pagerAdapter = adapter
|
||||
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
|
||||
actionModeDelegate.addListener(this, viewLifecycleOwner)
|
||||
|
||||
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
|
||||
viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
|
||||
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,8 @@ class FavouritesContainerFragment :
|
||||
top = headerHeight - insets.top
|
||||
)
|
||||
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 {
|
||||
updatePadding(
|
||||
@@ -98,8 +99,8 @@ class FavouritesContainerFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
|
||||
pagerAdapter?.replaceData(wrapCategories(categories))
|
||||
private fun onCategoriesChanged(categories: List<CategoryListModel>) {
|
||||
pagerAdapter?.replaceData(categories)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
@@ -121,23 +122,10 @@ class FavouritesContainerFragment :
|
||||
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
|
||||
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
|
||||
tabView.showPopupMenu(menuRes, { menu ->
|
||||
createOrderSubmenu(menu, 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
|
||||
override fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean {
|
||||
when (item) {
|
||||
is CategoryListModel.All -> showAllCategoriesMenu(tabView)
|
||||
is CategoryListModel.CategoryItem -> showCategoryMenu(tabView, item.category)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -146,31 +134,6 @@ class FavouritesContainerFragment :
|
||||
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 wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> {
|
||||
val data = ArrayList<FavouriteCategory>(categories.size + 1)
|
||||
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date())
|
||||
data += categories
|
||||
return data
|
||||
}
|
||||
|
||||
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
|
||||
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
||||
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
|
||||
val menuItem = submenu.add(R.id.group_order, Menu.NONE, i, item.titleRes)
|
||||
menuItem.isCheckable = true
|
||||
menuItem.isChecked = item == category.order
|
||||
}
|
||||
submenu.setGroupCheckable(R.id.group_order, true, true)
|
||||
}
|
||||
|
||||
private fun TabLayout.setTabsEnabled(enabled: Boolean) {
|
||||
val tabStrip = getChildAt(0) as? ViewGroup ?: return
|
||||
for (tab in tabStrip.children) {
|
||||
@@ -178,6 +141,33 @@ class FavouritesContainerFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCategoryMenu(tabView: View, category: FavouriteCategory) {
|
||||
val menu = PopupMenu(tabView.context, tabView)
|
||||
menu.inflate(R.menu.popup_category)
|
||||
menu.setOnMenuItemClickListener {
|
||||
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
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun showAllCategoriesMenu(tabView: View) {
|
||||
val menu = PopupMenu(tabView.context, tabView)
|
||||
menu.inflate(R.menu.popup_category_all)
|
||||
menu.setOnMenuItemClickListener {
|
||||
when (it.itemId) {
|
||||
R.id.action_create -> FavouritesCategoryEditActivity.newIntent(requireContext())
|
||||
R.id.action_hide -> viewModel.setAllCategoriesVisible(false)
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance() = FavouritesContainerFragment()
|
||||
|
||||
@@ -7,14 +7,16 @@ import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
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
|
||||
|
||||
class FavouritesPagerAdapter(
|
||||
fragment: Fragment,
|
||||
private val longClickListener: FavouritesTabLongClickListener
|
||||
) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
|
||||
TabLayoutMediator.TabConfigurationStrategy, View.OnLongClickListener {
|
||||
TabLayoutMediator.TabConfigurationStrategy,
|
||||
View.OnLongClickListener {
|
||||
|
||||
private val differ = AsyncListDiffer(this, DiffCallback())
|
||||
|
||||
@@ -35,12 +37,15 @@ class FavouritesPagerAdapter(
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
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.setOnLongClickListener(this)
|
||||
}
|
||||
|
||||
fun replaceData(data: List<FavouriteCategory>) {
|
||||
fun replaceData(data: List<CategoryListModel>) {
|
||||
differ.submitList(data)
|
||||
}
|
||||
|
||||
@@ -50,16 +55,22 @@ class FavouritesPagerAdapter(
|
||||
return longClickListener.onTabLongClick(v, item)
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
|
||||
private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory
|
||||
): Boolean = oldItem.id == newItem.id
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel
|
||||
): 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(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory
|
||||
): Boolean = oldItem.id == newItem.id && oldItem.title == newItem.title
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel
|
||||
): Boolean = oldItem == newItem
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.koitharu.kotatsu.favourites.ui
|
||||
|
||||
import android.view.View
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||
|
||||
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.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
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.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.ui.titleRes
|
||||
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.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||
import org.koitharu.kotatsu.utils.ext.showPopupMenu
|
||||
|
||||
class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
class CategoriesActivity :
|
||||
BaseActivity<ActivityCategoriesBinding>(),
|
||||
OnListItemClickListener<FavouriteCategory>,
|
||||
View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
|
||||
View.OnClickListener,
|
||||
CategoriesEditDelegate.CategoriesEditCallback,
|
||||
AllCategoriesToggleListener {
|
||||
|
||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||
|
||||
@@ -39,7 +42,7 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
adapter = CategoriesAdapter(this)
|
||||
adapter = CategoriesAdapter(this, this)
|
||||
editDelegate = CategoriesEditDelegate(this, this)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.adapter = adapter
|
||||
@@ -47,40 +50,39 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
|
||||
reorderHelper.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
viewModel.categories.observe(this, ::onCategoriesChanged)
|
||||
viewModel.allCategories.observe(this, ::onCategoriesChanged)
|
||||
viewModel.onError.observe(this, ::onError)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.fab_add -> editDelegate.createCategory()
|
||||
R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: FavouriteCategory, view: View) {
|
||||
view.showPopupMenu(R.menu.popup_category, { menu ->
|
||||
createOrderSubmenu(menu, item)
|
||||
}) {
|
||||
when (it.itemId) {
|
||||
val menu = PopupMenu(view.context, view)
|
||||
menu.inflate(R.menu.popup_category)
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_remove -> editDelegate.deleteCategory(item)
|
||||
R.id.action_rename -> editDelegate.renameCategory(item)
|
||||
R.id.action_order -> return@showPopupMenu false
|
||||
else -> {
|
||||
val order = SORT_ORDERS.getOrNull(it.order) ?: return@showPopupMenu false
|
||||
viewModel.setCategoryOrder(item.id, order)
|
||||
}
|
||||
R.id.action_edit -> startActivity(FavouritesCategoryEditActivity.newIntent(this, item.id))
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
|
||||
reorderHelper.startDrag(
|
||||
binding.recyclerView.findContainingViewHolder(view) ?: return false
|
||||
)
|
||||
val viewHolder = binding.recyclerView.findContainingViewHolder(view) ?: return false
|
||||
reorderHelper.startDrag(viewHolder)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onAllCategoriesToggle(isVisible: Boolean) {
|
||||
viewModel.setAllCategoriesVisible(isVisible)
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
binding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
rightMargin = topMargin + insets.right
|
||||
@@ -90,11 +92,11 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
binding.recyclerView.updatePadding(
|
||||
left = insets.left,
|
||||
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
|
||||
binding.textViewHolder.isVisible = categories.isEmpty()
|
||||
}
|
||||
@@ -108,40 +110,23 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
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(
|
||||
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
|
||||
) {
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: 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(
|
||||
recyclerView: RecyclerView,
|
||||
@@ -155,6 +140,8 @@ class CategoriesActivity : BaseActivity<ActivityCategoriesBinding>(),
|
||||
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
|
||||
viewModel.reorderCategories(fromPos, toPos)
|
||||
}
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -4,13 +4,18 @@ import androidx.recyclerview.widget.DiffUtil
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
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(
|
||||
onItemClickListener: OnListItemClickListener<FavouriteCategory>,
|
||||
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
|
||||
allCategoriesToggleListener: AllCategoriesToggleListener,
|
||||
) : AsyncListDifferDelegationAdapter<CategoryListModel>(DiffCallback()) {
|
||||
|
||||
init {
|
||||
delegatesManager.addDelegate(categoryAD(onItemClickListener))
|
||||
.addDelegate(allCategoriesAD(allCategoriesToggleListener))
|
||||
setHasStableIds(true)
|
||||
}
|
||||
|
||||
@@ -18,29 +23,27 @@ class CategoriesAdapter(
|
||||
return items[position].id
|
||||
}
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
|
||||
private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
|
||||
|
||||
override fun areItemsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id
|
||||
}
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel,
|
||||
): Boolean = oldItem.id == newItem.id
|
||||
|
||||
override fun areContentsTheSame(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
): Boolean {
|
||||
return oldItem.id == newItem.id && oldItem.title == newItem.title
|
||||
&& oldItem.order == newItem.order
|
||||
}
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel,
|
||||
): Boolean = oldItem == newItem
|
||||
|
||||
override fun getChangePayload(
|
||||
oldItem: FavouriteCategory,
|
||||
newItem: FavouriteCategory,
|
||||
oldItem: CategoryListModel,
|
||||
newItem: CategoryListModel,
|
||||
): Any? = when {
|
||||
oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order
|
||||
else -> super.getChangePayload(oldItem, newItem)
|
||||
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.text.InputType
|
||||
import android.widget.Toast
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.dialog.TextInputDialog
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
|
||||
private const val MAX_TITLE_LENGTH = 24
|
||||
|
||||
class CategoriesEditDelegate(
|
||||
private val context: Context,
|
||||
private val callback: CategoriesEditCallback
|
||||
@@ -26,49 +21,8 @@ class CategoriesEditDelegate(
|
||||
.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 {
|
||||
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.*
|
||||
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.favourites.ui.categories.adapter.CategoryListModel
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import java.util.*
|
||||
|
||||
class FavouritesCategoriesViewModel(
|
||||
private val repository: FavouritesRepository
|
||||
private val repository: FavouritesRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var reorderJob: Job? = null
|
||||
|
||||
val categories = repository.observeCategories()
|
||||
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
fun createCategory(name: String) {
|
||||
launchJob {
|
||||
repository.addCategory(name)
|
||||
}
|
||||
}
|
||||
|
||||
fun renameCategory(id: Long, name: String) {
|
||||
launchJob {
|
||||
repository.renameCategory(id, name)
|
||||
}
|
||||
}
|
||||
val allCategories = combine(
|
||||
repository.observeCategories(),
|
||||
observeAllCategoriesVisible(),
|
||||
) { list, showAll ->
|
||||
mapCategories(list, showAll, true)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val visibleCategories = combine(
|
||||
repository.observeCategories(),
|
||||
observeAllCategoriesVisible(),
|
||||
) { list, showAll ->
|
||||
mapCategories(list, showAll, showAll)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
fun deleteCategory(id: Long) {
|
||||
launchJob {
|
||||
@@ -36,20 +39,40 @@ class FavouritesCategoriesViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setCategoryOrder(id: Long, order: SortOrder) {
|
||||
launchJob {
|
||||
repository.setCategoryOrder(id, order)
|
||||
}
|
||||
fun setAllCategoriesVisible(isVisible: Boolean) {
|
||||
settings.isAllFavouritesVisible = isVisible
|
||||
}
|
||||
|
||||
fun reorderCategories(oldPos: Int, newPos: Int) {
|
||||
val prevJob = reorderJob
|
||||
reorderJob = launchJob(Dispatchers.Default) {
|
||||
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 }
|
||||
Collections.swap(ids, oldPos, newPos)
|
||||
ids.remove(0L)
|
||||
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 com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
@@ -8,23 +8,23 @@ import org.koitharu.kotatsu.databinding.ItemCategoryBinding
|
||||
|
||||
fun categoryAD(
|
||||
clickListener: OnListItemClickListener<FavouriteCategory>
|
||||
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryBinding>(
|
||||
) = adapterDelegateViewBinding<CategoryListModel.CategoryItem, CategoryListModel, ItemCategoryBinding>(
|
||||
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
binding.imageViewMore.setOnClickListener {
|
||||
clickListener.onItemClick(item, it)
|
||||
clickListener.onItemClick(item.category, it)
|
||||
}
|
||||
@Suppress("ClickableViewAccessibility")
|
||||
binding.imageViewHandle.setOnTouchListener { v, event ->
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||
clickListener.onItemLongClick(item, itemView)
|
||||
clickListener.onItemLongClick(item.category, itemView)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
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.core.model.FavouriteCategory
|
||||
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.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.model.MangaCategoryItem
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -35,9 +35,6 @@ class FavouriteCategoriesDialog :
|
||||
}
|
||||
|
||||
private var adapter: MangaCategoriesAdapter? = null
|
||||
private val editDelegate by lazy(LazyThreadSafetyMode.NONE) {
|
||||
CategoriesEditDelegate(requireContext(), this@FavouriteCategoriesDialog)
|
||||
}
|
||||
|
||||
override fun onInflateView(
|
||||
inflater: LayoutInflater,
|
||||
@@ -62,7 +59,7 @@ class FavouriteCategoriesDialog :
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_create -> {
|
||||
editDelegate.createCategory()
|
||||
FavouritesCategoryEditActivity.newIntent(requireContext())
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
@@ -75,12 +72,6 @@ class FavouriteCategoriesDialog :
|
||||
|
||||
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>) {
|
||||
adapter?.items = categories
|
||||
}
|
||||
@@ -99,7 +90,7 @@ class FavouriteCategoriesDialog :
|
||||
fun show(fm: FragmentManager, manga: Collection<Manga>) = FavouriteCategoriesDialog().withArgs(1) {
|
||||
putParcelableArrayList(
|
||||
KEY_MANGA_LIST,
|
||||
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it.withoutChapters()) }
|
||||
manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withChapters = false) }
|
||||
)
|
||||
}.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) {
|
||||
// Fast path
|
||||
favouritesRepository.observeCategoriesIds(manga[0].id)
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.list
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.iterator
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
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.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
|
||||
class FavouritesListFragment : MangaListFragment() {
|
||||
@@ -16,17 +23,66 @@ class FavouritesListFragment : MangaListFragment() {
|
||||
}
|
||||
|
||||
private val categoryId: Long
|
||||
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: 0L
|
||||
get() = arguments?.getLong(ARG_CATEGORY_ID) ?: NO_ID
|
||||
|
||||
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 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 {
|
||||
mode.menuInflater.inflate(R.menu.mode_favourites, 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 {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
@@ -40,6 +96,7 @@ class FavouritesListFragment : MangaListFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
const val NO_ID = 0L
|
||||
private const val ARG_CATEGORY_ID = "category_id"
|
||||
|
||||
fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package org.koitharu.kotatsu.favourites.ui.list
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
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.ui.MangaListViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
@@ -24,8 +28,16 @@ class FavouritesListViewModel(
|
||||
settings: AppSettings,
|
||||
) : 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(
|
||||
if (categoryId == 0L) {
|
||||
if (categoryId == NO_ID) {
|
||||
repository.observeAll(SortOrder.NEWEST)
|
||||
} else {
|
||||
repository.observeAll(categoryId)
|
||||
@@ -37,7 +49,7 @@ class FavouritesListViewModel(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_heart_outline,
|
||||
textPrimary = R.string.text_empty_holder_primary,
|
||||
textSecondary = if (categoryId == 0L) {
|
||||
textSecondary = if (categoryId == NO_ID) {
|
||||
R.string.you_have_not_favourites_yet
|
||||
} else {
|
||||
R.string.favourites_category_empty
|
||||
@@ -60,7 +72,7 @@ class FavouritesListViewModel(
|
||||
return
|
||||
}
|
||||
launchJob {
|
||||
if (categoryId == 0L) {
|
||||
if (categoryId == NO_ID) {
|
||||
repository.removeFromFavourites(ids)
|
||||
} else {
|
||||
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 {
|
||||
return trackingRepository.getNewChaptersCount(mangaId)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@ import org.koitharu.kotatsu.history.ui.HistoryListViewModel
|
||||
val historyModule
|
||||
get() = module {
|
||||
|
||||
single { HistoryRepository(get(), get(), get()) }
|
||||
factory { HistoryRepository(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.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class HistoryListFragment : MangaListFragment() {
|
||||
|
||||
@@ -61,6 +62,13 @@ class HistoryListFragment : MangaListFragment() {
|
||||
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 {
|
||||
return when (item.itemId) {
|
||||
R.id.action_remove -> {
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.view.updateLayoutParams
|
||||
@@ -14,7 +13,7 @@ import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import coil.target.PoolableViewTarget
|
||||
import coil.target.ViewTarget
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import org.koin.android.ext.android.inject
|
||||
@@ -61,16 +60,12 @@ class ImageActivity : BaseActivity<ActivityImageBinding>() {
|
||||
|
||||
private class SsivTarget(
|
||||
override val view: SubsamplingScaleImageView,
|
||||
) : PoolableViewTarget<SubsamplingScaleImageView> {
|
||||
|
||||
override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
|
||||
) : ViewTarget<SubsamplingScaleImageView> {
|
||||
|
||||
override fun onError(error: Drawable?) = setDrawable(error)
|
||||
|
||||
override fun onSuccess(result: Drawable) = setDrawable(result)
|
||||
|
||||
override fun onClear() = setDrawable(null)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return (this === other) || (other is SsivTarget && view == other.view)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class ListModeSelectDialog : AlertDialogFragment<DialogListModeBinding>(),
|
||||
binding.textViewGridTitle.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.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.PaginationScrollListener
|
||||
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.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
@@ -243,8 +244,11 @@ abstract class MangaListFragment :
|
||||
ListMode.LIST -> {
|
||||
layoutManager = FitHeightLinearLayoutManager(context)
|
||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
||||
addItemDecoration(SpacingItemDecoration(spacing))
|
||||
updatePadding(left = spacing, right = spacing)
|
||||
val decoration = TypedSpacingItemDecoration(
|
||||
MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0,
|
||||
fallbackSpacing = spacing
|
||||
)
|
||||
addItemDecoration(decoration)
|
||||
}
|
||||
ListMode.DETAILED_LIST -> {
|
||||
layoutManager = FitHeightLinearLayoutManager(context)
|
||||
|
||||
@@ -28,6 +28,7 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74
|
||||
)
|
||||
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
|
||||
|
||||
init {
|
||||
hasBackground = false
|
||||
@@ -40,7 +41,7 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
|
||||
|
||||
override fun getItemId(parent: RecyclerView, child: View): Long {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -51,21 +52,24 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
|
||||
bounds: RectF,
|
||||
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.style = Paint.Style.FILL
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
paint.color = strokeColor
|
||||
paint.style = Paint.Style.STROKE
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
checkIcon?.run {
|
||||
setBounds(
|
||||
(bounds.left + iconOffset).toInt(),
|
||||
(bounds.top + iconOffset).toInt(),
|
||||
(bounds.left + iconOffset + intrinsicWidth).toInt(),
|
||||
(bounds.top + iconOffset + intrinsicHeight).toInt(),
|
||||
)
|
||||
draw(canvas)
|
||||
if (isCard) {
|
||||
checkIcon?.run {
|
||||
setBounds(
|
||||
(bounds.left + iconOffset).toInt(),
|
||||
(bounds.top + iconOffset).toInt(),
|
||||
(bounds.left + iconOffset + intrinsicWidth).toInt(),
|
||||
(bounds.top + iconOffset + intrinsicHeight).toInt(),
|
||||
)
|
||||
draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.size.Scale
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.badge.BadgeDrawable
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
@@ -43,6 +44,7 @@ fun mangaGridItemAD(
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
.error(R.drawable.ic_placeholder)
|
||||
.allowRgb565(true)
|
||||
.scale(Scale.FILL)
|
||||
.lifecycle(lifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
badge = itemView.bindBadge(badge, item.counter)
|
||||
@@ -53,7 +55,7 @@ fun mangaGridItemAD(
|
||||
badge = null
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
CoilUtils.clear(binding.imageViewCover)
|
||||
CoilUtils.dispose(binding.imageViewCover)
|
||||
binding.imageViewCover.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.size.Scale
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.badge.BadgeDrawable
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
@@ -44,6 +45,7 @@ fun mangaListDetailedItemAD(
|
||||
.placeholder(R.drawable.ic_placeholder)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
.error(R.drawable.ic_placeholder)
|
||||
.scale(Scale.FILL)
|
||||
.allowRgb565(true)
|
||||
.lifecycle(lifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
@@ -57,7 +59,7 @@ fun mangaListDetailedItemAD(
|
||||
badge = null
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
CoilUtils.clear(binding.imageViewCover)
|
||||
CoilUtils.dispose(binding.imageViewCover)
|
||||
binding.imageViewCover.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.size.Scale
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.badge.BadgeDrawable
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
@@ -44,6 +45,7 @@ fun mangaListItemAD(
|
||||
.placeholder(R.drawable.ic_placeholder)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
.error(R.drawable.ic_placeholder)
|
||||
.scale(Scale.FILL)
|
||||
.allowRgb565(true)
|
||||
.lifecycle(lifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
@@ -55,7 +57,7 @@ fun mangaListItemAD(
|
||||
badge = null
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
CoilUtils.clear(binding.imageViewCover)
|
||||
CoilUtils.dispose(binding.imageViewCover)
|
||||
binding.imageViewCover.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,18 @@ package org.koitharu.kotatsu.local
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
||||
import org.koitharu.kotatsu.utils.ExternalStorageHelper
|
||||
|
||||
val localModule
|
||||
get() = module {
|
||||
|
||||
single { LocalStorageManager(androidContext(), get()) }
|
||||
factory { LocalStorageManager(androidContext(), get()) }
|
||||
single { LocalMangaRepository(get()) }
|
||||
|
||||
factory { ExternalStorageHelper(androidContext()) }
|
||||
factory { DownloadManager.Factory(androidContext(), get(), 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.webkit.MimeTypeMap
|
||||
import coil.bitmap.BitmapPool
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.Options
|
||||
import coil.fetch.FetchResult
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.size.Size
|
||||
import coil.request.Options
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class CbzFetcher : Fetcher<Uri> {
|
||||
class CbzFetcher(
|
||||
private val uri: Uri,
|
||||
private val options: Options
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(
|
||||
pool: BitmapPool,
|
||||
data: Uri,
|
||||
size: Size,
|
||||
options: Options,
|
||||
): FetchResult = runInterruptible(Dispatchers.IO) {
|
||||
val zip = ZipFile(data.schemeSpecificPart)
|
||||
val entry = zip.getEntry(data.fragment)
|
||||
override suspend fun fetch() = runInterruptible(Dispatchers.IO) {
|
||||
val zip = ZipFile(uri.schemeSpecificPart)
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
val ext = MimeTypeMap.getFileExtensionFromUrl(entry.name)
|
||||
val bufferedSource = ExtraCloseableBufferedSource(
|
||||
zip.getInputStream(entry).source().buffer(),
|
||||
zip,
|
||||
)
|
||||
SourceResult(
|
||||
source = ExtraCloseableBufferedSource(
|
||||
zip.getInputStream(entry).source().buffer(),
|
||||
zip,
|
||||
source = ImageSource(
|
||||
source = bufferedSource,
|
||||
context = options.context,
|
||||
metadata = CbzMetadata(uri),
|
||||
),
|
||||
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 handles(data: Uri) = data.scheme == "cbz"
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
|
||||
return if (data.scheme == "cbz") {
|
||||
CbzFetcher(data, options)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CbzMetadata(val uri: Uri) : ImageSource.Metadata()
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import java.util.*
|
||||
class CbzFilter : FilenameFilter {
|
||||
|
||||
override fun accept(dir: File, name: String): Boolean {
|
||||
return isFileSupported(name)
|
||||
}
|
||||
|
||||
fun isFileSupported(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
return ext == "cbz" || ext == "zip"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import androidx.annotation.WorkerThread
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -11,7 +12,6 @@ import okhttp3.Cache
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ext.computeSize
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import java.io.File
|
||||
|
||||
private const val DIR_NAME = "manga"
|
||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||
@@ -71,7 +71,7 @@ class LocalStorageManager(
|
||||
private fun getAvailableStorageDirs(): MutableSet<File> {
|
||||
val result = LinkedHashSet<File>()
|
||||
result += File(context.filesDir, DIR_NAME)
|
||||
result += context.getExternalFilesDirs(DIR_NAME)
|
||||
context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result)
|
||||
result.retainAll { it.exists() || it.mkdirs() }
|
||||
return result
|
||||
}
|
||||
@@ -87,8 +87,8 @@ class LocalStorageManager(
|
||||
private fun getCacheDirs(subDir: String): MutableSet<File> {
|
||||
val result = LinkedHashSet<File>()
|
||||
result += File(context.cacheDir, subDir)
|
||||
context.externalCacheDirs.mapTo(result) {
|
||||
File(it, subDir)
|
||||
context.externalCacheDirs.mapNotNullTo(result) {
|
||||
File(it ?: return@mapNotNullTo null, subDir)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -110,4 +110,4 @@ class LocalStorageManager(
|
||||
private fun File.isWriteable() = runCatching {
|
||||
canWrite()
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,17 @@ class MangaIndex(source: String?) {
|
||||
json.put("state", manga.state?.name)
|
||||
json.put("source", manga.source.name)
|
||||
json.put("cover_large", manga.largeCoverUrl)
|
||||
json.put("tags", JSONArray().also { a ->
|
||||
for (tag in manga.tags) {
|
||||
val jo = JSONObject()
|
||||
jo.put("key", tag.key)
|
||||
jo.put("title", tag.title)
|
||||
a.put(jo)
|
||||
json.put(
|
||||
"tags",
|
||||
JSONArray().also { a ->
|
||||
for (tag in manga.tags) {
|
||||
val jo = JSONObject()
|
||||
jo.put("key", tag.key)
|
||||
jo.put("title", tag.title)
|
||||
a.put(jo)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
if (!append || !json.has("chapters")) {
|
||||
json.put("chapters", JSONObject())
|
||||
}
|
||||
@@ -84,11 +87,15 @@ class MangaIndex(source: String?) {
|
||||
jo.put("uploadDate", chapter.uploadDate)
|
||||
jo.put("scanlator", chapter.scanlator)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChapter(id: Long): Boolean {
|
||||
return json.getJSONObject("chapters").remove(id.toString()) != null
|
||||
}
|
||||
|
||||
fun setCoverEntry(name: String) {
|
||||
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 com.tomclaw.cache.DiskLruCache
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||
import org.koitharu.kotatsu.utils.FileSize
|
||||
import org.koitharu.kotatsu.utils.ext.subdir
|
||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
class PagesCache(context: Context) {
|
||||
|
||||
@@ -60,4 +60,4 @@ class PagesCache(context: Context) {
|
||||
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user