diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..b6d4254a1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -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
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/report_issue.yml b/.github/ISSUE_TEMPLATE/report_issue.yml
new file mode 100644
index 000000000..97aa936c3
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/report_issue.yml
@@ -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
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/request_feature.yml b/.github/ISSUE_TEMPLATE/request_feature.yml
new file mode 100644
index 000000000..2077efe27
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/request_feature.yml
@@ -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
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index a8c7b78f4..3ba4daee9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,8 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
+/.idea/kotlinScripting.xml
+/.idea/deploymentTargetDropDown.xml
.DS_Store
/build
/captures
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
deleted file mode 100644
index 27370aa28..000000000
--- a/.idea/deploymentTargetDropDown.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index a0de2a152..6e5389ed9 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -7,7 +7,7 @@
-
+
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index fe5866281..a6fb1fbe4 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,8 +1,10 @@
-
+
+
+
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 9cec1af6c..000000000
--- a/.travis.yml
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/README.md b/README.md
index b079e87af..cea436ae3 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/app/build.gradle b/app/build.gradle
index 1225098d7..913934c49 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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'
diff --git a/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt
new file mode 100644
index 000000000..4323e3a5f
--- /dev/null
+++ b/app/src/debug/java/org/koitharu/kotatsu/core/parser/DummyParser.kt
@@ -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
+ 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?,
+ sortOrder: SortOrder?
+ ): List {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun getPages(chapter: MangaChapter): List {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun getTags(): Set {
+ TODO("Not yet implemented")
+ }
+}
\ No newline at end of file
diff --git a/app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt b/app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt
new file mode 100644
index 000000000..596d4c626
--- /dev/null
+++ b/app/src/debug/java/org/koitharu/kotatsu/core/parser/MangaParser.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d5402bdd5..760ae8af9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -53,7 +53,8 @@
-
+ android:label="@string/manga_shelf"
+ android:theme="@style/Theme.Kotatsu.DialogWhenLarge">
@@ -103,13 +105,18 @@
android:windowSoftInputMode="adjustResize" />
-
+ android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
+
+
+
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
index 09a970eee..03b0dd53b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/domain/MangaUtils.kt
@@ -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
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
index 54cae9713..2fcfeac76 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseActivity.kt
@@ -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 : AppCompatActivity(),
+abstract class BaseActivity :
+ AppCompatActivity(),
WindowInsetsDelegate.WindowInsetsListener {
protected lateinit var binding: B
@@ -123,4 +124,4 @@ abstract class BaseActivity : AppCompatActivity(),
super.onBackPressed()
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
index 0672b880f..75503afc5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseBottomSheet.kt
@@ -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 : BottomSheetDialogFragment() {
@@ -43,7 +44,9 @@ abstract class BaseBottomSheet : 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
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt
index 50d8fe803..39233e28f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/BaseViewModel.kt
@@ -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()
- val isLoading = MutableLiveData(false)
+ val isLoading = CountedBooleanLiveData()
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt
new file mode 100644
index 000000000..241d13f94
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/CoroutineIntentService.kt
@@ -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?)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt
new file mode 100644
index 000000000..d3b911ace
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/AppBottomSheetDialog.kt
@@ -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
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt
index 3c048aa54..58e353ca8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/dialog/StorageSelectDialog.kt
@@ -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
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt
new file mode 100644
index 000000000..5662f026a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/list/decor/TypedSpacingItemDecoration.kt
@@ -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,
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt
new file mode 100644
index 000000000..cb54ef7db
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/util/CountedBooleanLiveData.kt
@@ -0,0 +1,20 @@
+package org.koitharu.kotatsu.base.ui.util
+
+import androidx.lifecycle.MutableLiveData
+
+class CountedBooleanLiveData : MutableLiveData(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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt
index 9c8366293..5d601d67c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/CheckableImageView.kt
@@ -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)
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
index 7004192ad..18d7262dc 100644
--- a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/ListItemTextView.kt
@@ -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)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt
new file mode 100644
index 000000000..0d915dc4f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/base/ui/widgets/WindowInsetHolder.kt
@@ -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
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
index fc9bd599c..442808bad 100644
--- a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserActivity.kt
@@ -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(), BrowserCallback {
@@ -28,6 +29,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback
}
with(binding.webView.settings) {
javaScriptEnabled = true
+ userAgentString = UserAgentInterceptor.userAgent
}
binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
diff --git a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt
index f39da5fb5..5e801a93f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/browser/BrowserClient.kt
@@ -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(mode = LazyThreadSafetyMode.SYNCHRONIZED)
-
override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url)
callback.onLoadingStateChanged(isLoading = false)
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt
deleted file mode 100644
index 6a90243fa..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt
+++ /dev/null
@@ -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))
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
index 3d2f668a6..4b42b4c60 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt
@@ -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
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt
new file mode 100644
index 000000000..25e1d3688
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipInput.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
new file mode 100644
index 000000000..f01dc73d9
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
@@ -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))
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
index ba4a8b6a3..57fd4d6ee 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt
@@ -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(
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt
index dadbb05eb..215d02259 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt
@@ -5,5 +5,5 @@ import org.koin.dsl.module
val databaseModule
get() = module {
- single { MangaDatabase.create(androidContext()) }
+ single { MangaDatabase(androidContext()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
index 35c52192c..6257a8456 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt
@@ -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)
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
index 010a2653f..436455014 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt
@@ -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()
- }
-}
\ No newline at end of file
+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()
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt
index 4bd188966..f8352524b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/TracksDao.kt
@@ -10,6 +10,9 @@ abstract class TracksDao {
@Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List
+ @Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
+ abstract suspend fun findAll(ids: Collection): List
+
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): TrackEntity?
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt
new file mode 100644
index 000000000..59cba96ef
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration9To10.kt
@@ -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")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt
index de5256337..7da9e309f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/github/GithubModule.kt
@@ -4,7 +4,7 @@ import org.koin.dsl.module
val githubModule
get() = module {
- single {
+ factory {
GithubRepository(get())
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
index 655a0eb08..798ec2fbd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/FavouriteCategory.kt
@@ -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
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
index 3079b6c76..ed5594c42 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt
@@ -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.ids() = mapToSet { it.id }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
index fd9b6cfa1..b302ce634 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt
@@ -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
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt
index af589dea3..b6491f154 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/network/UserAgentInterceptor.kt
@@ -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,
+ )
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt
index 98ebe3a6a..9e8b18a52 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/os/ShortcutsRepository.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt
index 4a386d3f8..ba5412a50 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/FaviconMapper.kt
@@ -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 {
+class FaviconMapper : Mapper {
- 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"
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
index 1857064b3..ca4cc495b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/MangaRepository.kt
@@ -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::class.java)
+
operator fun invoke(source: MangaSource): MangaRepository {
- return if (source == MangaSource.LOCAL) {
- get()
- } else {
- RemoteMangaRepository(source, get())
+ if (source == MangaSource.LOCAL) {
+ return get()
+ }
+ cache[source]?.get()?.let { return it }
+ return synchronized(cache) {
+ cache[source]?.get()?.let { return it }
+ val repository = RemoteMangaRepository(MangaParser(source, get()))
+ cache[source] = WeakReference(repository)
+ repository
}
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
index a532f6e6e..14a113e24 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt
@@ -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
get() = parser.sortOrders
@@ -28,7 +24,7 @@ class RemoteMangaRepository(
offset: Int,
query: String?,
tags: Set?,
- sortOrder: SortOrder?
+ sortOrder: SortOrder?,
): List = 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
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index 8583a7adc..ac70b392a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -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
+ 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
+ var sourcesOrder: List
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
+ 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) {
+ 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 {
- 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)
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
index 0d38d95ce..03bafa077 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/DateTimeAgo.kt
@@ -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)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt
index 26ccbf44a..54529b37c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/ui/uiModule.kt
@@ -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().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()
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
new file mode 100644
index 000000000..d34e753ab
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
@@ -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()
+ 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
index 96f369c56..91698a76b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt
@@ -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
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
index ef427f091..a1920bf80 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsActivity.kt
@@ -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(), TabLayoutMediator.TabConfigurationStrategy,
+class DetailsActivity :
+ BaseActivity(),
+ TabLayoutMediator.TabConfigurationStrategy,
AdapterView.OnItemSelectedListener {
private val viewModel by viewModel {
@@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity(), 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(), 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(), TabLayoutMediato
}
}
+ private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List) {
+ 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(), TabLayoutMediato
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
index ab4ae21e1..99c7319e3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsFragment.kt
@@ -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)
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
index 722a5bf1a..2f242ead6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt
@@ -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 }
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
index 6f37c8e53..2d5b90840 100644
--- a/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt
@@ -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
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
index e1933a683..58335ed31 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/domain/DownloadManager.kt
@@ -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?,
+ chaptersIds: LongArray?,
startId: Int,
): ProgressJob {
val stateFlow = MutableStateFlow(
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?,
+ chaptersIds: LongArray?,
outState: MutableStateFlow,
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) = 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) =
+ 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,
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
index dd3fa3035..528908bfb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadNotification.kt
@@ -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)
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
index a005346f4..05c6df6bd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/DownloadService.kt
@@ -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().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(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?,
+ chaptersIds: LongArray?,
): ProgressJob {
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
listenJob(job)
@@ -106,19 +99,28 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob) {
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
+
+ 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 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)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt
index c7d0b0191..679405295 100644
--- a/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/download/ui/service/ForegroundNotificationSwitcher.kt
@@ -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?,
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
index 9cd6e751e..6d880abb9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/FavouritesModule.kt
@@ -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()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
index 801f2566a..c6a65c78e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
@@ -11,4 +11,5 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
sortKey = sortKey,
order = SortOrder(order, SortOrder.NEWEST),
createdAt = Date(createdAt),
+ isTrackingEnabled = track,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
index 436dc12ea..d0ada21da 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt
@@ -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
@@ -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)
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
index d2b0bc7ed..5fe02f019 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt
@@ -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,
)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt
index 9e5da45f7..89fcc92fb 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt
@@ -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
+ @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE category_id = :categoryId)")
+ abstract suspend fun findAllManga(categoryId: Int): List
+
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
abstract suspend fun findAllManga(): List
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
index 5cb19fda5..6e995696b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
@@ -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 {
val entities = db.favouritesDao.findAll()
@@ -48,6 +52,11 @@ class FavouritesRepository(private val db: MangaDatabase) {
}.distinctUntilChanged()
}
+ fun observeCategory(id: Long): Flow {
+ return db.favouriteCategoriesDao.observe(id)
+ .map { it.toFavouriteCategory() }
+ }
+
fun observeCategories(mangaId: Long): Flow> {
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) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
index 9fc69a228..61fae0369 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesContainerFragment.kt
@@ -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(),
@@ -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) {
- pagerAdapter?.replaceData(wrapCategories(categories))
+ private fun onCategoriesChanged(categories: List) {
+ 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,36 +134,38 @@ 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): List {
- val data = ArrayList(categories.size + 1)
- data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date())
- data += categories
- return data
+ private fun TabLayout.setTabsEnabled(enabled: Boolean) {
+ val tabStrip = getChildAt(0) as? ViewGroup ?: return
+ for (tab in tabStrip.children) {
+ tab.isEnabled = enabled
+ }
}
- 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
+ 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
}
- submenu.setGroupCheckable(R.id.group_order, true, true)
+ menu.show()
}
- private fun TabLayout.setTabsEnabled(enabled: Boolean) {
- val tabStrip = getChildAt(0) as? ViewGroup ?: return
- for (tab in tabStrip.children) {
- tab.isEnabled = enabled
+ 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 {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt
index 29de80809..329e06751 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesPagerAdapter.kt
@@ -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) {
+ fun replaceData(data: List) {
differ.submitList(data)
}
@@ -50,16 +55,22 @@ class FavouritesPagerAdapter(
return longClickListener.onTabLongClick(v, item)
}
- private class DiffCallback : DiffUtil.ItemCallback() {
+ private class DiffCallback : DiffUtil.ItemCallback() {
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
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt
index acc4f89c0..13fca87c9 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/FavouritesTabLongClickListener.kt
@@ -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
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt
new file mode 100644
index 000000000..380722b84
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/AllCategoriesToggleListener.kt
@@ -0,0 +1,6 @@
+package org.koitharu.kotatsu.favourites.ui.categories
+
+interface AllCategoriesToggleListener {
+
+ fun onAllCategoriesToggle(isVisible: Boolean)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt
index c147351b0..80fd2a137 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesActivity.kt
@@ -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(),
+class CategoriesActivity :
+ BaseActivity(),
OnListItemClickListener,
- View.OnClickListener, CategoriesEditDelegate.CategoriesEditCallback {
+ View.OnClickListener,
+ CategoriesEditDelegate.CategoriesEditCallback,
+ AllCategoriesToggleListener {
private val viewModel by viewModel()
@@ -39,7 +42,7 @@ class CategoriesActivity : BaseActivity(),
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(),
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 {
rightMargin = topMargin + insets.right
@@ -90,11 +92,11 @@ class CategoriesActivity : BaseActivity(),
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) {
+ private fun onCategoriesChanged(categories: List) {
adapter.items = categories
binding.textViewHolder.isVisible = categories.isEmpty()
}
@@ -108,40 +110,23 @@ class CategoriesActivity : BaseActivity(),
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(),
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderCategories(fromPos, toPos)
}
+
+ override fun isLongPressDragEnabled(): Boolean = false
}
companion object {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt
index adf19ca9c..7a5620158 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesAdapter.kt
@@ -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,
-) : AsyncListDifferDelegationAdapter(DiffCallback()) {
+ allCategoriesToggleListener: AllCategoriesToggleListener,
+) : AsyncListDifferDelegationAdapter(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() {
+ private class DiffCallback : DiffUtil.ItemCallback() {
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
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
index 603a70ee3..f7a98c078 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoriesEditDelegate.kt
@@ -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)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
index c87e44c76..31c1e11de 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt
@@ -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,
+ isAllCategoriesVisible: Boolean,
+ withAllCategoriesItem: Boolean,
+ ): List {
+ val result = ArrayList(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()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt
new file mode 100644
index 000000000..113198bfa
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/AllCategoriesAD.kt
@@ -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(
+ { inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) }
+) {
+
+ binding.imageViewToggle.setOnClickListener {
+ allCategoriesToggleListener.onAllCategoriesToggle(!item.isVisible)
+ }
+
+ bind {
+ binding.imageViewToggle.isChecked = item.isVisible
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoryAD.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
similarity index 68%
rename from app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoryAD.kt
rename to app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
index 97b38d926..d840b783f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/CategoryAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt
@@ -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
-) = adapterDelegateViewBinding(
+) = adapterDelegateViewBinding(
{ 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
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt
new file mode 100644
index 000000000..899b73e1c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryListModel.kt
@@ -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
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
new file mode 100644
index 000000000..27239c0dc
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
@@ -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(), AdapterView.OnItemClickListener {
+
+ private val viewModel by viewModel {
+ 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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
new file mode 100644
index 000000000..78446dd14
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
@@ -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()
+ val category = MutableLiveData()
+
+ 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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt
index 184c6cfe6..95f733006 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/FavouriteCategoriesDialog.kt
@@ -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) {
adapter?.items = categories
}
@@ -99,7 +90,7 @@ class FavouriteCategoriesDialog :
fun show(fm: FragmentManager, manga: Collection) = 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)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
index 84f9cf8f9..b9f906549 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/categories/select/MangaCategoriesViewModel.kt
@@ -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)
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
index 1bfd8766a..8d4b9e419 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt
@@ -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) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
index 476fa6fb1..06d5bee3d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
@@ -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 = 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)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
index 74fb0ef40..246cb3a5f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/HistoryModule.kt
@@ -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()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
index bdc5cc5c2..6980b80ee 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
@@ -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 -> {
diff --git a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt
index 8e674701a..28f58887b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/image/ui/ImageActivity.kt
@@ -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() {
private class SsivTarget(
override val view: SubsamplingScaleImageView,
- ) : PoolableViewTarget {
-
- override fun onStart(placeholder: Drawable?) = setDrawable(placeholder)
+ ) : ViewTarget {
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)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt
index 46dfaefd5..a7ae219e8 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/ListModeSelectDialog.kt
@@ -43,7 +43,7 @@ class ListModeSelectDialog : AlertDialogFragment(),
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)
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
index d0f5f9d78..4b8c2b570 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
@@ -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)
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt
index 939d5b256..3ef530824 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/MangaSelectionDecoration.kt
@@ -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() ?: 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)
+ }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
index ced1697b0..f1d6d3af4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
@@ -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)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
index 10e9a473d..74a67378f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
@@ -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)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
index 18696de6b..5087baa3f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
@@ -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)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt
index cda2dbad6..f8cb28657 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/LocalModule.kt
@@ -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()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
index 4e2746cec..c773a05d3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFetcher.kt
@@ -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 {
+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 {
+
+ override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
+ return if (data.scheme == "cbz") {
+ CbzFetcher(data, options)
+ } else {
+ null
+ }
+ }
+ }
- override fun handles(data: Uri) = data.scheme == "cbz"
+ class CbzMetadata(val uri: Uri) : ImageSource.Metadata()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
index 106cbaacd..8b8fca986 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/CbzFilter.kt
@@ -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"
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
index 2cbddf856..6e9c1e399 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/LocalStorageManager.kt
@@ -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 {
val result = LinkedHashSet()
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 {
val result = LinkedHashSet()
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)
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt
index f7d1a8aaf..3a585be9c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaIndex.kt
@@ -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)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt
deleted file mode 100644
index 0aacb4ee7..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/MangaZip.kt
+++ /dev/null
@@ -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)
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
index bf0da97f9..73dd83bb4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/PagesCache.kt
@@ -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()
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt
new file mode 100644
index 000000000..8aef4fead
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/data/TempFileFilter.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt b/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt
deleted file mode 100644
index fe61169b2..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/local/data/WritableCbzFile.kt
+++ /dev/null
@@ -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)
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt
new file mode 100644
index 000000000..53e2d474a
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/CbzMangaOutput.kt
@@ -0,0 +1,153 @@
+package org.koitharu.kotatsu.local.domain
+
+import androidx.annotation.WorkerThread
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import okio.Closeable
+import org.koitharu.kotatsu.core.zip.ZipOutput
+import org.koitharu.kotatsu.local.data.MangaIndex
+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.deleteAwait
+import org.koitharu.kotatsu.utils.ext.readText
+import java.io.File
+import java.util.zip.ZipFile
+
+class CbzMangaOutput(
+ val file: File,
+ manga: Manga,
+) : Closeable {
+
+ private val output = ZipOutput(File(file.path + ".tmp"))
+ private val index = MangaIndex(null)
+
+ init {
+ index.setMangaInfo(manga, false)
+ }
+
+ suspend fun mergeWithExisting() {
+ if (file.exists()) {
+ runInterruptible(Dispatchers.IO) {
+ mergeWith(file)
+ }
+ }
+ }
+
+ suspend fun addCover(file: File, ext: String) {
+ val name = buildString {
+ append(FILENAME_PATTERN.format(0, 0, 0))
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ output.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.branch.hashCode(), chapter.number, pageNumber))
+ if (ext.isNotEmpty() && ext.length <= 4) {
+ append('.')
+ append(ext)
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ output.put(name, file)
+ }
+ index.addChapter(chapter)
+ }
+
+ suspend fun finalize() {
+ runInterruptible(Dispatchers.IO) {
+ output.put(ENTRY_NAME_INDEX, index.toString())
+ output.finish()
+ output.close()
+ }
+ file.deleteAwait()
+ output.file.renameTo(file)
+ }
+
+ suspend fun cleanup() {
+ output.file.deleteAwait()
+ }
+
+ override fun close() {
+ output.close()
+ }
+
+ @WorkerThread
+ private fun mergeWith(other: File) {
+ var otherIndex: MangaIndex? = null
+ ZipFile(other).use { zip ->
+ for (entry in zip.entries()) {
+ if (entry.name == ENTRY_NAME_INDEX) {
+ otherIndex = MangaIndex(
+ zip.getInputStream(entry).use {
+ it.reader().readText()
+ }
+ )
+ } else {
+ output.copyEntryFrom(zip, entry)
+ }
+ }
+ }
+ otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
+ for (chapter in chapters) {
+ index.addChapter(chapter)
+ }
+ }
+ }
+
+ companion object {
+
+ private const val FILENAME_PATTERN = "%08d_%03d%03d"
+
+ const val ENTRY_NAME_INDEX = "index.json"
+
+ fun get(root: File, manga: Manga): CbzMangaOutput {
+ val name = manga.title.toFileNameSafe() + ".cbz"
+ val file = File(root, name)
+ return CbzMangaOutput(file, manga)
+ }
+
+ @WorkerThread
+ fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set) {
+ ZipFile(subject.file).use { zip ->
+ val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
+ idsToRemove.forEach { id -> index.removeChapter(id) }
+ val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
+ index.getChapterNamesPattern(it)
+ }
+ val coverEntryName = index.getCoverEntry()
+ for (entry in zip.entries()) {
+ when {
+ entry.name == ENTRY_NAME_INDEX -> {
+ subject.output.put(ENTRY_NAME_INDEX, index.toString())
+ }
+ entry.isDirectory -> {
+ subject.output.addDirectory(entry.name)
+ }
+ entry.name == coverEntryName -> {
+ subject.output.copyEntryFrom(zip, entry)
+ }
+ else -> {
+ val name = entry.name.substringBefore('.')
+ if (patterns.any { it.matches(name) }) {
+ subject.output.copyEntryFrom(zip, entry)
+ }
+ }
+ }
+ }
+ subject.output.finish()
+ subject.output.close()
+ subject.file.delete()
+ subject.output.file.renameTo(subject.file)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
index c6137a485..e034d0672 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/domain/LocalMangaRepository.kt
@@ -3,22 +3,22 @@ package org.koitharu.kotatsu.local.domain
import android.annotation.SuppressLint
import android.net.Uri
import android.webkit.MimeTypeMap
+import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runInterruptible
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.*
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.MangaIndex
-import org.koitharu.kotatsu.local.data.MangaZip
+import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toCamelCase
import org.koitharu.kotatsu.utils.AlphanumComparator
+import org.koitharu.kotatsu.utils.CompositeMutex
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.readText
import org.koitharu.kotatsu.utils.ext.resolveName
@@ -27,11 +27,15 @@ import java.io.IOException
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
+import kotlin.coroutines.CoroutineContext
+
+private const val MAX_PARALLELISM = 4
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
override val source = MangaSource.LOCAL
private val filenameFilter = CbzFilter()
+ private val locks = CompositeMutex()
override suspend fun getList(
offset: Int,
@@ -39,27 +43,43 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
tags: Set?,
sortOrder: SortOrder?
): List {
- require(offset == 0) {
- "LocalMangaRepository does not support pagination"
+ if (offset > 0) {
+ return emptyList()
}
val files = getAllFiles()
- return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
+ val list = coroutineScope {
+ val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
+ files.map { file ->
+ getFromFileAsync(file, dispatcher)
+ }.awaitAll()
+ }.filterNotNullTo(ArrayList(files.size))
+ if (!query.isNullOrEmpty()) {
+ list.retainAll { x ->
+ x.title.contains(query, ignoreCase = true) ||
+ x.altTitle?.contains(query, ignoreCase = true) == true
+ }
+ }
+ if (!tags.isNullOrEmpty()) {
+ list.retainAll { x ->
+ x.tags.containsAll(tags)
+ }
+ }
+ return list
}
override suspend fun getDetails(manga: Manga) = when {
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
"Manga is not local or saved"
}
- manga.chapters == null -> getFromFile(Uri.parse(manga.url).toFile())
- else -> manga
+ else -> getFromFile(Uri.parse(manga.url).toFile())
}
override suspend fun getPages(chapter: MangaChapter): List {
- return runInterruptible(Dispatchers.IO){
+ return runInterruptible(Dispatchers.IO) {
val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file)
- val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
+ val index = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
var entries = zip.entries().asSequence()
entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter)
@@ -94,10 +114,25 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return file.deleteAwait()
}
+ suspend fun deleteChapters(manga: Manga, ids: Set) {
+ lockManga(manga.id)
+ try {
+ runInterruptible(Dispatchers.IO) {
+ val uri = Uri.parse(manga.url)
+ val file = uri.toFile()
+ val cbz = CbzMangaOutput(file, manga)
+ CbzMangaOutput.filterChapters(cbz, ids)
+ }
+ } finally {
+ unlockManga(manga.id)
+ }
+ }
+
+ @WorkerThread
@SuppressLint("DefaultLocale")
fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
val fileUri = file.toUri().toString()
- val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
+ val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
val info = index?.getMangaInfo()
if (index != null && info != null) {
@@ -158,7 +193,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}.getOrNull() ?: return null
return runInterruptible(Dispatchers.IO) {
ZipFile(file).use { zip ->
- val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
+ val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
index?.getMangaInfo()
}
@@ -170,7 +205,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return runInterruptible(Dispatchers.IO) {
for (file in files) {
val index = ZipFile(file).use { zip ->
- val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
+ val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
entry?.let(zip::readText)?.let(::MangaIndex)
} ?: continue
val info = index.getMangaInfo() ?: continue
@@ -187,6 +222,15 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}
}
+ private fun CoroutineScope.getFromFileAsync(
+ file: File,
+ context: CoroutineContext,
+ ): Deferred = async(context) {
+ runInterruptible {
+ runCatching { getFromFile(file) }.getOrNull()
+ }
+ }
+
private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
private fun findFirstImageEntry(entries: Enumeration): ZipEntry? {
@@ -211,7 +255,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
withContext(Dispatchers.IO) {
val name = contentResolver.resolveName(uri)
?: throw IOException("Cannot fetch name from uri: $uri")
- if (!isFileSupported(name)) {
+ if (!filenameFilter.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
}
val dest = File(
@@ -228,15 +272,29 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}
}
- fun isFileSupported(name: String): Boolean {
- val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
- return ext == "cbz" || ext == "zip"
- }
-
suspend fun getOutputDir(): File? {
return storageManager.getDefaultWriteableDir()
}
+ suspend fun cleanup() {
+ val dirs = storageManager.getWriteableDirs()
+ runInterruptible(Dispatchers.IO) {
+ dirs.flatMap { dir ->
+ dir.listFiles(TempFileFilter())?.toList().orEmpty()
+ }.forEach { file ->
+ file.delete()
+ }
+ }
+ }
+
+ suspend fun lockManga(id: Long) {
+ locks.lock(id)
+ }
+
+ suspend fun unlockManga(id: Long) {
+ locks.unlock(id)
+ }
+
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt
new file mode 100644
index 000000000..3bc53726c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt
@@ -0,0 +1,80 @@
+package org.koitharu.kotatsu.local.ui
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import org.koin.android.ext.android.inject
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.CoroutineIntentService
+import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.download.ui.service.DownloadService
+import org.koitharu.kotatsu.local.domain.LocalMangaRepository
+import org.koitharu.kotatsu.parsers.model.Manga
+
+class LocalChaptersRemoveService : CoroutineIntentService() {
+
+ private val localMangaRepository by inject()
+
+ override suspend fun processIntent(intent: Intent?) {
+ val manga = intent?.getParcelableExtra(EXTRA_MANGA)?.manga ?: return
+ val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
+ startForeground()
+ val mangaWithChapters = localMangaRepository.getDetails(manga)
+ localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
+ sendBroadcast(
+ Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE)
+ .putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
+ )
+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ }
+
+ private fun startForeground() {
+ val title = getString(R.string.local_manga_processing)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
+ channel.setShowBadge(false)
+ channel.enableVibration(false)
+ channel.setSound(null, null)
+ channel.enableLights(false)
+ manager.createNotificationChannel(channel)
+ }
+
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(title)
+ .setPriority(NotificationCompat.PRIORITY_MIN)
+ .setDefaults(0)
+ .setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
+ .setSilent(true)
+ .setProgress(0, 0, true)
+ .setSmallIcon(android.R.drawable.stat_notify_sync)
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
+ .setOngoing(true)
+ .build()
+ startForeground(NOTIFICATION_ID, notification)
+ }
+
+ companion object {
+
+ private const val CHANNEL_ID = "local_processing"
+ private const val NOTIFICATION_ID = 21
+
+ private const val EXTRA_MANGA = "manga"
+ private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
+
+ fun start(context: Context, manga: Manga, chaptersIds: Collection) {
+ if (chaptersIds.isEmpty()) {
+ return
+ }
+ val intent = Intent(context, LocalChaptersRemoveService::class.java)
+ intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
+ intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
+ ContextCompat.startForegroundService(context, intent)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
index f4568b23f..07dea24b5 100644
--- a/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
@@ -3,16 +3,18 @@ package org.koitharu.kotatsu.local.ui
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
-import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
@@ -21,6 +23,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.progress.Progress
+import java.io.IOException
class LocalListViewModel(
private val repository: LocalMangaRepository,
@@ -64,6 +67,7 @@ class LocalListViewModel(
init {
onRefresh()
+ cleanup()
}
override fun onRefresh() {
@@ -116,4 +120,18 @@ class LocalListViewModel(
listError.value = e
}
}
+
+ private fun cleanup() {
+ if (!DownloadService.isRunning) {
+ viewModelScope.launch {
+ runCatching {
+ repository.cleanup()
+ }.onFailure { error ->
+ if (BuildConfig.DEBUG) {
+ error.printStackTrace()
+ }
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt
index e8c69d824..c6e11107b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/MainModule.kt
@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.main.ui.protect.ProtectViewModel
val mainModule
get() = module {
single { AppProtectHelper(get()) }
- single { ShortcutsRepository(androidContext(), get(), get(), get()) }
+ factory { ShortcutsRepository(androidContext(), get(), get(), get()) }
viewModel { MainViewModel(get(), get()) }
viewModel { ProtectViewModel(get(), get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
index 29f0c01ac..687fb8015 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -7,8 +7,10 @@ import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
+import androidx.activity.result.ActivityResultCallback
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.view.ActionMode
+import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.view.*
@@ -17,12 +19,14 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
+import androidx.transition.TransitionManager
+import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
@@ -49,13 +53,14 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.AppUpdateChecker
import org.koitharu.kotatsu.settings.SettingsActivity
+import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
+import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.*
-import com.google.android.material.R as materialR
private const val TAG_PRIMARY = "primary"
private const val TAG_SEARCH = "search"
@@ -74,6 +79,7 @@ class MainActivity :
private lateinit var navHeaderBinding: NavigationHeaderBinding
private var drawerToggle: ActionBarDrawerToggle? = null
private var drawer: DrawerLayout? = null
+ private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback())
override val appBar: AppBarLayout
get() = binding.appbar
@@ -118,6 +124,7 @@ class MainActivity :
}
binding.fab.setOnClickListener(this@MainActivity)
+ binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null
supportFragmentManager.findFragmentByTag(TAG_PRIMARY)?.let {
if (it is HistoryListFragment) binding.fab.show() else binding.fab.hide()
@@ -276,6 +283,19 @@ class MainActivity :
searchSuggestionViewModel.onQueryChanged(query)
}
+ override fun onVoiceSearchClick() {
+ val options = binding.searchView.drawableEnd?.bounds?.let { bounds ->
+ ActivityOptionsCompat.makeScaleUpAnimation(
+ binding.searchView,
+ bounds.centerX(),
+ bounds.centerY(),
+ bounds.width(),
+ bounds.height(),
+ )
+ }
+ voiceInputLauncher.tryLaunch(binding.searchView.hint?.toString(), options)
+ }
+
override fun onClearSearchHistory() {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.clear_search_history)
@@ -372,26 +392,44 @@ class MainActivity :
}
private fun onSearchOpened() {
+ TransitionManager.beginDelayedTransition(binding.appbar)
drawerToggle?.isDrawerIndicatorEnabled = false
+ binding.toolbarCard.updateLayoutParams {
+ scrollFlags = SCROLL_FLAG_NO_SCROLL
+ }
+ binding.appbar.setBackgroundColor(getThemeColor(materialR.attr.colorSurfaceVariant))
+ binding.appbar.updatePadding(left = 0, right = 0)
adjustDrawerLock()
adjustFabVisibility(isSearchOpened = true)
}
private fun onSearchClosed() {
+ TransitionManager.beginDelayedTransition(binding.appbar)
drawerToggle?.isDrawerIndicatorEnabled = true
+ binding.toolbarCard.updateLayoutParams {
+ scrollFlags = SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS
+ }
+ binding.appbar.background = null
+ val padding = resources.getDimensionPixelOffset(R.dimen.margin_normal)
+ binding.appbar.updatePadding(left = padding, right = padding)
adjustDrawerLock()
adjustFabVisibility(isSearchOpened = false)
}
private fun onFirstStart() {
- lifecycleScope.launch(Dispatchers.Default) {
- TrackWorker.setup(applicationContext)
- SuggestionsWorker.setup(applicationContext)
- AppUpdateChecker(this@MainActivity).checkIfNeeded()
- if (!get().isSourcesSelected) {
- withContext(Dispatchers.Main) {
- OnboardDialogFragment.showWelcome(supportFragmentManager)
- }
+ lifecycleScope.launchWhenResumed {
+ val isUpdateSupported = withContext(Dispatchers.Default) {
+ TrackWorker.setup(applicationContext)
+ SuggestionsWorker.setup(applicationContext)
+ AppUpdateChecker.isUpdateSupported(this@MainActivity)
+ }
+ if (isUpdateSupported) {
+ AppUpdateChecker(this@MainActivity).checkIfNeeded()
+ }
+ val settings = get()
+ when {
+ !settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
+ settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
}
}
}
@@ -420,4 +458,13 @@ class MainActivity :
if (isLocked) DrawerLayout.LOCK_MODE_LOCKED_CLOSED else DrawerLayout.LOCK_MODE_UNLOCKED
)
}
+
+ private inner class VoiceInputCallback : ActivityResultCallback {
+
+ override fun onActivityResult(result: String?) {
+ if (result != null) {
+ binding.searchView.query = result
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt
index 35f7321a5..a92d866d2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/protect/AppProtectHelper.kt
@@ -34,7 +34,7 @@ class AppProtectHelper(private val settings: AppSettings) : Application.Activity
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
- if (activity !is ProtectActivity && activity.isTaskRoot) {
+ if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) {
restoreLock()
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
index 0d0aec467..c83ad608b 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ReaderModule.kt
@@ -1,17 +1,21 @@
package org.koitharu.kotatsu.reader
+import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.local.data.PagesCache
+import org.koitharu.kotatsu.reader.ui.PageSaveHelper
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
val readerModule
get() = module {
- single { MangaDataRepository(get()) }
+ factory { MangaDataRepository(get()) }
single { PagesCache(get()) }
+ factory { PageSaveHelper(get(), androidContext()) }
+
viewModel { params ->
ReaderViewModel(
intent = params[0],
@@ -21,7 +25,7 @@ val readerModule
historyRepository = get(),
shortcutsRepository = get(),
settings = get(),
- externalStorageHelper = get(),
+ pageSaveHelper = get(),
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt
index 250a63a49..696f48cc3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/domain/PageLoader.kt
@@ -6,10 +6,6 @@ import android.graphics.BitmapFactory
import android.net.Uri
import androidx.collection.LongSparseArray
import androidx.collection.set
-import java.io.File
-import java.util.*
-import java.util.concurrent.atomic.AtomicInteger
-import java.util.zip.ZipFile
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -31,6 +27,10 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
+import java.io.File
+import java.util.*
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.zip.ZipFile
private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10
@@ -113,10 +113,19 @@ class PageLoader : KoinComponent, Closeable {
}
}
+ suspend fun getPageUrl(page: MangaPage): String {
+ return getRepository(page.source).getPageUrl(page)
+ }
+
private fun onIdle() {
synchronized(prefetchQueue) {
- val page = prefetchQueue.pollFirst() ?: return
- tasks[page.id] = loadPageAsyncImpl(page)
+ while (prefetchQueue.isNotEmpty()) {
+ val page = prefetchQueue.pollFirst() ?: return
+ if (cache[page.url] == null) {
+ tasks[page.id] = loadPageAsyncImpl(page)
+ return
+ }
+ }
}
}
@@ -146,7 +155,7 @@ class PageLoader : KoinComponent, Closeable {
}
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow): File {
- val pageUrl = getRepository(page.source).getPageUrl(page)
+ val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl)
return if (uri.scheme == "cbz") {
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt
new file mode 100644
index 000000000..3e19c7036
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt
@@ -0,0 +1,77 @@
+package org.koitharu.kotatsu.reader.ui
+
+import android.content.Context
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.activity.result.ActivityResultLauncher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okio.IOException
+import org.koitharu.kotatsu.base.domain.MangaUtils
+import org.koitharu.kotatsu.local.data.PagesCache
+import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.parsers.util.toFileNameSafe
+import org.koitharu.kotatsu.reader.domain.PageLoader
+import java.io.File
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.resume
+
+private const val MAX_FILENAME_LENGTH = 10
+private const val EXTENSION_FALLBACK = "png"
+
+class PageSaveHelper(
+ private val cache: PagesCache,
+ context: Context,
+) {
+
+ private var continuation: Continuation? = null
+ private val contentResolver = context.contentResolver
+
+ suspend fun savePage(
+ pageLoader: PageLoader,
+ page: MangaPage,
+ saveLauncher: ActivityResultLauncher,
+ ): Uri {
+ val pageUrl = pageLoader.getPageUrl(page)
+ val pageFile = pageLoader.loadPage(page, force = false)
+ val proposedName = getProposedFileName(pageUrl, pageFile)
+ val destination = withContext(Dispatchers.Main) {
+ suspendCancellableCoroutine { cont ->
+ continuation = cont
+ saveLauncher.launch(proposedName)
+ }.also {
+ continuation = null
+ }
+ }
+ runInterruptible(Dispatchers.IO) {
+ contentResolver.openOutputStream(destination)?.use { output ->
+ pageFile.inputStream().use { input ->
+ input.copyTo(output)
+ }
+ } ?: throw IOException("Output stream is null")
+ }
+ return destination
+ }
+
+ fun onActivityResult(uri: Uri): Boolean = continuation?.apply {
+ resume(uri)
+ } != null
+
+ private suspend fun getProposedFileName(url: String, file: File): String {
+ var name = url.toHttpUrl().pathSegments.last()
+ var extension = name.substringAfterLast('.', "")
+ name = name.substringBeforeLast('.')
+ if (extension.length !in 2..4) {
+ val mimeType = MangaUtils.getImageMimeType(file)
+ extension = if (mimeType != null) {
+ MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK
+ } else {
+ EXTENSION_FALLBACK
+ }
+ }
+ return name.toFileNameSafe().take(MAX_FILENAME_LENGTH) + "." + extension
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
index 6a363cf4b..ca9228207 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt
@@ -9,7 +9,6 @@ import android.view.*
import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.core.graphics.Insets
-import androidx.core.net.toUri
import androidx.core.view.*
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
@@ -187,10 +186,7 @@ class ReaderActivity :
R.id.action_save_page -> {
viewModel.getCurrentPage()?.also { page ->
viewModel.saveCurrentState(reader?.getCurrentState())
- val name = page.url.toUri().run {
- fragment ?: lastPathSegment ?: ""
- }
- savePageRequest.launch(name)
+ viewModel.saveCurrentPage(page, savePageRequest)
} ?: showWaitWhileLoading()
}
else -> return super.onOptionsItemSelected(item)
@@ -199,9 +195,7 @@ class ReaderActivity :
}
override fun onActivityResult(uri: Uri?) {
- if (uri != null) {
- viewModel.saveCurrentPage(uri)
- }
+ viewModel.onActivityResult(uri)
}
private fun onLoadingStateChanged(isLoading: Boolean) {
@@ -410,18 +404,18 @@ class ReaderActivity :
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, ReaderActivity::class.java)
- .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
+ .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
}
fun newIntent(context: Context, manga: Manga, branch: String?): Intent {
return Intent(context, ReaderActivity::class.java)
- .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
+ .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
.putExtra(EXTRA_BRANCH, branch)
}
fun newIntent(context: Context, manga: Manga, state: ReaderState?): Intent {
return Intent(context, ReaderActivity::class.java)
- .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
+ .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
.putExtra(EXTRA_STATE, state)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
index 9f1f18983..bfe5ac663 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui
import android.net.Uri
import android.util.LongSparseArray
+import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.*
@@ -26,7 +27,6 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
-import org.koitharu.kotatsu.utils.ExternalStorageHelper
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@@ -40,10 +40,11 @@ class ReaderViewModel(
private val historyRepository: HistoryRepository,
private val shortcutsRepository: ShortcutsRepository,
private val settings: AppSettings,
- private val externalStorageHelper: ExternalStorageHelper,
+ private val pageSaveHelper: PageSaveHelper,
) : BaseViewModel() {
private var loadingJob: Job? = null
+ private var pageSaveJob: Job? = null
private val currentState = MutableStateFlow(initialState)
private val mangaData = MutableStateFlow(intent.manga)
private val chapters = LongSparseArray()
@@ -54,7 +55,7 @@ class ReaderViewModel(
val onPageSaved = SingleLiveEvent()
val uiState = combine(
mangaData,
- currentState
+ currentState,
) { manga, state ->
val chapter = state?.chapterId?.let(chapters::get)
ReaderUiState(
@@ -137,13 +138,18 @@ class ReaderViewModel(
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
}
- fun saveCurrentPage(destination: Uri) {
- launchJob(Dispatchers.Default) {
+ fun saveCurrentPage(
+ page: MangaPage,
+ saveLauncher: ActivityResultLauncher,
+ ) {
+ val prevJob = pageSaveJob
+ pageSaveJob = launchLoadingJob(Dispatchers.Default) {
+ prevJob?.cancelAndJoin()
try {
- val page = getCurrentPage() ?: error("Page not found")
- externalStorageHelper.savePage(page, destination)
- onPageSaved.postCall(destination)
- } catch (_: CancellationException) {
+ val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher)
+ onPageSaved.postCall(dest)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
e.printStackTrace()
@@ -153,6 +159,15 @@ class ReaderViewModel(
}
}
+ fun onActivityResult(uri: Uri?) {
+ if (uri != null) {
+ pageSaveHelper.onActivityResult(uri)
+ } else {
+ pageSaveJob?.cancel()
+ pageSaveJob = null
+ }
+ }
+
fun getCurrentPage(): MangaPage? {
val state = currentState.value ?: return null
return content.value?.pages?.find {
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
index 9ce52f4fe..5630cb1ba 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt
@@ -109,7 +109,7 @@ class PageHolderDelegate(
state = State.LOADED
callback.onImageReady(file.toUri())
} catch (e: CancellationException) {
- // do nothing
+ throw e
} catch (e: Exception) {
state = State.ERROR
error = e
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt
index ddaa63b5e..5a9721124 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonReaderFragment.kt
@@ -14,7 +14,7 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.doOnCurrentItemChanged
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
-import org.koitharu.kotatsu.utils.ext.firstItem
+import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
class WebtoonReaderFragment : BaseReader() {
@@ -52,7 +52,7 @@ class WebtoonReaderFragment : BaseReader() {
setItems.await() ?: return@launchWhenCreated
if (position != -1) {
with(binding.recyclerView) {
- firstItem = position
+ firstVisibleItemPosition = position
post {
(findViewHolderForAdapterPosition(position) as? WebtoonHolder)
?.restoreScroll(pendingState.scroll)
@@ -91,6 +91,6 @@ class WebtoonReaderFragment : BaseReader() {
}
override fun switchPageTo(position: Int, smooth: Boolean) {
- binding.recyclerView.firstItem = position
+ binding.recyclerView.firstVisibleItemPosition = position
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt
index c9a07f0fe..55d59adea 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/PagesThumbnailsSheet.kt
@@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import org.koin.android.ext.android.get
+import org.koin.androidx.viewmodel.ext.android.getViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -19,17 +20,22 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.reader.domain.PageLoader
+import org.koitharu.kotatsu.reader.ui.ReaderActivity
+import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
-class PagesThumbnailsSheet : BaseBottomSheet(),
+class PagesThumbnailsSheet :
+ BaseBottomSheet(),
OnListItemClickListener {
private lateinit var thumbnails: List
private val spanResolver = MangaListSpanResolver()
private var currentPageIndex = -1
+ private var pageLoader: PageLoader? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -75,11 +81,11 @@ class PagesThumbnailsSheet : BaseBottomSheet(),
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
)
adapter = PageThumbnailAdapter(
- thumbnails,
- get(),
- viewLifecycleScope,
- get(),
- this@PagesThumbnailsSheet
+ dataSet = thumbnails,
+ coil = get(),
+ scope = viewLifecycleScope,
+ loader = getPageLoader(),
+ clickListener = this@PagesThumbnailsSheet
)
addOnLayoutChangeListener(spanResolver)
spanResolver.setGridSize(get().gridSize / 100f, this)
@@ -90,14 +96,27 @@ class PagesThumbnailsSheet : BaseBottomSheet(),
}
}
+ override fun onDestroyView() {
+ super.onDestroyView()
+ pageLoader?.close()
+ pageLoader = null
+ }
+
override fun onItemClick(item: MangaPage, view: View) {
- ((parentFragment as? OnPageSelectListener)
- ?: (activity as? OnPageSelectListener))?.run {
+ (
+ (parentFragment as? OnPageSelectListener)
+ ?: (activity as? OnPageSelectListener)
+ )?.run {
onPageSelected(item)
dismiss()
}
}
+ private fun getPageLoader(): PageLoader {
+ val viewModel = (activity as? ReaderActivity)?.getViewModel()
+ return viewModel?.pageLoader ?: PageLoader().also { pageLoader = it }
+ }
+
private inner class ToolbarController(toolbar: Toolbar) : BottomSheetToolbarController(toolbar) {
override fun onStateChanged(bottomSheet: View, newState: Int) {
super.onStateChanged(bottomSheet, newState)
@@ -127,6 +146,5 @@ class PagesThumbnailsSheet : BaseBottomSheet(),
putString(ARG_TITLE, title)
putInt(ARG_CURRENT, currentPage)
}.show(fm, TAG)
-
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt
index 416b520d2..8cddae963 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAD.kt
@@ -1,38 +1,60 @@
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
-import androidx.core.net.toUri
+import android.graphics.drawable.Drawable
import coil.ImageLoader
import coil.request.ImageRequest
-import coil.size.PixelSize
+import coil.size.Size
+import com.google.android.material.R as materialR
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import kotlinx.coroutines.*
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
-import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
-import org.koitharu.kotatsu.utils.ext.IgnoreErrors
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.setTextColorAttr
-import com.google.android.material.R as materialR
fun pageThumbnailAD(
coil: ImageLoader,
scope: CoroutineScope,
- cache: PagesCache,
- clickListener: OnListItemClickListener
+ loader: PageLoader,
+ clickListener: OnListItemClickListener,
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }
) {
var job: Job? = null
val gridWidth = itemView.context.resources.getDimensionPixelSize(R.dimen.preferred_grid_width)
- val thumbSize = PixelSize(
+ val thumbSize = Size(
width = gridWidth,
height = (gridWidth * 13f / 18f).toInt()
)
+ suspend fun loadPageThumbnail(item: PageThumbnail): Drawable? = withContext(Dispatchers.Default) {
+ item.page.preview?.let { url ->
+ coil.execute(
+ ImageRequest.Builder(context)
+ .data(url)
+ .referer(item.page.referer)
+ .size(thumbSize)
+ .allowRgb565(true)
+ .build()
+ ).drawable
+ }?.let { drawable ->
+ return@withContext drawable
+ }
+ val file = loader.loadPage(item.page, force = false)
+ coil.execute(
+ ImageRequest.Builder(context)
+ .data(file)
+ .size(thumbSize)
+ .allowRgb565(true)
+ .build()
+ ).drawable
+ }
+
binding.root.setOnClickListener {
clickListener.onItemClick(item.page, itemView)
}
@@ -45,22 +67,11 @@ fun pageThumbnailAD(
setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary)
text = (item.number).toString()
}
- job = scope.launch(Dispatchers.Default + IgnoreErrors) {
- val url = item.page.preview ?: item.page.url.let {
- val pageUrl = item.repository.getPageUrl(item.page)
- cache[pageUrl]?.toUri()?.toString() ?: pageUrl
- }
- val drawable = coil.execute(
- ImageRequest.Builder(context)
- .data(url)
- .referer(item.page.referer)
- .size(thumbSize)
- .allowRgb565(true)
- .build()
- ).drawable
- withContext(Dispatchers.Main) {
- binding.imageViewThumb.setImageDrawable(drawable)
- }
+ job = scope.launch {
+ val drawable = runCatching {
+ loadPageThumbnail(item)
+ }.getOrNull()
+ binding.imageViewThumb.setImageDrawable(drawable)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt
index 0d8161670..b293d2865 100644
--- a/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/PageThumbnailAdapter.kt
@@ -4,20 +4,20 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
import kotlinx.coroutines.CoroutineScope
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage
+import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
class PageThumbnailAdapter(
dataSet: List,
coil: ImageLoader,
scope: CoroutineScope,
- cache: PagesCache,
+ loader: PageLoader,
clickListener: OnListItemClickListener
) : ListDelegationAdapter>() {
init {
- delegatesManager.addDelegate(pageThumbnailAD(coil, scope, cache, clickListener))
+ delegatesManager.addDelegate(pageThumbnailAD(coil, scope, loader, clickListener))
setItems(dataSet)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
index 6408c036e..b2a540baa 100644
--- a/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt
@@ -137,6 +137,9 @@ class RemoteListViewModel(
e.printStackTrace()
}
listError.value = e
+ if (!mangaList.value.isNullOrEmpty()) {
+ onError.postCall(e)
+ }
}
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt
index 1f14c6ee3..1d1fb43fc 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/SearchModule.kt
@@ -13,8 +13,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
val searchModule
get() = module {
- single { MangaSearchRepository(get(), get(), androidContext(), get()) }
-
+ factory { MangaSearchRepository(get(), get(), androidContext(), get()) }
factory { MangaSuggestionsProvider.createSuggestions(androidContext()) }
viewModel { params ->
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt
index 64e53dc6b..7c65c7c42 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionItemCallback.kt
@@ -31,7 +31,7 @@ class SearchSuggestionItemCallback(
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
- val item = viewHolder.getItem() ?: return
+ val item = viewHolder.getItem(SearchSuggestionItem.RecentQuery::class.java) ?: return
listener.onRemoveQuery(item.query)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt
index 9a942009b..ea9dfd6f2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt
@@ -14,4 +14,6 @@ interface SearchSuggestionListener {
fun onClearSearchHistory()
fun onTagClick(tag: MangaTag)
+
+ fun onVoiceSearchClick()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt
index 07e24ca16..261648fce 100644
--- a/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt
@@ -2,15 +2,20 @@ package org.koitharu.kotatsu.search.ui.widget
import android.annotation.SuppressLint
import android.content.Context
+import android.os.Parcelable
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.MotionEvent
+import android.view.SoundEffectConstants
+import android.view.accessibility.AccessibilityEvent
import android.view.inputmethod.EditorInfo
import androidx.annotation.AttrRes
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.content.ContextCompat
-import com.google.android.material.R
+import com.google.android.material.R as materialR
+import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
+import org.koitharu.kotatsu.utils.ext.drawableEnd
import org.koitharu.kotatsu.utils.ext.drawableStart
private const val DRAWABLE_END = 2
@@ -18,11 +23,19 @@ private const val DRAWABLE_END = 2
class SearchEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
- @AttrRes defStyleAttr: Int = R.attr.editTextStyle,
+ @AttrRes defStyleAttr: Int = materialR.attr.editTextStyle,
) : AppCompatEditText(context, attrs, defStyleAttr) {
var searchSuggestionListener: SearchSuggestionListener? = null
- private val clearIcon = ContextCompat.getDrawable(context, R.drawable.abc_ic_clear_material)
+ private val clearIcon = ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material)
+ private val voiceIcon = ContextCompat.getDrawable(context, R.drawable.ic_voice_input)
+ private var isEmpty = text.isNullOrEmpty()
+
+ var isVoiceSearchEnabled: Boolean = false
+ set(value) {
+ field = value
+ updateActionIcon()
+ }
var query: String
get() = text?.trim()?.toString().orEmpty()
@@ -57,15 +70,19 @@ class SearchEditText @JvmOverloads constructor(
lengthAfter: Int,
) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
- setCompoundDrawablesRelativeWithIntrinsicBounds(
- drawableStart,
- null,
- if (text.isNullOrEmpty()) null else clearIcon,
- null,
- )
+ val empty = text.isNullOrEmpty()
+ if (isEmpty != empty) {
+ isEmpty = empty
+ updateActionIcon()
+ }
searchSuggestionListener?.onQueryChanged(query)
}
+ override fun onRestoreInstanceState(state: Parcelable?) {
+ super.onRestoreInstanceState(state)
+ updateActionIcon()
+ }
+
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
@@ -76,7 +93,9 @@ class SearchEditText @JvmOverloads constructor(
event.x.toInt() in (width - drawable.bounds.width() - paddingRight)..(width - paddingRight)
}
if (isOnDrawable) {
- text?.clear()
+ sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED)
+ playSoundEffect(SoundEffectConstants.CLICK)
+ onActionIconClick()
return true
}
}
@@ -87,4 +106,22 @@ class SearchEditText @JvmOverloads constructor(
super.clearFocus()
text?.clear()
}
+
+ private fun onActionIconClick() {
+ when {
+ !text.isNullOrEmpty() -> text?.clear()
+ isVoiceSearchEnabled -> searchSuggestionListener?.onVoiceSearchClick()
+ }
+ }
+
+ private fun updateActionIcon() {
+ val icon = when {
+ !text.isNullOrEmpty() -> clearIcon
+ isVoiceSearchEnabled -> voiceIcon
+ else -> null
+ }
+ if (icon !== drawableEnd) {
+ setCompoundDrawablesRelativeWithIntrinsicBounds(drawableStart, null, icon, null)
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt
index 12d7f21ce..5b7bc661e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppUpdateChecker.kt
@@ -8,6 +8,15 @@ import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.annotation.MainThread
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import java.io.ByteArrayInputStream
+import java.io.InputStream
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+import java.security.cert.CertificateEncodingException
+import java.security.cert.CertificateException
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.get
@@ -19,15 +28,6 @@ import org.koitharu.kotatsu.core.github.VersionId
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.utils.FileSize
-import java.io.ByteArrayInputStream
-import java.io.InputStream
-import java.security.MessageDigest
-import java.security.NoSuchAlgorithmException
-import java.security.cert.CertificateEncodingException
-import java.security.cert.CertificateException
-import java.security.cert.CertificateFactory
-import java.security.cert.X509Certificate
-import java.util.concurrent.TimeUnit
class AppUpdateChecker(private val activity: ComponentActivity) {
@@ -61,25 +61,22 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
@MainThread
private fun showUpdateDialog(version: AppVersion) {
+ val message = buildString {
+ append(activity.getString(R.string.new_version_s, version.name))
+ appendLine()
+ append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize)))
+ appendLine()
+ appendLine()
+ append(version.description)
+ }
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.app_update_available)
- .setMessage(buildString {
- append(activity.getString(R.string.new_version_s, version.name))
- appendLine()
- append(
- activity.getString(
- R.string.size_s,
- FileSize.BYTES.format(activity, version.apkSize),
- )
- )
- appendLine()
- appendLine()
- append(version.description)
- })
+ .setMessage(message)
.setPositiveButton(R.string.download) { _, _ ->
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(version.apkUrl)))
}
.setNegativeButton(R.string.close, null)
+ .setCancelable(false)
.create()
.show()
}
@@ -128,4 +125,4 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt
index aca880ea1..36031ec21 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt
@@ -25,9 +25,10 @@ class AppearanceSettingsFragment :
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_appearance)
findPreference(AppSettings.KEY_GRID_SIZE)?.run {
- summary = "%d%%".format(value)
+ val pattern = context.getString(R.string.percent_string_pattern)
+ summary = pattern.format(value.toString())
setOnPreferenceChangeListener { preference, newValue ->
- preference.summary = "%d%%".format(newValue)
+ preference.summary = pattern.format(newValue.toString())
true
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
index 221c62897..4f9c79c46 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt
@@ -16,7 +16,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager
-import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@@ -35,6 +35,13 @@ class ContentSettingsFragment :
findPreference(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
+ findPreference(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run {
+ summary = value.toString()
+ setOnPreferenceChangeListener { preference, newValue ->
+ preference.summary = newValue.toString()
+ true
+ }
+ }
bindRemoteSourcesSummary()
}
@@ -101,7 +108,7 @@ class ContentSettingsFragment :
private fun bindRemoteSourcesSummary() {
findPreference(AppSettings.KEY_REMOTE_SOURCES)?.run {
- val total = MangaSource.values().size - 1
+ val total = settings.remoteMangaSources.size
summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total)
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt
index b8852f47e..ee15c796a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/NotificationSettingsLegacyFragment.kt
@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.settings
import android.content.Context
+import android.content.SharedPreferences
import android.media.RingtoneManager
import android.os.Bundle
import android.view.View
@@ -11,7 +12,9 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.utils.RingtonePickContract
-class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notifications) {
+class NotificationSettingsLegacyFragment :
+ BasePreferenceFragment(R.string.notifications),
+ SharedPreferences.OnSharedPreferenceChangeListener {
private val ringtonePickContract = registerForActivityResult(
RingtonePickContract(get().getString(R.string.notification_sound))
@@ -25,15 +28,28 @@ class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notif
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_notifications)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
findPreference(AppSettings.KEY_NOTIFICATIONS_SOUND)?.run {
val uri = settings.notificationSound
summary = RingtoneManager.getRingtone(context, uri)?.getTitle(context)
?: getString(R.string.silent)
}
+ updateInfo()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ settings.subscribe(this)
+ }
+
+ override fun onDestroyView() {
+ settings.unsubscribe(this)
+ super.onDestroyView()
+ }
+
+ override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
+ when (key) {
+ AppSettings.KEY_TRACKER_NOTIFICATIONS -> updateInfo()
+ }
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
@@ -45,4 +61,9 @@ class NotificationSettingsLegacyFragment : BasePreferenceFragment(R.string.notif
else -> super.onPreferenceTreeClick(preference)
}
}
-}
+
+ private fun updateInfo() {
+ findPreference(AppSettings.KEY_NOTIFICATIONS_INFO)
+ ?.isVisible = !settings.isTrackerNotificationsEnabled
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt
index 6d9f543c4..230ab0d32 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/SettingsModule.kt
@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.settings.backup.BackupViewModel
import org.koitharu.kotatsu.settings.backup.RestoreViewModel
+import org.koitharu.kotatsu.settings.newsources.NewSourcesViewModel
import org.koitharu.kotatsu.settings.onboard.OnboardViewModel
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
@@ -16,9 +17,9 @@ import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
val settingsModule
get() = module {
- single { BackupRepository(get()) }
- single { RestoreRepository(get()) }
- single { AppSettings(androidContext()) }
+ factory { BackupRepository(get()) }
+ factory { RestoreRepository(get()) }
+ single(createdAtStart = true) { AppSettings(androidContext()) }
viewModel { BackupViewModel(get(), androidContext()) }
viewModel { params ->
@@ -27,4 +28,5 @@ val settingsModule
viewModel { ProtectSetupViewModel(get()) }
viewModel { OnboardViewModel(get()) }
viewModel { SourcesSettingsViewModel(get()) }
+ viewModel { NewSourcesViewModel(get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt
index 0392bb9c7..3a7c8843c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/SourceSettingsExt.kt
@@ -1,15 +1,15 @@
package org.koitharu.kotatsu.settings
import android.view.inputmethod.EditorInfo
-import androidx.preference.*
+import androidx.preference.EditTextPreference
+import androidx.preference.Preference
+import androidx.preference.PreferenceFragmentCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.config.ConfigKey
+import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference
import org.koitharu.kotatsu.settings.utils.EditTextBindListener
import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider
-import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
-
-private const val KEY_DOMAIN = "domain"
fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMangaRepository) {
val configKeys = repository.getConfigKeys()
@@ -19,23 +19,17 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
is ConfigKey.Domain -> {
val presetValues = key.presetValues
if (presetValues.isNullOrEmpty()) {
- EditTextPreference(requireContext()).apply {
- summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
- setOnBindEditTextListener(
- EditTextBindListener(
- inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI,
- hint = key.defaultValue,
- )
- )
- }
+ EditTextPreference(requireContext())
} else {
- DropDownPreference(requireContext()).apply {
- entries = presetValues
- entryValues = entries
- summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
- setDefaultValueCompat(key.defaultValue)
- }
+ AutoCompleteTextViewPreference(requireContext()).apply { entries = presetValues }
}.apply {
+ summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
+ setOnBindEditTextListener(
+ EditTextBindListener(
+ inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI,
+ hint = key.defaultValue,
+ )
+ )
setTitle(R.string.domain)
setDialogTitle(R.string.domain)
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt
index 1e993f845..f1637def6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/TrackerSettingsFragment.kt
@@ -1,21 +1,34 @@
package org.koitharu.kotatsu.settings
import android.content.Intent
+import android.content.SharedPreferences
+import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.style.URLSpan
+import android.view.View
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
-import org.koitharu.kotatsu.tracker.work.TrackWorker
+import org.koitharu.kotatsu.tracker.domain.TrackingRepository
+import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
+import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
-class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_chapters) {
+class TrackerSettingsFragment :
+ BasePreferenceFragment(R.string.check_for_new_chapters),
+ SharedPreferences.OnSharedPreferenceChangeListener {
+
+ private val repository by inject()
+ private val channels by inject()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_tracker)
@@ -32,22 +45,81 @@ class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_ch
}
}
}
+ updateCategoriesEnabled()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ updateCategoriesSummary()
+ updateNotificationsSummary()
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ settings.subscribe(this)
+ }
+
+ override fun onDestroyView() {
+ settings.unsubscribe(this)
+ super.onDestroyView()
+ }
+
+ override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String?) {
+ when (key) {
+ AppSettings.KEY_TRACKER_NOTIFICATIONS -> updateNotificationsSummary()
+ AppSettings.KEY_TRACK_SOURCES,
+ AppSettings.KEY_TRACKER_ENABLED -> updateCategoriesEnabled()
+ }
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
- AppSettings.KEY_NOTIFICATIONS_SETTINGS -> {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
+ AppSettings.KEY_NOTIFICATIONS_SETTINGS -> when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, requireContext().packageName)
- .putExtra(Settings.EXTRA_CHANNEL_ID, TrackWorker.CHANNEL_ID)
startActivity(intent)
true
- } else {
+ }
+ channels.areNotificationsDisabled -> {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ .setData(Uri.fromParts("package", requireContext().packageName, null))
+ startActivity(intent)
+ true
+ }
+ else -> {
super.onPreferenceTreeClick(preference)
}
}
+ AppSettings.KEY_TRACK_CATEGORIES -> {
+ startActivity(CategoriesActivity.newIntent(preference.context))
+ true
+ }
else -> super.onPreferenceTreeClick(preference)
}
}
+
+ private fun updateNotificationsSummary() {
+ val pref = findPreference(AppSettings.KEY_NOTIFICATIONS_SETTINGS) ?: return
+ pref.setSummary(
+ when {
+ channels.areNotificationsDisabled -> R.string.disabled
+ channels.isNotificationGroupEnabled() -> R.string.show_notification_new_chapters_on
+ else -> R.string.show_notification_new_chapters_off
+ }
+ )
+ }
+
+ private fun updateCategoriesEnabled() {
+ val pref = findPreference(AppSettings.KEY_TRACK_CATEGORIES) ?: return
+ pref.isEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources
+ }
+
+ private fun updateCategoriesSummary() {
+ val pref = findPreference(AppSettings.KEY_TRACK_CATEGORIES) ?: return
+ viewLifecycleScope.launch {
+ val count = repository.getCategoriesCount()
+ pref.summary = getString(R.string.enabled_d_of_d, count[0], count[1])
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt
index 9b59a983c..baa2a5217 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt
@@ -5,12 +5,8 @@ import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.app.backup.FullBackupDataOutput
import android.os.ParcelFileDescriptor
-import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking
-import org.koitharu.kotatsu.core.backup.BackupArchive
-import org.koitharu.kotatsu.core.backup.BackupEntry
-import org.koitharu.kotatsu.core.backup.BackupRepository
-import org.koitharu.kotatsu.core.backup.RestoreRepository
+import org.koitharu.kotatsu.core.backup.*
import org.koitharu.kotatsu.core.db.MangaDatabase
import java.io.*
@@ -46,7 +42,7 @@ class AppBackupAgent : BackupAgent() {
mode: Long,
mtime: Long
) {
- if (destination?.name?.endsWith(".bak") == true) {
+ if (destination?.name?.endsWith(".bk.zip") == true) {
restoreBackupFile(data.fileDescriptor, size)
destination.delete()
} else {
@@ -55,37 +51,34 @@ class AppBackupAgent : BackupAgent() {
}
private fun createBackupFile() = runBlocking {
- val repository = BackupRepository(MangaDatabase.create(applicationContext))
- val backup = BackupArchive.createNew(this@AppBackupAgent)
- backup.put(repository.createIndex())
- backup.put(repository.dumpHistory())
- backup.put(repository.dumpCategories())
- backup.put(repository.dumpFavourites())
- backup.flush()
- backup.cleanup()
- backup.file
+ val repository = BackupRepository(MangaDatabase(applicationContext))
+ BackupZipOutput(this@AppBackupAgent).use { backup ->
+ backup.put(repository.createIndex())
+ backup.put(repository.dumpHistory())
+ backup.put(repository.dumpCategories())
+ backup.put(repository.dumpFavourites())
+ backup.finish()
+ backup.file
+ }
}
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
- val repository = RestoreRepository(MangaDatabase.create(applicationContext))
+ val repository = RestoreRepository(MangaDatabase(applicationContext))
val tempFile = File.createTempFile("backup_", ".tmp")
FileInputStream(fd).use { input ->
tempFile.outputStream().use { output ->
input.copyLimitedTo(output, size)
}
}
- val backup = BackupArchive(tempFile)
+ val backup = BackupZipInput(tempFile)
try {
runBlocking {
- backup.unpack()
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
}
} finally {
- runBlocking(NonCancellable) {
- backup.cleanup()
- }
+ backup.close()
tempFile.delete()
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt
index 8c3ac36a9..2532dc8d2 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt
@@ -3,8 +3,8 @@ package org.koitharu.kotatsu.settings.backup
import android.content.Context
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel
-import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupRepository
+import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
@@ -19,23 +19,25 @@ class BackupViewModel(
init {
launchLoadingJob {
- val backup = BackupArchive.createNew(context)
- backup.put(repository.createIndex())
+ val file = BackupZipOutput(context).use { backup ->
+ backup.put(repository.createIndex())
- progress.value = Progress(0, 3)
- backup.put(repository.dumpHistory())
+ progress.value = Progress(0, 3)
+ backup.put(repository.dumpHistory())
- progress.value = Progress(1, 3)
- backup.put(repository.dumpCategories())
+ progress.value = Progress(1, 3)
+ backup.put(repository.dumpCategories())
- progress.value = Progress(2, 3)
- backup.put(repository.dumpFavourites())
+ progress.value = Progress(2, 3)
+ backup.put(repository.dumpFavourites())
- progress.value = Progress(3, 3)
- backup.flush()
- progress.value = null
- backup.cleanup()
- onBackupDone.call(backup.file)
+ progress.value = Progress(3, 3)
+ backup.finish()
+ progress.value = null
+ backup.close()
+ backup.file
+ }
+ onBackupDone.call(file)
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt
index e7d185eb2..79f2fc7c4 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt
@@ -3,19 +3,17 @@ package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.net.Uri
import androidx.lifecycle.MutableLiveData
-import java.io.File
-import java.io.FileNotFoundException
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runInterruptible
-import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.base.ui.BaseViewModel
-import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupEntry
+import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress
+import java.io.File
+import java.io.FileNotFoundException
class RestoreViewModel(
uri: Uri?,
@@ -40,10 +38,9 @@ class RestoreViewModel(
input.copyTo(output)
}
}
- BackupArchive(tempFile)
+ BackupZipInput(tempFile)
}
try {
- backup.unpack()
val result = CompositeResult()
progress.value = Progress(0, 3)
@@ -58,10 +55,8 @@ class RestoreViewModel(
progress.value = Progress(3, 3)
onRestoreDone.call(result)
} finally {
- withContext(NonCancellable) {
- backup.cleanup()
- backup.file.delete()
- }
+ backup.close()
+ backup.file.delete()
}
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt
new file mode 100644
index 000000000..9718e5306
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesDialogFragment.kt
@@ -0,0 +1,68 @@
+package org.koitharu.kotatsu.settings.newsources
+
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.FragmentManager
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.koin.android.ext.android.get
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.base.ui.AlertDialogFragment
+import org.koitharu.kotatsu.databinding.DialogOnboardBinding
+import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
+import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
+import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
+
+class NewSourcesDialogFragment :
+ AlertDialogFragment(),
+ SourceConfigListener,
+ DialogInterface.OnClickListener {
+
+ private val viewModel by viewModel()
+
+ override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): DialogOnboardBinding {
+ return DialogOnboardBinding.inflate(inflater, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ val adapter = SourceConfigAdapter(this, get(), viewLifecycleOwner)
+ binding.recyclerView.adapter = adapter
+ binding.textViewTitle.setText(R.string.new_sources_text)
+
+ viewModel.sources.observe(viewLifecycleOwner) { adapter.items = it }
+ }
+
+ override fun onBuildDialog(builder: MaterialAlertDialogBuilder) {
+ builder
+ .setPositiveButton(R.string.done, this)
+ .setCancelable(true)
+ .setTitle(R.string.remote_sources)
+ }
+
+ override fun onClick(dialog: DialogInterface, which: Int) {
+ viewModel.apply()
+ dialog.dismiss()
+ }
+
+ override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
+
+ override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
+ viewModel.onItemEnabledChanged(item, isEnabled)
+ }
+
+ override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) = Unit
+
+ override fun onHeaderClick(header: SourceConfigItem.LocaleGroup) = Unit
+
+ companion object {
+
+ private const val TAG = "NewSources"
+
+ fun show(fm: FragmentManager) = NewSourcesDialogFragment().show(fm, TAG)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt
new file mode 100644
index 000000000..530851d46
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/newsources/NewSourcesViewModel.kt
@@ -0,0 +1,42 @@
+package org.koitharu.kotatsu.settings.newsources
+
+import androidx.lifecycle.MutableLiveData
+import org.koitharu.kotatsu.base.ui.BaseViewModel
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
+
+class NewSourcesViewModel(
+ private val settings: AppSettings,
+) : BaseViewModel() {
+
+ val sources = MutableLiveData>()
+ private val initialList = settings.newSources
+
+ init {
+ buildList()
+ }
+
+ fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
+ if (isEnabled) {
+ settings.hiddenSources -= item.source.name
+ } else {
+ settings.hiddenSources += item.source.name
+ }
+ }
+
+ fun apply() {
+ settings.markKnownSources(initialList)
+ }
+
+ private fun buildList() {
+ val hidden = settings.hiddenSources
+ sources.value = initialList.map {
+ SourceConfigItem.SourceItem(
+ source = it,
+ summary = null,
+ isEnabled = it.name !in hidden,
+ isDraggable = false,
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt
index 005469b96..4f695b154 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt
@@ -18,8 +18,10 @@ import org.koitharu.kotatsu.utils.ext.observeNotNull
import org.koitharu.kotatsu.utils.ext.showAllowStateLoss
import org.koitharu.kotatsu.utils.ext.withArgs
-class OnboardDialogFragment : AlertDialogFragment(),
- OnListItemClickListener, DialogInterface.OnClickListener {
+class OnboardDialogFragment :
+ AlertDialogFragment(),
+ OnListItemClickListener,
+ DialogInterface.OnClickListener {
private val viewModel by viewModel()
private var isWelcome: Boolean = false
@@ -53,6 +55,7 @@ class OnboardDialogFragment : AlertDialogFragment(),
super.onViewCreated(view, savedInstanceState)
val adapter = SourceLocalesAdapter(this)
binding.recyclerView.adapter = adapter
+ binding.textViewTitle.setText(R.string.onboard_text)
viewModel.list.observeNotNull(viewLifecycleOwner) {
adapter.items = it
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt
index b9f97f37d..2f1495243 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt
@@ -17,11 +17,9 @@ class OnboardViewModel(
private val settings: AppSettings,
) : BaseViewModel() {
- private val allSources = MangaSource.values().filterNot { x -> x == MangaSource.LOCAL }
+ private val allSources = settings.remoteMangaSources
- private val locales = allSources.mapTo(ArraySet()) {
- it.locale
- }
+ private val locales = allSources.mapTo(ArraySet()) { it.locale }
private val selectedLocales = locales.toMutableSet()
@@ -57,6 +55,7 @@ class OnboardViewModel(
settings.hiddenSources = allSources.filterNot { x ->
x.locale in selectedLocales
}.mapToSet { x -> x.name }
+ settings.markKnownSources(settings.newSources)
}
private fun rebuildList() {
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
index 19388cbcc..01185ba2a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt
@@ -32,7 +32,7 @@ class SourcesSettingsViewModel(
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
snapshot.move(oldPos, newPos)
settings.sourcesOrder = snapshot.mapNotNull {
- (it as? SourceConfigItem.SourceItem)?.source?.ordinal
+ (it as? SourceConfigItem.SourceItem)?.source?.name
}
buildList()
return true
@@ -111,9 +111,6 @@ class SourcesSettingsViewModel(
if (enabledSources?.size != sources.size) {
result += SourceConfigItem.Header(R.string.available_sources)
for ((key, list) in map) {
- val locale = if (key != null) {
- Locale(key)
- } else null
list.sortBy { it.ordinal }
val isExpanded = key in expandedGroups
result += SourceConfigItem.LocaleGroup(
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
index 48f0e74c5..752e3d33e 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt
@@ -83,7 +83,9 @@ fun sourceConfigDraggableItemDelegate(
on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable }
) {
- val eventListener = object : View.OnClickListener, View.OnTouchListener,
+ val eventListener = object :
+ View.OnClickListener,
+ View.OnTouchListener,
CompoundButton.OnCheckedChangeListener {
override fun onClick(v: View?) = listener.onItemSettingsClick(item)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt b/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt
index 6ad087b80..41f4afa84 100644
--- a/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt
@@ -11,18 +11,19 @@ import androidx.activity.result.contract.ActivityResultContract
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.browser.BrowserCallback
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.browser.ProgressChromeClient
+import org.koitharu.kotatsu.core.network.UserAgentInterceptor
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.TaggedActivityResult
-import com.google.android.material.R as materialR
class SourceAuthActivity : BaseActivity(), BrowserCallback {
@@ -52,6 +53,7 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba
}
with(binding.webView.settings) {
javaScriptEnabled = true
+ userAgentString = UserAgentInterceptor.userAgentChrome
}
binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt
new file mode 100644
index 000000000..fe1d3f15c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/settings/utils/AutoCompleteTextViewPreference.kt
@@ -0,0 +1,58 @@
+package org.koitharu.kotatsu.settings.utils
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ArrayAdapter
+import android.widget.AutoCompleteTextView
+import android.widget.EditText
+import androidx.annotation.ArrayRes
+import androidx.annotation.AttrRes
+import androidx.annotation.StyleRes
+import androidx.preference.EditTextPreference
+import org.koitharu.kotatsu.R
+
+class AutoCompleteTextViewPreference @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = R.attr.autoCompleteTextViewPreferenceStyle,
+ @StyleRes defStyleRes: Int = R.style.Preference_AutoCompleteTextView,
+) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
+
+ private val autoCompleteBindListener = AutoCompleteBindListener()
+ var entries: Array = emptyArray()
+
+ init {
+ super.setOnBindEditTextListener(autoCompleteBindListener)
+ }
+
+ fun setEntries(@ArrayRes arrayResId: Int) {
+ this.entries = context.resources.getStringArray(arrayResId)
+ }
+
+ fun setEntries(entries: Collection) {
+ this.entries = entries.toTypedArray()
+ }
+
+ override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) {
+ autoCompleteBindListener.delegate = onBindEditTextListener
+ }
+
+ private inner class AutoCompleteBindListener : OnBindEditTextListener {
+
+ var delegate: OnBindEditTextListener? = null
+
+ override fun onBindEditText(editText: EditText) {
+ delegate?.onBindEditText(editText)
+ if (editText !is AutoCompleteTextView || entries.isEmpty()) {
+ return
+ }
+ editText.threshold = 0
+ editText.setAdapter(ArrayAdapter(editText.context, android.R.layout.simple_spinner_dropdown_item, entries))
+ (editText.parent as? ViewGroup)?.findViewById(R.id.dropdown)?.setOnClickListener {
+ editText.showDropDown()
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt
index a08495f54..975e96d66 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/TrackerModule.kt
@@ -1,14 +1,17 @@
package org.koitharu.kotatsu.tracker
+import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.ui.FeedViewModel
+import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
val trackerModule
get() = module {
- single { TrackingRepository(get()) }
+ factory { TrackingRepository(get()) }
+ factory { TrackerNotificationChannels(androidContext(), get()) }
viewModel { FeedViewModel(get()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
index 661f68bba..aefa9a69a 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt
@@ -2,15 +2,15 @@ package org.koitharu.kotatsu.tracker.domain
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
-import org.koitharu.kotatsu.core.db.entity.TrackEntity
-import org.koitharu.kotatsu.core.db.entity.TrackLogEntity
-import org.koitharu.kotatsu.core.db.entity.toManga
-import org.koitharu.kotatsu.core.db.entity.toTrackingLogItem
+import org.koitharu.kotatsu.core.db.entity.*
+import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.MangaTracking
import org.koitharu.kotatsu.core.model.TrackingLogItem
+import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
+import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.*
class TrackingRepository(
@@ -21,16 +21,29 @@ class TrackingRepository(
return db.tracksDao.findNewChapters(mangaId) ?: 0
}
- suspend fun getAllTracks(useFavourites: Boolean, useHistory: Boolean): List {
- val mangaList = ArrayList()
- if (useFavourites) {
- db.favouritesDao.findAllManga().mapTo(mangaList) { it.toManga(emptySet()) }
- }
- if (useHistory) {
- db.historyDao.findAllManga().mapTo(mangaList) { it.toManga(emptySet()) }
+ suspend fun getHistoryManga(): List {
+ return db.historyDao.findAllManga().toMangaList()
+ }
+
+ suspend fun getFavouritesManga(): Map> {
+ val categories = db.favouriteCategoriesDao.findAll()
+ return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity ->
+ categoryEntity.toFavouriteCategory() to db.favouritesDao.findAllManga(categoryEntity.categoryId).toMangaList()
}
- val tracks = db.tracksDao.findAll().groupBy { it.mangaId }
- return mangaList
+ }
+
+ suspend fun getCategoriesCount(): IntArray {
+ val categories = db.favouriteCategoriesDao.findAll()
+ return intArrayOf(
+ categories.count { it.track },
+ categories.size,
+ )
+ }
+
+ suspend fun getTracks(mangaList: Collection): List {
+ val ids = mangaList.mapToSet { it.id }
+ val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }
+ return mangaList // TODO optimize
.filterNot { it.source == MangaSource.LOCAL }
.distinctBy { it.id }
.map { manga ->
@@ -103,4 +116,6 @@ class TrackingRepository(
)
db.tracksDao.upsert(entity)
}
+
+ private fun Collection.toMangaList() = map { it.toManga(emptySet()) }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt
index ae8d0ed5f..584e2a4ff 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedFragment.kt
@@ -11,7 +11,7 @@ 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.list.PaginationScrollListener
-import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
+import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.databinding.FragmentFeedBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@@ -57,7 +57,11 @@ class FeedFragment :
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing
paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
- addItemDecoration(SpacingItemDecoration(spacing))
+ val decoration = TypedSpacingItemDecoration(
+ FeedAdapter.ITEM_TYPE_FEED to 0,
+ fallbackSpacing = spacing
+ )
+ addItemDecoration(decoration)
}
viewModel.content.observe(viewLifecycleOwner, this::onListChanged)
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt
index 898db478e..292d2c982 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/FeedViewModel.kt
@@ -10,14 +10,15 @@ import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.TrackingLogItem
-import org.koitharu.kotatsu.list.ui.model.EmptyState
-import org.koitharu.kotatsu.list.ui.model.ListHeader
-import org.koitharu.kotatsu.list.ui.model.LoadingFooter
-import org.koitharu.kotatsu.list.ui.model.LoadingState
+import org.koitharu.kotatsu.core.ui.DateTimeAgo
+import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
+import org.koitharu.kotatsu.utils.ext.daysDiff
+import java.util.*
+import java.util.concurrent.TimeUnit
class FeedViewModel(
private val repository: TrackingRepository
@@ -34,8 +35,8 @@ class FeedViewModel(
hasNextPage
) { list, isHasNextPage ->
buildList(list.size + 2) {
- add(header)
if (list.isEmpty()) {
+ add(header)
add(
EmptyState(
icon = R.drawable.ic_feed,
@@ -45,7 +46,7 @@ class FeedViewModel(
)
)
} else {
- list.mapTo(this) { it.toFeedItem() }
+ list.mapListTo(this)
if (isHasNextPage) {
add(LoadingFooter)
}
@@ -85,4 +86,29 @@ class FeedViewModel(
onFeedCleared.postCall(Unit)
}
}
+
+ private fun List.mapListTo(destination: MutableList) {
+ var prevDate: DateTimeAgo? = null
+ for (item in this) {
+ val date = timeAgo(item.createdAt)
+ if (prevDate != date) {
+ destination += date
+ }
+ prevDate = date
+ destination += item.toFeedItem()
+ }
+ }
+
+ private fun timeAgo(date: Date): DateTimeAgo {
+ val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
+ val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
+ val diffDays = -date.daysDiff(System.currentTimeMillis())
+ return when {
+ diffMinutes < 3 -> DateTimeAgo.JustNow
+ diffDays < 1 -> DateTimeAgo.Today
+ diffDays == 1 -> DateTimeAgo.Yesterday
+ diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
+ else -> DateTimeAgo.Absolute(date)
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt
index 1c2d5f7fd..bebf7baae 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedAdapter.kt
@@ -4,10 +4,11 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
-import kotlin.jvm.internal.Intrinsics
+import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.adapter.*
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
+import kotlin.jvm.internal.Intrinsics
class FeedAdapter(
coil: ImageLoader,
@@ -24,6 +25,7 @@ class FeedAdapter(
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener))
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
+ .addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD())
}
private class DiffCallback : DiffUtil.ItemCallback() {
@@ -32,6 +34,9 @@ class FeedAdapter(
oldItem is FeedItem && newItem is FeedItem -> {
oldItem.id == newItem.id
}
+ oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
+ oldItem == newItem
+ }
else -> oldItem.javaClass == newItem.javaClass
}
@@ -49,5 +54,6 @@ class FeedAdapter(
const val ITEM_TYPE_ERROR_FOOTER = 4
const val ITEM_TYPE_EMPTY = 5
const val ITEM_TYPE_HEADER = 6
+ const val ITEM_TYPE_DATE_HEADER = 7
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt
index 77f465f45..000ff5399 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/adapter/FeedItemAD.kt
@@ -3,23 +3,23 @@ package org.koitharu.kotatsu.tracker.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import coil.request.Disposable
+import coil.size.Scale
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.databinding.ItemTracklogBinding
+import org.koitharu.kotatsu.databinding.ItemFeedBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest
-import org.koitharu.kotatsu.utils.ext.textAndVisible
fun feedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener
-) = adapterDelegateViewBinding(
- { inflater, parent -> ItemTracklogBinding.inflate(inflater, parent, false) }
+) = adapterDelegateViewBinding(
+ { inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) }
) {
var imageRequest: Disposable? = null
@@ -35,16 +35,15 @@ fun feedItemAD(
.fallback(R.drawable.ic_placeholder)
.error(R.drawable.ic_placeholder)
.allowRgb565(true)
+ .scale(Scale.FILL)
.lifecycle(lifecycleOwner)
.enqueueWith(coil)
binding.textViewTitle.text = item.title
- binding.badge.text = item.subtitle
- binding.textViewChapters.text = item.chapters
- binding.textViewTruncated.textAndVisible = if (item.truncated > 0) {
- getString(R.string._and_x_more, item.truncated)
- } else {
- null
- }
+ binding.textViewSummary.text = context.resources.getQuantityString(
+ R.plurals.new_chapters,
+ item.count,
+ item.count,
+ )
}
onViewRecycled {
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt
index daf0b5e60..1cdce1869 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/FeedItem.kt
@@ -7,8 +7,6 @@ data class FeedItem(
val id: Long,
val imageUrl: String,
val title: String,
- val subtitle: String,
- val chapters: CharSequence,
val manga: Manga,
- val truncated: Int,
+ val count: Int,
) : ListModel
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/ListModelConversionExt.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/ListModelConversionExt.kt
index f4552b9a7..fc4bc6080 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/ListModelConversionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/ui/model/ListModelConversionExt.kt
@@ -2,26 +2,10 @@ package org.koitharu.kotatsu.tracker.ui.model
import org.koitharu.kotatsu.core.model.TrackingLogItem
-fun TrackingLogItem.toFeedItem(): FeedItem {
- val truncate = chapters.size > MAX_CHAPTERS
- val chaptersString = if (truncate) {
- chapters.joinToString(
- separator = "\n",
- limit = MAX_CHAPTERS - 1,
- truncated = "",
- ).trimEnd()
- } else {
- chapters.joinToString("\n")
- }
- return FeedItem(
- id = id,
- imageUrl = manga.coverUrl,
- title = manga.title,
- subtitle = chapters.size.toString(),
- chapters = chaptersString,
- manga = manga,
- truncated = chapters.size - MAX_CHAPTERS + 1,
- )
-}
-
-private const val MAX_CHAPTERS = 6
\ No newline at end of file
+fun TrackingLogItem.toFeedItem() = FeedItem(
+ id = id,
+ imageUrl = manga.coverUrl,
+ title = manga.title,
+ count = chapters.size,
+ manga = manga,
+)
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt
index a61b25c47..943572f24 100644
--- a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackWorker.kt
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
-import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
@@ -41,26 +40,22 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
private val coil by inject()
private val repository by inject()
private val settings by inject()
+ private val channels by inject()
override suspend fun doWork(): Result {
- val trackSources = settings.trackSources
- if (trackSources.isEmpty()) {
- return Result.success()
- }
- val tracks = repository.getAllTracks(
- useFavourites = AppSettings.TRACK_FAVOURITES in trackSources,
- useHistory = AppSettings.TRACK_HISTORY in trackSources
- )
- if (tracks.isEmpty()) {
+ if (!settings.isTrackerEnabled) {
return Result.success()
}
if (TAG in tags) { // not expedited
trySetForeground()
}
+ val tracks = getAllTracks()
+
var success = 0
val workData = Data.Builder()
.putInt(DATA_TOTAL, tracks.size)
- for ((index, track) in tracks.withIndex()) {
+ for ((index, item) in tracks.withIndex()) {
+ val (track, channelId) = item
val details = runCatching {
MangaRepository(track.manga.source).getDetails(track.manga)
}.getOrNull()
@@ -80,12 +75,12 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
track.knownChaptersCount == 0 && track.lastChapterId == 0L -> { // manga was empty on last check
repository.storeTrackResult(
mangaId = track.manga.id,
- knownChaptersCount = track.knownChaptersCount,
+ knownChaptersCount = 0,
lastChapterId = 0L,
previousTrackChapterId = track.lastNotifiedChapterId,
newChapters = chapters
)
- showNotification(details, chapters)
+ showNotification(details, channelId, chapters)
}
chapters.size == track.knownChaptersCount -> {
if (chapters.lastOrNull()?.id == track.lastChapterId) {
@@ -114,7 +109,8 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
)
showNotification(
details,
- newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId }
+ channelId,
+ newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId },
)
}
}
@@ -126,11 +122,12 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
knownChaptersCount = track.knownChaptersCount,
lastChapterId = track.lastChapterId,
previousTrackChapterId = track.lastNotifiedChapterId,
- newChapters = newChapters
+ newChapters = newChapters,
)
showNotification(
- track.manga,
- newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId }
+ manga = track.manga,
+ channelId = channelId,
+ newChapters = newChapters.takeLastWhile { x -> x.id != track.lastNotifiedChapterId },
)
}
}
@@ -144,13 +141,60 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
}
}
- private suspend fun showNotification(manga: Manga, newChapters: List) {
- if (newChapters.isEmpty() || !settings.trackerNotifications) {
+ private suspend fun getAllTracks(): List {
+ val sources = settings.trackSources
+ if (sources.isEmpty()) {
+ return emptyList()
+ }
+ val knownIds = HashSet()
+ val result = ArrayList()
+ // Favourites
+ if (AppSettings.TRACK_FAVOURITES in sources) {
+ val favourites = repository.getFavouritesManga()
+ channels.updateChannels(favourites.keys)
+ for ((category, mangaList) in favourites) {
+ if (!category.isTrackingEnabled || mangaList.isEmpty()) {
+ continue
+ }
+ val categoryTracks = repository.getTracks(mangaList)
+ val channelId = if (channels.isFavouriteNotificationsEnabled(category)) {
+ channels.getFavouritesChannelId(category.id)
+ } else {
+ null
+ }
+ for (track in categoryTracks) {
+ if (knownIds.add(track.manga)) {
+ result.add(TrackingItem(track, channelId))
+ }
+ }
+ }
+ }
+ // History
+ if (AppSettings.TRACK_HISTORY in sources) {
+ val history = repository.getHistoryManga()
+ val historyTracks = repository.getTracks(history)
+ val channelId = if (channels.isHistoryNotificationsEnabled()) {
+ channels.getHistoryChannelId()
+ } else {
+ null
+ }
+ for (track in historyTracks) {
+ if (knownIds.add(track.manga)) {
+ result.add(TrackingItem(track, channelId))
+ }
+ }
+ }
+ result.trimToSize()
+ return result
+ }
+
+ private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List) {
+ if (newChapters.isEmpty() || channelId == null) {
return
}
val id = manga.url.hashCode()
val colorPrimary = ContextCompat.getColor(applicationContext, R.color.blue_primary)
- val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
+ val builder = NotificationCompat.Builder(applicationContext, channelId)
val summary = applicationContext.resources.getQuantityString(
R.plurals.new_chapters,
newChapters.size, newChapters.size
@@ -236,7 +280,6 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
companion object {
- const val CHANNEL_ID = "tracking"
private const val WORKER_CHANNEL_ID = "track_worker"
private const val WORKER_NOTIFICATION_ID = 35
private const val DATA_PROGRESS = "progress"
@@ -244,27 +287,7 @@ class TrackWorker(context: Context, workerParams: WorkerParameters) :
private const val TAG = "tracking"
private const val TAG_ONESHOT = "tracking_oneshot"
- @RequiresApi(Build.VERSION_CODES.O)
- private fun createNotificationChannel(context: Context) {
- val manager =
- context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
- if (manager.getNotificationChannel(CHANNEL_ID) == null) {
- val channel = NotificationChannel(
- CHANNEL_ID,
- context.getString(R.string.new_chapters),
- NotificationManager.IMPORTANCE_DEFAULT
- )
- channel.setShowBadge(true)
- channel.lightColor = ContextCompat.getColor(context, R.color.blue_primary_dark)
- channel.enableLights(true)
- manager.createNotificationChannel(channel)
- }
- }
-
fun setup(context: Context) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- createNotificationChannel(context)
- }
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt
new file mode 100644
index 000000000..81fcb73d5
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackerNotificationChannels.kt
@@ -0,0 +1,143 @@
+package org.koitharu.kotatsu.tracker.work
+
+import android.app.NotificationChannel
+import android.app.NotificationChannelGroup
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.core.app.NotificationManagerCompat
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.core.prefs.AppSettings
+
+class TrackerNotificationChannels(
+ private val context: Context,
+ private val settings: AppSettings,
+) {
+
+ private val manager = NotificationManagerCompat.from(context)
+
+ val areNotificationsDisabled: Boolean
+ get() = !manager.areNotificationsEnabled()
+
+ fun updateChannels(categories: Collection) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return
+ }
+ manager.deleteNotificationChannel(OLD_CHANNEL_ID)
+ val group = createGroup()
+ val existingChannels = group.channels.associateByTo(HashMap()) { it.id }
+ for (category in categories) {
+ val id = getFavouritesChannelId(category.id)
+ if (existingChannels.remove(id)?.name == category.title) {
+ continue
+ }
+ val channel = NotificationChannel(id, category.title, NotificationManager.IMPORTANCE_DEFAULT)
+ channel.group = GROUP_ID
+ manager.createNotificationChannel(channel)
+ }
+ existingChannels.remove(CHANNEL_ID_HISTORY)
+ createHistoryChannel()
+ for (id in existingChannels.keys) {
+ manager.deleteNotificationChannel(id)
+ }
+ }
+
+ fun createChannel(category: FavouriteCategory) {
+ renameChannel(category.id, category.title)
+ }
+
+ fun renameChannel(categoryId: Long, name: String) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return
+ }
+ val id = getFavouritesChannelId(categoryId)
+ val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
+ channel.group = createGroup().id
+ manager.createNotificationChannel(channel)
+ }
+
+ fun deleteChannel(categoryId: Long) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return
+ }
+ manager.deleteNotificationChannel(getFavouritesChannelId(categoryId))
+ }
+
+ fun isFavouriteNotificationsEnabled(category: FavouriteCategory): Boolean {
+ if (!manager.areNotificationsEnabled()) {
+ return false
+ }
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = manager.getNotificationChannel(getFavouritesChannelId(category.id))
+ channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE
+ } else {
+ // fallback
+ settings.isTrackerNotificationsEnabled
+ }
+ }
+
+ fun isHistoryNotificationsEnabled(): Boolean {
+ if (!manager.areNotificationsEnabled()) {
+ return false
+ }
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = manager.getNotificationChannel(getHistoryChannelId())
+ channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE
+ } else {
+ // fallback
+ settings.isTrackerNotificationsEnabled
+ }
+ }
+
+ fun isNotificationGroupEnabled(): Boolean {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return settings.isTrackerNotificationsEnabled
+ }
+ val group = manager.getNotificationChannelGroup(GROUP_ID) ?: return true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && group.isBlocked) {
+ return false
+ }
+ return group.channels.any { it.importance != NotificationManagerCompat.IMPORTANCE_NONE }
+ }
+
+ fun getFavouritesChannelId(categoryId: Long): String {
+ return CHANNEL_ID_PREFIX + categoryId
+ }
+
+ fun getHistoryChannelId(): String {
+ return CHANNEL_ID_HISTORY
+ }
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun createGroup(): NotificationChannelGroup {
+ manager.getNotificationChannelGroup(GROUP_ID)?.let {
+ return it
+ }
+ val group = NotificationChannelGroup(GROUP_ID, context.getString(R.string.new_chapters))
+ manager.createNotificationChannelGroup(group)
+ return group
+ }
+
+ private fun createHistoryChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return
+ }
+ val channel = NotificationChannel(
+ CHANNEL_ID_HISTORY,
+ context.getString(R.string.history),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ )
+ channel.group = GROUP_ID
+ manager.createNotificationChannel(channel)
+ }
+
+ companion object {
+
+ const val GROUP_ID = "trackers"
+ private const val CHANNEL_ID_PREFIX = "track_fav_"
+ private const val CHANNEL_ID_HISTORY = "track_history"
+ private const val OLD_CHANNEL_ID = "tracking"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt
new file mode 100644
index 000000000..933918009
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/tracker/work/TrackingItem.kt
@@ -0,0 +1,31 @@
+package org.koitharu.kotatsu.tracker.work
+
+import org.koitharu.kotatsu.core.model.MangaTracking
+
+class TrackingItem(
+ val tracking: MangaTracking,
+ val channelId: String?,
+) {
+
+ operator fun component1() = tracking
+
+ operator fun component2() = channelId
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as TrackingItem
+
+ if (tracking != other.tracking) return false
+ if (channelId != other.channelId) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = tracking.hashCode()
+ result = 31 * result + channelId.hashCode()
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt b/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt
index 9a703ffd7..ae40b41f6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/BottomSheetToolbarController.kt
@@ -10,14 +10,13 @@ open class BottomSheetToolbarController(
) : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
- if (newState == BottomSheetBehavior.STATE_EXPANDED) {
+ val isExpanded = newState == BottomSheetBehavior.STATE_EXPANDED && bottomSheet.top <= 0
+ if (isExpanded) {
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
} else {
toolbar.navigationIcon = null
}
}
- override fun onSlide(bottomSheet: View, slideOffset: Float) {
-
- }
+ override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt
new file mode 100644
index 000000000..e66355588
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt
@@ -0,0 +1,66 @@
+package org.koitharu.kotatsu.utils
+
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.*
+import kotlin.coroutines.resume
+
+class CompositeMutex : Set {
+
+ private val data = HashMap>>()
+ private val mutex = Mutex()
+
+ override val size: Int
+ get() = data.size
+
+ override fun contains(element: T): Boolean {
+ return data.containsKey(element)
+ }
+
+ override fun containsAll(elements: Collection): Boolean {
+ return elements.all { x -> data.containsKey(x) }
+ }
+
+ override fun isEmpty(): Boolean {
+ return data.isEmpty()
+ }
+
+ override fun iterator(): Iterator {
+ return data.keys.iterator()
+ }
+
+ suspend fun lock(element: T) {
+ waitForRemoval(element)
+ mutex.withLock {
+ val lastValue = data.put(element, LinkedList>())
+ check(lastValue == null) {
+ "CompositeMutex is double-locked for $element"
+ }
+ }
+ }
+
+ suspend fun unlock(element: T) {
+ val continuations = mutex.withLock {
+ checkNotNull(data.remove(element)) {
+ "CompositeMutex is not locked for $element"
+ }
+ }
+ continuations.forEach { c ->
+ if (c.isActive) {
+ c.resume(Unit)
+ }
+ }
+ }
+
+ private suspend fun waitForRemoval(element: T) {
+ val list = data[element] ?: return
+ suspendCancellableCoroutine { continuation ->
+ list.add(continuation)
+ continuation.invokeOnCancellation {
+ list.remove(continuation)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ExternalStorageHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ExternalStorageHelper.kt
deleted file mode 100644
index b769645b8..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ExternalStorageHelper.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.koitharu.kotatsu.utils
-
-import android.content.Context
-import android.net.Uri
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runInterruptible
-import okio.IOException
-import org.koitharu.kotatsu.parsers.model.MangaPage
-import org.koitharu.kotatsu.reader.domain.PageLoader
-
-class ExternalStorageHelper(context: Context) {
-
- private val contentResolver = context.contentResolver
-
- suspend fun savePage(page: MangaPage, destination: Uri) {
- val pageLoader = PageLoader()
- val pageFile = pageLoader.loadPage(page, force = false)
- runInterruptible(Dispatchers.IO) {
- contentResolver.openOutputStream(destination)?.use { output ->
- pageFile.inputStream().use { input ->
- input.copyTo(output)
- }
- } ?: throw IOException("Output stream is null")
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/FileSize.kt b/app/src/main/java/org/koitharu/kotatsu/utils/FileSize.kt
index f06200dbd..cb558edfe 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/FileSize.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/FileSize.kt
@@ -32,4 +32,4 @@ enum class FileSize(private val multiplier: Int) {
}
}
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/GridTouchHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/GridTouchHelper.kt
index 86c6cccf0..13ccd3fa7 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/GridTouchHelper.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/GridTouchHelper.kt
@@ -5,8 +5,10 @@ import android.view.GestureDetector
import android.view.MotionEvent
import kotlin.math.roundToInt
-class GridTouchHelper(context: Context, private val listener: OnGridTouchListener) :
- GestureDetector.SimpleOnGestureListener() {
+class GridTouchHelper(
+ context: Context,
+ private val listener: OnGridTouchListener
+) : GestureDetector.SimpleOnGestureListener() {
private val detector = GestureDetector(context, this)
private val width = context.resources.displayMetrics.widthPixels
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt
deleted file mode 100644
index 01eaf7118..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-package org.koitharu.kotatsu.utils
-
-import androidx.annotation.WorkerThread
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runInterruptible
-import kotlinx.coroutines.withContext
-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
-
-open class MutableZipFile(val file: File) {
-
- protected val dir = File(file.parentFile, file.nameWithoutExtension)
-
- suspend fun unpack(): Unit = runInterruptible(Dispatchers.IO) {
- check(dir.list().isNullOrEmpty()) {
- "Dir ${dir.name} is not empty"
- }
- if (!dir.exists()) {
- dir.mkdir()
- }
- if (!file.exists()) {
- return@runInterruptible
- }
- ZipInputStream(FileInputStream(file)).use { zip ->
- var entry = zip.nextEntry
- while (entry != null) {
- val target = File(dir.path + File.separator + entry.name)
- target.parentFile?.mkdirs()
- target.outputStream().use { out ->
- zip.copyTo(out)
- }
- zip.closeEntry()
- entry = zip.nextEntry
- }
- }
- }
-
- suspend fun cleanup() = withContext(Dispatchers.IO) {
- dir.deleteRecursively()
- }
-
- suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) {
- val tempFile = File(file.path + ".tmp")
- if (tempFile.exists()) {
- tempFile.delete()
- }
- try {
- ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
- dir.listFiles()?.forEach {
- zipFile(it, it.name, zip)
- }
- zip.flush()
- }
- tempFile.renameTo(file)
- } finally {
- if (tempFile.exists()) {
- tempFile.delete()
- }
- }
- }
-
- operator fun get(name: String) = File(dir, name)
-
- suspend fun put(name: String, file: File): Unit = withContext(Dispatchers.IO) {
- file.copyTo(this@MutableZipFile[name], overwrite = true)
- }
-
- suspend fun put(name: String, data: String): Unit = withContext(Dispatchers.IO) {
- this@MutableZipFile[name].writeText(data)
- }
-
- suspend fun getContent(name: String): String = withContext(Dispatchers.IO) {
- get(name).readText()
- }
-
- companion object {
-
- @WorkerThread
- 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)
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt b/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt
new file mode 100644
index 000000000..57eb100a4
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/PausingDispatcher.kt
@@ -0,0 +1,50 @@
+package org.koitharu.kotatsu.utils
+
+import androidx.annotation.MainThread
+import java.util.concurrent.ConcurrentLinkedQueue
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Runnable
+
+class PausingDispatcher(
+ private val dispatcher: CoroutineDispatcher,
+) : CoroutineDispatcher() {
+
+ @Volatile
+ private var isPaused = false
+ private val queue = ConcurrentLinkedQueue()
+
+ override fun isDispatchNeeded(context: CoroutineContext): Boolean {
+ return isPaused || super.isDispatchNeeded(context)
+ }
+
+ override fun dispatch(context: CoroutineContext, block: Runnable) {
+ if (isPaused) {
+ queue.add(Task(context, block))
+ } else {
+ dispatcher.dispatch(context, block)
+ }
+ }
+
+ @MainThread
+ fun pause() {
+ isPaused = true
+ }
+
+ @MainThread
+ fun resume() {
+ if (!isPaused) {
+ return
+ }
+ isPaused = false
+ while (true) {
+ val task = queue.poll() ?: break
+ dispatcher.dispatch(task.context, task.block)
+ }
+ }
+
+ private class Task(
+ val context: CoroutineContext,
+ val block: Runnable,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/SelectionController.kt b/app/src/main/java/org/koitharu/kotatsu/utils/SelectionController.kt
deleted file mode 100644
index f3274af2e..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/utils/SelectionController.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package org.koitharu.kotatsu.utils
-
-import kotlinx.coroutines.flow.MutableStateFlow
-
-class SelectionController {
-
- private val state = MutableStateFlow(emptySet())
-
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt b/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt
new file mode 100644
index 000000000..e95e0fb96
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/VoiceInputContract.kt
@@ -0,0 +1,26 @@
+package org.koitharu.kotatsu.utils
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.speech.RecognizerIntent
+import androidx.activity.result.contract.ActivityResultContract
+
+class VoiceInputContract : ActivityResultContract() {
+
+ override fun createIntent(context: Context, input: String?): Intent {
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
+ intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
+ intent.putExtra(RecognizerIntent.EXTRA_PROMPT, input)
+ return intent
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): String? {
+ return if (resultCode == Activity.RESULT_OK && intent != null) {
+ val matches = intent.getStringArrayExtra(RecognizerIntent.EXTRA_RESULTS)
+ matches?.firstOrNull()
+ } else {
+ null
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt b/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt
deleted file mode 100644
index 214c934dd..000000000
--- a/app/src/main/java/org/koitharu/kotatsu/utils/WordSet.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package org.koitharu.kotatsu.utils
-
-class WordSet(private vararg val words: String) {
-
- fun anyWordIn(dateString: String): Boolean = words.any {
- dateString.contains(it, ignoreCase = true)
- }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
index 21140be00..6f15f7cd3 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
@@ -1,22 +1,31 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
+import android.content.pm.ResolveInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri
+import android.os.Build
+import androidx.activity.result.ActivityResultLauncher
+import androidx.core.app.ActivityOptionsCompat
import androidx.work.CoroutineWorker
-import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlin.coroutines.resume
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
suspend fun ConnectivityManager.waitForNetwork(): Network {
val request = NetworkRequest.Builder().build()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ // fast path
+ activeNetwork?.let { return it }
+ }
return suspendCancellableCoroutine { cont ->
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
+ unregisterNetworkCallback(this)
if (cont.isActive) {
cont.resume(network)
}
@@ -34,4 +43,16 @@ fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
val info = getForegroundInfo()
setForeground(info)
-}.isSuccess
\ No newline at end of file
+}.isSuccess
+
+fun ActivityResultLauncher.resolve(context: Context, input: I): ResolveInfo? {
+ val pm = context.packageManager
+ val intent = contract.createIntent(context, input)
+ return pm.resolveActivity(intent, 0)
+}
+
+fun ActivityResultLauncher.tryLaunch(input: I, options: ActivityOptionsCompat? = null): Boolean {
+ return runCatching {
+ launch(input, options)
+ }.isSuccess
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
index 916d83491..04bc82e1d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt
@@ -3,8 +3,6 @@ package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArraySet
import java.util.*
-fun LongArray.toArraySet(): Set = createSet(size) { i -> this[i] }
-
fun > Array.names() = Array(size) { i ->
this[i].name
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt
index 67ca43114..dc6974749 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt
@@ -1,16 +1,14 @@
package org.koitharu.kotatsu.utils.ext
import android.content.res.Resources
-import android.util.Log
-import java.io.FileNotFoundException
-import java.net.SocketTimeoutException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
-import org.koitharu.kotatsu.parsers.util.format
+import java.io.FileNotFoundException
+import java.net.SocketTimeoutException
fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -22,12 +20,4 @@ fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
is SocketTimeoutException -> resources.getString(R.string.network_error)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
else -> localizedMessage ?: resources.getString(R.string.error_occurred)
-}
-
-inline fun measured(tag: String, block: () -> T): T {
- val time = System.currentTimeMillis()
- val res = block()
- val spent = System.currentTimeMillis() - time
- Log.d("measured", "$tag ${spent.format(1)} ms")
- return res
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt
index 40f1268b9..412ead77c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CoroutineExt.kt
@@ -3,10 +3,8 @@ package org.koitharu.kotatsu.utils.ext
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CoroutineExceptionHandler
import org.koitharu.kotatsu.BuildConfig
-import kotlin.coroutines.CoroutineContext
-import kotlin.coroutines.EmptyCoroutineContext
val IgnoreErrors
get() = CoroutineExceptionHandler { _, e ->
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt
index 89a076473..0cc08fd55 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/DateExt.kt
@@ -15,6 +15,6 @@ fun Date.formatRelative(minResolution: Long): CharSequence = DateUtils.getRelati
fun Date.daysDiff(other: Long): Int {
val thisDay = time / TimeUnit.DAYS.toMillis(1L)
- val otherDay = other/ TimeUnit.DAYS.toMillis(1L)
+ val otherDay = other / TimeUnit.DAYS.toMillis(1L)
return (thisDay - otherDay).toInt()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt
index cae599f8a..d881d3b1d 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FragmentExt.kt
@@ -26,11 +26,12 @@ fun Fragment.parcelableArgument(name: String): Lazy {
}
}
-inline fun Fragment.serializableArgument(name: String): Lazy {
+fun Fragment.serializableArgument(name: String): Lazy {
return lazy(LazyThreadSafetyMode.NONE) {
- requireNotNull(arguments?.getSerializable(name) as? T) {
+ @Suppress("UNCHECKED_CAST")
+ requireNotNull(arguments?.getSerializable(name)) {
"No argument $name passed into ${javaClass.simpleName}"
- }
+ } as T
}
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt
index 03123c692..ad1fe8b39 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/LiveDataExt.kt
@@ -4,10 +4,10 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.liveData
-import kotlinx.coroutines.flow.Flow
-import org.koitharu.kotatsu.utils.BufferedObserver
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
+import kotlinx.coroutines.flow.Flow
+import org.koitharu.kotatsu.utils.BufferedObserver
fun LiveData.observeNotNull(owner: LifecycleOwner, observer: Observer) {
this.observe(owner) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PreferencesExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PreferencesExt.kt
index 2bbdd6498..d20c1eca6 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/PreferencesExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/PreferencesExt.kt
@@ -9,17 +9,17 @@ fun ListPreference.setDefaultValueCompat(defaultValue: String) {
}
}
-fun > SharedPreferences.getEnumValue(key: String, enumClass: Class): E? {
+fun > SharedPreferences.getEnumValue(key: String, enumClass: Class): E? {
val stringValue = getString(key, null) ?: return null
return enumClass.enumConstants?.find {
it.name == stringValue
}
}
-fun > SharedPreferences.getEnumValue(key: String, defaultValue: E): E {
+fun > SharedPreferences.getEnumValue(key: String, defaultValue: E): E {
return getEnumValue(key, defaultValue.javaClass) ?: defaultValue
}
-fun > SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
+fun > SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
putString(key, value?.name)
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt
index 59620f2b0..bca90a845 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/TextViewExt.kt
@@ -5,12 +5,11 @@ import android.view.View
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
-import androidx.core.content.res.use
import androidx.core.view.isGone
var TextView.textAndVisible: CharSequence?
- inline get() = text?.takeIf { visibility == View.VISIBLE }
- inline set(value) {
+ get() = text?.takeIf { visibility == View.VISIBLE }
+ set(value) {
text = value
isGone = value.isNullOrEmpty()
}
@@ -40,8 +39,5 @@ fun TextView.setTextAndVisible(@StringRes textResId: Int) {
}
fun TextView.setTextColorAttr(@AttrRes attrResId: Int) {
- val colors = context.obtainStyledAttributes(intArrayOf(attrResId)).use {
- it.getColorStateList(0)
- }
- setTextColor(colors)
+ setTextColor(context.getThemeColorStateList(attrResId))
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt
index 795fd3979..85ee5ea39 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ThemeExt.kt
@@ -4,20 +4,24 @@ import android.content.Context
import android.graphics.Color
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
-import androidx.annotation.Px
import androidx.core.content.res.use
-@Px
-fun Context.getThemeDimen(@AttrRes resId: Int) = obtainStyledAttributes(intArrayOf(resId)).use {
- it.getDimension(0, 0f)
-}
-
-fun Context.getThemeDrawable(@AttrRes resId: Int) = obtainStyledAttributes(intArrayOf(resId)).use {
+fun Context.getThemeDrawable(
+ @AttrRes resId: Int,
+) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDrawable(0)
}
@ColorInt
-fun Context.getThemeColor(@AttrRes resId: Int, @ColorInt default: Int = Color.TRANSPARENT) =
- obtainStyledAttributes(intArrayOf(resId)).use {
- it.getColor(0, default)
- }
\ No newline at end of file
+fun Context.getThemeColor(
+ @AttrRes resId: Int,
+ @ColorInt default: Int = Color.TRANSPARENT
+) = obtainStyledAttributes(intArrayOf(resId)).use {
+ it.getColor(0, default)
+}
+
+fun Context.getThemeColorStateList(
+ @AttrRes resId: Int,
+) = obtainStyledAttributes(intArrayOf(resId)).use {
+ it.getColorStateList(0)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
index c52d50290..021c77859 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/ViewExt.kt
@@ -2,20 +2,15 @@ package org.koitharu.kotatsu.utils.ext
import android.app.Activity
import android.graphics.Rect
-import android.view.LayoutInflater
-import android.view.Menu
import android.view.View
-import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
-import androidx.annotation.LayoutRes
-import androidx.annotation.MenuRes
-import androidx.appcompat.widget.PopupMenu
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.slider.Slider
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
+import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewHolder
import kotlin.math.roundToInt
fun View.hideKeyboard() {
@@ -28,19 +23,15 @@ fun View.showKeyboard() {
imm.showSoftInput(this, 0)
}
-inline fun ViewGroup.inflate(@LayoutRes resId: Int) =
- LayoutInflater.from(context).inflate(resId, this, false) as T
-
-val RecyclerView.hasItems: Boolean
- get() = (adapter?.itemCount ?: 0) > 0
-
fun RecyclerView.clearItemDecorations() {
+ suppressLayout(true)
while (itemDecorationCount > 0) {
removeItemDecorationAt(0)
}
+ suppressLayout(false)
}
-var RecyclerView.firstItem: Int
+var RecyclerView.firstVisibleItemPosition: Int
get() = (layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()
?: RecyclerView.NO_POSITION
set(value) {
@@ -49,18 +40,6 @@ var RecyclerView.firstItem: Int
}
}
-inline fun View.showPopupMenu(
- @MenuRes menuRes: Int,
- onPrepare: (Menu) -> Unit = {},
- onItemClick: PopupMenu.OnMenuItemClickListener,
-) {
- val menu = PopupMenu(context, this)
- menu.inflate(menuRes)
- menu.setOnMenuItemClickListener(onItemClick)
- onPrepare(menu.menu)
- menu.show()
-}
-
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
if (visibility != View.VISIBLE) {
return false
@@ -97,7 +76,7 @@ inline fun ViewPager2.doOnPageChanged(crossinline callback: (Int) -> Unit) {
}
val ViewPager2.recyclerView: RecyclerView?
- inline get() = children.find { it is RecyclerView } as? RecyclerView
+ get() = children.firstNotNullOfOrNull { it as? RecyclerView }
fun View.resetTransformations() {
alpha = 1f
@@ -106,6 +85,7 @@ fun View.resetTransformations() {
translationZ = 0f
scaleX = 1f
scaleY = 1f
+ rotation = 0f
rotationX = 0f
rotationY = 0f
}
@@ -133,8 +113,17 @@ fun RecyclerView.findCenterViewPosition(): Int {
return getChildAdapterPosition(view)
}
-inline fun RecyclerView.ViewHolder.getItem(): T? {
- return ((this as? AdapterDelegateViewBindingViewHolder<*, *>)?.item as? T)
+fun RecyclerView.ViewHolder.getItem(clazz: Class): T? {
+ val rawItem = when (this) {
+ is AdapterDelegateViewBindingViewHolder<*, *> -> item
+ is AdapterDelegateViewHolder<*> -> item
+ else -> null
+ } ?: return null
+ return if (clazz.isAssignableFrom(rawItem.javaClass)) {
+ clazz.cast(rawItem)
+ } else {
+ null
+ }
}
fun Slider.setValueRounded(newValue: Float) {
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt
index eb38e1d32..317c77c5f 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/ImageRequestIndicatorListener.kt
@@ -1,7 +1,8 @@
package org.koitharu.kotatsu.utils.progress
+import coil.request.ErrorResult
import coil.request.ImageRequest
-import coil.request.ImageResult
+import coil.request.SuccessResult
import com.google.android.material.progressindicator.BaseProgressIndicator
class ImageRequestIndicatorListener(
@@ -10,9 +11,9 @@ class ImageRequestIndicatorListener(
override fun onCancel(request: ImageRequest) = indicator.hide()
- override fun onError(request: ImageRequest, throwable: Throwable) = indicator.hide()
+ override fun onError(request: ImageRequest, result: ErrorResult) = indicator.hide()
override fun onStart(request: ImageRequest) = indicator.show()
- override fun onSuccess(request: ImageRequest, metadata: ImageResult.Metadata) = indicator.hide()
+ override fun onSuccess(request: ImageRequest, result: SuccessResult) = indicator.hide()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt
index ed9773c99..5456ae5b0 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/IntPercentLabelFormatter.kt
@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.utils.progress
+import android.content.Context
import com.google.android.material.slider.LabelFormatter
+import org.koitharu.kotatsu.R
-class IntPercentLabelFormatter : LabelFormatter {
- override fun getFormattedValue(value: Float) = "%d%%".format(value.toInt())
+class IntPercentLabelFormatter(context: Context) : LabelFormatter {
+
+ private val pattern = context.getString(R.string.percent_string_pattern)
+
+ override fun getFormattedValue(value: Float) = pattern.format(value.toInt().toString())
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt
index 7dff7fbf5..5723cae17 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt
@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class Progress(
val value: Int,
- val total: Int
+ val total: Int,
) : Parcelable, Comparable {
override fun compareTo(other: Progress): Int {
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt
new file mode 100644
index 000000000..f998a5119
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/TimeLeftEstimator.kt
@@ -0,0 +1,50 @@
+package org.koitharu.kotatsu.utils.progress
+
+import android.os.SystemClock
+import java.util.concurrent.TimeUnit
+import kotlin.math.roundToInt
+import kotlin.math.roundToLong
+
+private const val MIN_ESTIMATE_TICKS = 4
+private const val NO_TIME = -1L
+
+class TimeLeftEstimator {
+
+ private var times = ArrayList()
+ private var lastTick: Tick? = null
+ private val tooLargeTime = TimeUnit.DAYS.toMillis(1)
+
+ fun tick(value: Int, total: Int) {
+ if (total < 0) {
+ emptyTick()
+ return
+ }
+ val tick = Tick(value, total, SystemClock.elapsedRealtime())
+ lastTick?.let {
+ val ticksCount = value - it.value
+ times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt())
+ }
+ lastTick = tick
+ }
+
+ fun emptyTick() {
+ lastTick = null
+ }
+
+ fun getEstimatedTimeLeft(): Long {
+ val progress = lastTick ?: return NO_TIME
+ if (times.size < MIN_ESTIMATE_TICKS) {
+ return NO_TIME
+ }
+ val timePerTick = times.average()
+ val ticksLeft = progress.total - progress.value
+ val eta = (ticksLeft * timePerTick).roundToLong()
+ return if (eta < tooLargeTime) eta else NO_TIME
+ }
+
+ private class Tick(
+ val value: Int,
+ val total: Int,
+ val time: Long,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_hidden.xml b/app/src/main/res/drawable/ic_hidden.xml
new file mode 100644
index 000000000..82816e502
--- /dev/null
+++ b/app/src/main/res/drawable/ic_hidden.xml
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml
new file mode 100644
index 000000000..1048bdfff
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml
@@ -0,0 +1,22 @@
+
+
+
+