Compare commits
60 Commits
testbuild-
...
v3.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa150e45ff | ||
|
|
de9c1017b3 | ||
|
|
2709d40fc0 | ||
|
|
45b42ad5bd | ||
|
|
b759f8d0a0 | ||
|
|
23e7aa2aaa | ||
|
|
fdd4f5abca | ||
|
|
c695468aec | ||
|
|
9166716f2a | ||
|
|
3407e74e99 | ||
|
|
4c5314fe59 | ||
|
|
96be49aa83 | ||
|
|
28b556121b | ||
|
|
558c19e526 | ||
|
|
59c2d20311 | ||
|
|
fa1f2cbf51 | ||
|
|
de8739f143 | ||
|
|
9aa28f6fd2 | ||
|
|
a2b1699047 | ||
|
|
2dce65a448 | ||
|
|
3d68d7c818 | ||
|
|
4987d43042 | ||
|
|
684b494edb | ||
|
|
714b708fa9 | ||
|
|
c462c19a8b | ||
|
|
e34acf010e | ||
|
|
0fb29174c5 | ||
|
|
ca45774cdb | ||
|
|
cccc2c4fe4 | ||
|
|
c73af2d45f | ||
|
|
acf7102d07 | ||
|
|
75fcd31758 | ||
|
|
7bffb5f22d | ||
|
|
c220bd5517 | ||
|
|
7c827b45d5 | ||
|
|
e91d9ee38e | ||
|
|
b6a86a6538 | ||
|
|
16b6b6c071 | ||
|
|
695feef4a6 | ||
|
|
6bf4e0cf89 | ||
|
|
44d8d0f246 | ||
|
|
e617e8d6d3 | ||
|
|
1f411b7530 | ||
|
|
d64bd9d9d3 | ||
|
|
f33dc8f797 | ||
|
|
e63ae12c8c | ||
|
|
cbd3d439cd | ||
|
|
83eb0d9f23 | ||
|
|
3c739eed8e | ||
|
|
d77646adf1 | ||
|
|
5b5e6cba57 | ||
|
|
8fc9b27840 | ||
|
|
fa536220eb | ||
|
|
98f16774c4 | ||
|
|
ce8f57c3ca | ||
|
|
be66106336 | ||
|
|
16c8641a07 | ||
|
|
d3e9ce874a | ||
|
|
aaf9c6a0bf | ||
|
|
c2276eb2cb |
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: ⚠️ Source issue
|
||||||
|
url: https://github.com/nv95/kotatsu-parsers/issues/new
|
||||||
|
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead
|
||||||
93
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
Normal file
93
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
name: 🐞 Issue report
|
||||||
|
description: Report an issue in Kotatsu
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce-steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Provide an example of the issue.
|
||||||
|
placeholder: |
|
||||||
|
Example:
|
||||||
|
1. First step
|
||||||
|
2. Second step
|
||||||
|
3. Issue here
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: Explain what you should expect to happen.
|
||||||
|
placeholder: |
|
||||||
|
Example:
|
||||||
|
"This should happen..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual-behavior
|
||||||
|
attributes:
|
||||||
|
label: Actual behavior
|
||||||
|
description: Explain what actually happens.
|
||||||
|
placeholder: |
|
||||||
|
Example:
|
||||||
|
"This happened instead..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: kotatsu-version
|
||||||
|
attributes:
|
||||||
|
label: Kotatsu version
|
||||||
|
description: You can find your Kotatsu version in **Settings → About**.
|
||||||
|
placeholder: |
|
||||||
|
Example: "3.2.2"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: android-version
|
||||||
|
attributes:
|
||||||
|
label: Android version
|
||||||
|
description: You can find this somewhere in your Android settings.
|
||||||
|
placeholder: |
|
||||||
|
Example: "Android 12"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Device
|
||||||
|
description: List your device and model.
|
||||||
|
placeholder: |
|
||||||
|
Example: "LG Nexus 5X"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: other-details
|
||||||
|
attributes:
|
||||||
|
label: Other details
|
||||||
|
placeholder: |
|
||||||
|
Additional details and attachments.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: acknowledgements
|
||||||
|
attributes:
|
||||||
|
label: Acknowledgements
|
||||||
|
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
|
required: true
|
||||||
|
- label: I have written a short but informative title.
|
||||||
|
required: true
|
||||||
|
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
|
||||||
|
required: true
|
||||||
|
- label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||||
|
required: true
|
||||||
|
- label: I will fill out all of the requested information in this form.
|
||||||
|
required: true
|
||||||
39
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: ⭐ Feature request
|
||||||
|
description: Suggest a feature to improve Kotatsu
|
||||||
|
labels: [feature request]
|
||||||
|
body:
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: feature-description
|
||||||
|
attributes:
|
||||||
|
label: Describe your suggested feature
|
||||||
|
description: How can Kotatsu be improved?
|
||||||
|
placeholder: |
|
||||||
|
Example:
|
||||||
|
"It should work like this..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: other-details
|
||||||
|
attributes:
|
||||||
|
label: Other details
|
||||||
|
placeholder: |
|
||||||
|
Additional details and attachments.
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: acknowledgements
|
||||||
|
attributes:
|
||||||
|
label: Acknowledgements
|
||||||
|
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||||
|
required: true
|
||||||
|
- label: I have written a short but informative title.
|
||||||
|
required: true
|
||||||
|
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
|
||||||
|
required: true
|
||||||
|
- label: I have updated the app to version **[3.2.2](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||||
|
required: true
|
||||||
|
- label: I will fill out all of the requested information in this form.
|
||||||
|
required: true
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,6 +9,8 @@
|
|||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/kotlinScripting.xml
|
||||||
|
/.idea/deploymentTargetDropDown.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
|
|||||||
17
.idea/deploymentTargetDropDown.xml
generated
17
.idea/deploymentTargetDropDown.xml
generated
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="deploymentTargetDropDown">
|
|
||||||
<targetSelectedWithDropDown>
|
|
||||||
<Target>
|
|
||||||
<type value="QUICK_BOOT_TARGET" />
|
|
||||||
<deviceKey>
|
|
||||||
<Key>
|
|
||||||
<type value="VIRTUAL_DEVICE_PATH" />
|
|
||||||
<value value="$USER_HOME$/.android/avd/Pixel_API_S.avd" />
|
|
||||||
</Key>
|
|
||||||
</deviceKey>
|
|
||||||
</Target>
|
|
||||||
</targetSelectedWithDropDown>
|
|
||||||
<timeTargetWasSelectedWithDropDown value="2021-02-19T19:02:37.198775Z" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
4
.idea/inspectionProfiles/Project_Default.xml
generated
4
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,8 +1,10 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
<component name="InspectionProjectProfileManager">
|
||||||
<profile version="1.0">
|
<profile version="1.0">
|
||||||
<option name="myName" value="Project Default" />
|
<option name="myName" value="Project Default" />
|
||||||
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
|
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
|
||||||
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||||
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
|
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
|
||||||
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
</profile>
|
</profile>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 32
|
targetSdkVersion 32
|
||||||
versionCode 402
|
versionCode 406
|
||||||
versionName '3.1.1'
|
versionName '3.2.2'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ android {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||||
implementation('com.github.nv95:kotatsu-parsers:8e23a7fcd4') {
|
implementation('com.github.nv95:kotatsu-parsers:b495e5e457') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ dependencies {
|
|||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||||
implementation 'com.google.android.material:material:1.6.0-beta01'
|
implementation 'com.google.android.material:material:1.6.0'
|
||||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ dependencies {
|
|||||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import java.util.*
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This parser is just for parser development, it should not be used in releases
|
||||||
|
*/
|
||||||
|
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
|
||||||
|
|
||||||
|
override val configKeyDomain: ConfigKey.Domain
|
||||||
|
get() = ConfigKey.Domain("", null)
|
||||||
|
|
||||||
|
override val sortOrders: Set<SortOrder>
|
||||||
|
get() = EnumSet.allOf(SortOrder::class.java)
|
||||||
|
|
||||||
|
override suspend fun getDetails(manga: Manga): Manga {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getList(
|
||||||
|
offset: Int,
|
||||||
|
query: String?,
|
||||||
|
tags: Set<MangaTag>?,
|
||||||
|
sortOrder: SortOrder?
|
||||||
|
): List<Manga> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTags(): Set<MangaTag> {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.newParser
|
||||||
|
|
||||||
|
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||||
|
return if (source == MangaSource.DUMMY) {
|
||||||
|
DummyParser(loaderContext)
|
||||||
|
} else {
|
||||||
|
source.newParser(loaderContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
import org.koitharu.kotatsu.parsers.util.medianOrNull
|
import org.koitharu.kotatsu.parsers.util.medianOrNull
|
||||||
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
@@ -59,6 +60,14 @@ object MangaUtils : KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) {
|
||||||
|
val options = BitmapFactory.Options().apply {
|
||||||
|
inJustDecodeBounds = true
|
||||||
|
}
|
||||||
|
BitmapFactory.decodeFile(file.path, options)?.recycle()
|
||||||
|
options.outMimeType
|
||||||
|
}
|
||||||
|
|
||||||
private fun getBitmapSize(input: InputStream?): Size {
|
private fun getBitmapSize(input: InputStream?): Size {
|
||||||
val options = BitmapFactory.Options().apply {
|
val options = BitmapFactory.Options().apply {
|
||||||
inJustDecodeBounds = true
|
inJustDecodeBounds = true
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import android.view.ViewGroup.LayoutParams
|
|||||||
import androidx.appcompat.app.AppCompatDialog
|
import androidx.appcompat.app.AppCompatDialog
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
@@ -43,7 +44,9 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
|||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
return if (resources.getBoolean(R.bool.is_tablet)) {
|
return if (resources.getBoolean(R.bool.is_tablet)) {
|
||||||
AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
|
AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
|
||||||
} else super.onCreateDialog(savedInstanceState)
|
} else {
|
||||||
|
AppBottomSheetDialog(requireContext(), theme)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.base.ui
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
|
|
||||||
abstract class BaseViewModel : ViewModel() {
|
abstract class BaseViewModel : ViewModel() {
|
||||||
|
|
||||||
val onError = SingleLiveEvent<Throwable>()
|
val onError = SingleLiveEvent<Throwable>()
|
||||||
val isLoading = MutableLiveData(false)
|
val isLoading = CountedBooleanLiveData()
|
||||||
|
|
||||||
protected fun launchJob(
|
protected fun launchJob(
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
abstract class CoroutineIntentService : BaseService() {
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
super.onStartCommand(intent, flags, startId)
|
||||||
|
launchCoroutine(intent, startId)
|
||||||
|
return Service.START_REDELIVER_INTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
|
||||||
|
mutex.withLock {
|
||||||
|
try {
|
||||||
|
withContext(dispatcher) {
|
||||||
|
processIntent(intent)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract suspend fun processIntent(intent: Intent?)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.View
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
|
||||||
|
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/material-components/material-components-android/issues/2582
|
||||||
|
*/
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
val window = window
|
||||||
|
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
if (window != null) {
|
||||||
|
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
|
||||||
|
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
|
||||||
|
if (drawEdgeToEdge) {
|
||||||
|
// Copied from super.onAttachedToWindow:
|
||||||
|
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
|
// Fix super-class's window flag bug by respecting the intial system UI visibility:
|
||||||
|
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.list.decor
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.util.SparseIntArray
|
||||||
|
import android.view.View
|
||||||
|
import androidx.core.util.getOrDefault
|
||||||
|
import androidx.core.util.set
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
|
class TypedSpacingItemDecoration(
|
||||||
|
vararg spacingMapping: Pair<Int, Int>,
|
||||||
|
private val fallbackSpacing: Int = 0,
|
||||||
|
) : RecyclerView.ItemDecoration() {
|
||||||
|
|
||||||
|
private val mapping = SparseIntArray(spacingMapping.size)
|
||||||
|
|
||||||
|
init {
|
||||||
|
spacingMapping.forEach { (k, v) -> mapping[k] = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemOffsets(
|
||||||
|
outRect: Rect,
|
||||||
|
view: View,
|
||||||
|
parent: RecyclerView,
|
||||||
|
state: RecyclerView.State
|
||||||
|
) {
|
||||||
|
val itemType = parent.getChildViewHolder(view)?.itemViewType
|
||||||
|
val spacing = if (itemType == null) {
|
||||||
|
fallbackSpacing
|
||||||
|
} else {
|
||||||
|
mapping.getOrDefault(itemType, fallbackSpacing)
|
||||||
|
}
|
||||||
|
outRect.set(spacing, spacing, spacing, spacing)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
|
||||||
|
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) {
|
||||||
|
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
override fun setValue(value: Boolean) {
|
||||||
|
if (value) {
|
||||||
|
counter++
|
||||||
|
} else {
|
||||||
|
counter--
|
||||||
|
}
|
||||||
|
val newValue = counter > 0
|
||||||
|
if (newValue != this.value) {
|
||||||
|
super.setValue(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import android.os.Parcel
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.os.Parcelable.Creator
|
import android.os.Parcelable.Creator
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
import android.widget.Checkable
|
import android.widget.Checkable
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.appcompat.widget.AppCompatImageView
|
import androidx.appcompat.widget.AppCompatImageView
|
||||||
@@ -61,6 +62,12 @@ class CheckableImageView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ToggleOnClickListener : OnClickListener {
|
||||||
|
override fun onClick(view: View) {
|
||||||
|
(view as? Checkable)?.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun interface OnCheckedChangeListener {
|
fun interface OnCheckedChangeListener {
|
||||||
|
|
||||||
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
|
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import android.view.MenuItem
|
|||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
@@ -28,6 +29,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
with(binding.webView.settings) {
|
with(binding.webView.settings) {
|
||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
|
userAgentString = UserAgentInterceptor.userAgent
|
||||||
}
|
}
|
||||||
binding.webView.webViewClient = BrowserClient(this)
|
binding.webView.webViewClient = BrowserClient(this)
|
||||||
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
||||||
|
|||||||
@@ -2,15 +2,11 @@ package org.koitharu.kotatsu.browser
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
|
||||||
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
||||||
|
|
||||||
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
|
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
|
||||||
|
|
||||||
private val okHttp by inject<OkHttpClient>(mode = LazyThreadSafetyMode.SYNCHRONIZED)
|
|
||||||
|
|
||||||
override fun onPageFinished(webView: WebView, url: String) {
|
override fun onPageFinished(webView: WebView, url: String) {
|
||||||
super.onPageFinished(webView, url)
|
super.onPageFinished(webView, url)
|
||||||
callback.onLoadingStateChanged(isLoading = false)
|
callback.onLoadingStateChanged(isLoading = false)
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import java.io.File
|
|
||||||
import java.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.utils.MutableZipFile
|
|
||||||
import org.koitharu.kotatsu.utils.ext.format
|
|
||||||
|
|
||||||
class BackupArchive(file: File) : MutableZipFile(file) {
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!dir.exists()) {
|
|
||||||
dir.mkdirs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun put(entry: BackupEntry) {
|
|
||||||
put(entry.name, entry.data.toString(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getEntry(name: String): BackupEntry {
|
|
||||||
val json = withContext(Dispatchers.Default) {
|
|
||||||
JSONArray(getContent(name))
|
|
||||||
}
|
|
||||||
return BackupEntry(name, json)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val DIR_BACKUPS = "backups"
|
|
||||||
|
|
||||||
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
|
||||||
val filename = buildString {
|
|
||||||
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
|
|
||||||
append('_')
|
|
||||||
append(Date().format("ddMMyyyy"))
|
|
||||||
append(".bak")
|
|
||||||
}
|
|
||||||
BackupArchive(File(dir, filename))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.Closeable
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.io.File
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
class BackupZipInput(val file: File) : Closeable {
|
||||||
|
|
||||||
|
private val zipFile = ZipFile(file)
|
||||||
|
|
||||||
|
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
|
||||||
|
val entry = zipFile.getEntry(name)
|
||||||
|
val json = zipFile.getInputStream(entry).use {
|
||||||
|
JSONArray(it.bufferedReader().readText())
|
||||||
|
}
|
||||||
|
BackupEntry(name, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
zipFile.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.koitharu.kotatsu.core.backup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.Closeable
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||||
|
import org.koitharu.kotatsu.utils.ext.format
|
||||||
|
import java.io.File
|
||||||
|
import java.util.*
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
|
||||||
|
class BackupZipOutput(val file: File) : Closeable {
|
||||||
|
|
||||||
|
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||||
|
|
||||||
|
suspend fun put(entry: BackupEntry) {
|
||||||
|
output.put(entry.name, entry.data.toString(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun finish() {
|
||||||
|
output.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DIR_BACKUPS = "backups"
|
||||||
|
|
||||||
|
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||||
|
val dir = context.run {
|
||||||
|
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||||
|
}
|
||||||
|
dir.mkdirs()
|
||||||
|
val filename = buildString {
|
||||||
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
|
append('_')
|
||||||
|
append(Date().format("ddMMyyyy"))
|
||||||
|
append(".bk.zip")
|
||||||
|
}
|
||||||
|
BackupZipOutput(File(dir, filename))
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import java.util.*
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class UserAgentInterceptor : Interceptor {
|
class UserAgentInterceptor : Interceptor {
|
||||||
|
|
||||||
@@ -30,5 +30,14 @@ class UserAgentInterceptor : Interceptor {
|
|||||||
Build.DEVICE,
|
Build.DEVICE,
|
||||||
Locale.getDefault().language
|
Locale.getDefault().language
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val userAgentChrome
|
||||||
|
get() = (
|
||||||
|
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
|
"Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||||
|
).format(
|
||||||
|
Build.VERSION.RELEASE,
|
||||||
|
Build.MODEL,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import java.util.*
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
@@ -28,11 +30,18 @@ interface MangaRepository {
|
|||||||
|
|
||||||
companion object : KoinComponent {
|
companion object : KoinComponent {
|
||||||
|
|
||||||
|
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
|
||||||
|
|
||||||
operator fun invoke(source: MangaSource): MangaRepository {
|
operator fun invoke(source: MangaSource): MangaRepository {
|
||||||
return if (source == MangaSource.LOCAL) {
|
if (source == MangaSource.LOCAL) {
|
||||||
get<LocalMangaRepository>()
|
return get<LocalMangaRepository>()
|
||||||
} else {
|
}
|
||||||
RemoteMangaRepository(source, get())
|
cache[source]?.get()?.let { return it }
|
||||||
|
return synchronized(cache) {
|
||||||
|
cache[source]?.get()?.let { return it }
|
||||||
|
val repository = RemoteMangaRepository(MangaParser(source, get()))
|
||||||
|
cache[source] = WeakReference(repository)
|
||||||
|
repository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.newParser
|
|
||||||
|
|
||||||
class RemoteMangaRepository(
|
class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
|
||||||
override val source: MangaSource,
|
|
||||||
loaderContext: MangaLoaderContext,
|
|
||||||
) : MangaRepository {
|
|
||||||
|
|
||||||
private val parser: MangaParser = source.newParser(loaderContext)
|
override val source: MangaSource
|
||||||
|
get() = parser.source
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
override val sortOrders: Set<SortOrder>
|
||||||
get() = parser.sortOrders
|
get() = parser.sortOrders
|
||||||
@@ -28,7 +24,7 @@ class RemoteMangaRepository(
|
|||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?,
|
||||||
): List<Manga> = parser.getList(offset, query, tags, sortOrder)
|
): List<Manga> = parser.getList(offset, query, tags, sortOrder)
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
|
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
|
||||||
@@ -48,4 +44,4 @@ class RemoteMangaRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getConfig() = parser.config as SourceSettings
|
private fun getConfig() = parser.config as SourceSettings
|
||||||
}
|
}
|
||||||
@@ -11,23 +11,34 @@ import androidx.collection.arraySetOf
|
|||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import java.io.File
|
|
||||||
import java.text.DateFormat
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
||||||
import org.koitharu.kotatsu.utils.ext.putEnumValue
|
import org.koitharu.kotatsu.utils.ext.putEnumValue
|
||||||
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
||||||
|
import java.io.File
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
class AppSettings(context: Context) {
|
class AppSettings(context: Context) {
|
||||||
|
|
||||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
||||||
|
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
|
||||||
|
remove(MangaSource.LOCAL)
|
||||||
|
if (!BuildConfig.DEBUG) {
|
||||||
|
remove(MangaSource.DUMMY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val remoteMangaSources: Set<MangaSource>
|
||||||
|
get() = Collections.unmodifiableSet(remoteSources)
|
||||||
|
|
||||||
var listMode: ListMode
|
var listMode: ListMode
|
||||||
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
|
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
|
||||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
|
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
|
||||||
@@ -56,6 +67,10 @@ class AppSettings(context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||||
|
|
||||||
|
var isAllFavouritesVisible: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
|
||||||
|
|
||||||
val isUpdateCheckingEnabled: Boolean
|
val isUpdateCheckingEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
|
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
|
||||||
|
|
||||||
@@ -104,10 +119,9 @@ class AppSettings(context: Context) {
|
|||||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||||
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
|
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
|
||||||
|
|
||||||
var sourcesOrder: List<Int>
|
var sourcesOrder: List<String>
|
||||||
get() = prefs.getString(KEY_SOURCES_ORDER, null)
|
get() = prefs.getString(KEY_SOURCES_ORDER, null)
|
||||||
?.split('|')
|
?.split('|')
|
||||||
?.mapNotNull(String::toIntOrNull)
|
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
set(value) = prefs.edit {
|
set(value) = prefs.edit {
|
||||||
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
|
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
|
||||||
@@ -120,6 +134,20 @@ class AppSettings(context: Context) {
|
|||||||
val isSourcesSelected: Boolean
|
val isSourcesSelected: Boolean
|
||||||
get() = KEY_SOURCES_HIDDEN in prefs
|
get() = KEY_SOURCES_HIDDEN in prefs
|
||||||
|
|
||||||
|
val newSources: Set<MangaSource>
|
||||||
|
get() {
|
||||||
|
val known = sourcesOrder.toSet()
|
||||||
|
val hidden = hiddenSources
|
||||||
|
return remoteMangaSources
|
||||||
|
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
|
||||||
|
x.name in known || x.name in hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markKnownSources(sources: Collection<MangaSource>) {
|
||||||
|
sourcesOrder = sourcesOrder + sources.map { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
val isPagesNumbersEnabled: Boolean
|
val isPagesNumbersEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||||
|
|
||||||
@@ -141,6 +169,12 @@ class AppSettings(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isDownloadsSlowdownEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
|
||||||
|
|
||||||
|
val downloadsParallelism: Int
|
||||||
|
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
|
||||||
|
|
||||||
val isSuggestionsEnabled: Boolean
|
val isSuggestionsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
||||||
|
|
||||||
@@ -178,11 +212,10 @@ class AppSettings(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
||||||
val list = MangaSource.values().toMutableList()
|
val list = remoteSources.toMutableList()
|
||||||
list.remove(MangaSource.LOCAL)
|
|
||||||
val order = sourcesOrder
|
val order = sourcesOrder
|
||||||
list.sortBy { x ->
|
list.sortBy { x ->
|
||||||
val e = order.indexOf(x.ordinal)
|
val e = order.indexOf(x.name)
|
||||||
if (e == -1) order.size + x.ordinal else e
|
if (e == -1) order.size + x.ordinal else e
|
||||||
}
|
}
|
||||||
if (!includeHidden) {
|
if (!includeHidden) {
|
||||||
@@ -224,7 +257,7 @@ class AppSettings(context: Context) {
|
|||||||
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
||||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||||
const val KEY_DATE_FORMAT = "date_format"
|
const val KEY_DATE_FORMAT = "date_format"
|
||||||
const val KEY_SOURCES_ORDER = "sources_order"
|
const val KEY_SOURCES_ORDER = "sources_order_2"
|
||||||
const val KEY_SOURCES_HIDDEN = "sources_hidden"
|
const val KEY_SOURCES_HIDDEN = "sources_hidden"
|
||||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||||
@@ -261,6 +294,9 @@ class AppSettings(context: Context) {
|
|||||||
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
||||||
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
||||||
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
||||||
|
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
|
||||||
|
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
|
||||||
|
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.core.ui
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||||
|
import org.koitharu.kotatsu.utils.ext.format
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
sealed class DateTimeAgo : ListModel {
|
sealed class DateTimeAgo : ListModel {
|
||||||
|
|
||||||
@@ -72,9 +75,33 @@ sealed class DateTimeAgo : ListModel {
|
|||||||
override fun hashCode(): Int = days
|
override fun hashCode(): Int = days
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Absolute(private val date: Date) : DateTimeAgo() {
|
||||||
|
|
||||||
|
private val day = date.daysDiff(0)
|
||||||
|
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return date.format("d MMMM")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as Absolute
|
||||||
|
|
||||||
|
if (day != other.day) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
object LongAgo : DateTimeAgo() {
|
object LongAgo : DateTimeAgo() {
|
||||||
override fun format(resources: Resources): String {
|
override fun format(resources: Resources): String {
|
||||||
return resources.getString(R.string.long_ago)
|
return resources.getString(R.string.long_ago)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package org.koitharu.kotatsu.core.zip
|
||||||
|
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.collection.ArraySet
|
||||||
|
import okio.Closeable
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
class ZipOutput(
|
||||||
|
val file: File,
|
||||||
|
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
|
||||||
|
) : Closeable {
|
||||||
|
|
||||||
|
private val entryNames = ArraySet<String>()
|
||||||
|
private var isClosed = false
|
||||||
|
private val output = ZipOutputStream(file.outputStream()).apply {
|
||||||
|
setLevel(compressionLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun put(name: String, file: File): Boolean {
|
||||||
|
return output.appendFile(file, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun put(name: String, content: String): Boolean {
|
||||||
|
return output.appendText(content, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun addDirectory(name: String): Boolean {
|
||||||
|
val entry = if (name.endsWith("/")) {
|
||||||
|
ZipEntry(name)
|
||||||
|
} else {
|
||||||
|
ZipEntry("$name/")
|
||||||
|
}
|
||||||
|
return if (entryNames.add(entry.name)) {
|
||||||
|
output.putNextEntry(entry)
|
||||||
|
output.closeEntry()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
|
||||||
|
return if (entryNames.add(entry.name)) {
|
||||||
|
val zipEntry = ZipEntry(entry.name)
|
||||||
|
output.putNextEntry(zipEntry)
|
||||||
|
other.getInputStream(entry).use { input ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
output.closeEntry()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
output.finish()
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (!isClosed) {
|
||||||
|
output.close()
|
||||||
|
isClosed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
|
||||||
|
if (fileToZip.isDirectory) {
|
||||||
|
val entry = if (name.endsWith("/")) {
|
||||||
|
ZipEntry(name)
|
||||||
|
} else {
|
||||||
|
ZipEntry("$name/")
|
||||||
|
}
|
||||||
|
if (!entryNames.add(entry.name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
putNextEntry(entry)
|
||||||
|
closeEntry()
|
||||||
|
fileToZip.listFiles()?.forEach { childFile ->
|
||||||
|
appendFile(childFile, "$name/${childFile.name}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FileInputStream(fileToZip).use { fis ->
|
||||||
|
if (!entryNames.add(name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val zipEntry = ZipEntry(name)
|
||||||
|
putNextEntry(zipEntry)
|
||||||
|
fis.copyTo(this)
|
||||||
|
closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
|
||||||
|
if (!entryNames.add(name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val zipEntry = ZipEntry(name)
|
||||||
|
putNextEntry(zipEntry)
|
||||||
|
content.byteInputStream().copyTo(this)
|
||||||
|
closeEntry()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isGone
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
|||||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||||
|
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
@@ -67,8 +68,8 @@ class ChaptersFragment :
|
|||||||
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
viewModel.hasChapters.observe(viewLifecycleOwner) {
|
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||||
binding.textViewHolder.isGone = it
|
binding.textViewHolder.isVisible = it
|
||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ class ChaptersFragment :
|
|||||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||||
super.onPrepareOptionsMenu(menu)
|
super.onPrepareOptionsMenu(menu)
|
||||||
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
|
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
|
||||||
menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true
|
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||||
@@ -154,11 +155,29 @@ class ChaptersFragment :
|
|||||||
DownloadService.start(
|
DownloadService.start(
|
||||||
context ?: return false,
|
context ?: return false,
|
||||||
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
|
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
|
||||||
selectionDecoration?.checkedItemsIds
|
selectionDecoration?.checkedItemsIds?.toSet()
|
||||||
)
|
)
|
||||||
mode.finish()
|
mode.finish()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_delete -> {
|
||||||
|
val ids = selectionDecoration?.checkedItemsIds
|
||||||
|
val manga = viewModel.manga.value
|
||||||
|
when {
|
||||||
|
ids.isNullOrEmpty() || manga == null -> Unit
|
||||||
|
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||||
|
else -> {
|
||||||
|
LocalChaptersRemoveService.start(requireContext(), manga, ids)
|
||||||
|
Snackbar.make(
|
||||||
|
binding.recyclerViewChapters,
|
||||||
|
R.string.chapters_will_removed_background,
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.action_select_all -> {
|
R.id.action_select_all -> {
|
||||||
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
|
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
|
||||||
selectionDecoration?.checkAll(ids)
|
selectionDecoration?.checkAll(ids)
|
||||||
@@ -188,6 +207,9 @@ class ChaptersFragment :
|
|||||||
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
||||||
x.chapter.source == MangaSource.LOCAL
|
x.chapter.source == MangaSource.LOCAL
|
||||||
}
|
}
|
||||||
|
menu.findItem(R.id.action_delete).isVisible = items.all { x ->
|
||||||
|
x.chapter.source == MangaSource.LOCAL
|
||||||
|
}
|
||||||
mode.title = items.size.toString()
|
mode.title = items.size.toString()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,16 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
|
|||||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
||||||
import org.koitharu.kotatsu.utils.ShareHelper
|
import org.koitharu.kotatsu.utils.ShareHelper
|
||||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
|
|
||||||
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy,
|
class DetailsActivity :
|
||||||
|
BaseActivity<ActivityDetailsBinding>(),
|
||||||
|
TabLayoutMediator.TabConfigurationStrategy,
|
||||||
AdapterView.OnItemSelectedListener {
|
AdapterView.OnItemSelectedListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<DetailsViewModel> {
|
private val viewModel by viewModel<DetailsViewModel> {
|
||||||
@@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_delete -> {
|
R.id.action_delete -> {
|
||||||
viewModel.manga.value?.let { m ->
|
val title = viewModel.manga.value?.title.orEmpty()
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(R.string.delete_manga)
|
.setTitle(R.string.delete_manga)
|
||||||
.setMessage(getString(R.string.text_delete_local_manga, m.title))
|
.setMessage(getString(R.string.text_delete_local_manga, title))
|
||||||
.setPositiveButton(R.string.delete) { _, _ ->
|
.setPositiveButton(R.string.delete) { _, _ ->
|
||||||
viewModel.deleteLocal(m)
|
viewModel.deleteLocal()
|
||||||
}
|
}
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_save -> {
|
R.id.action_save -> {
|
||||||
viewModel.manga.value?.let {
|
viewModel.manga.value?.let {
|
||||||
val chaptersCount = it.chapters?.size ?: 0
|
val chaptersCount = it.chapters?.size ?: 0
|
||||||
if (chaptersCount > 5) {
|
val branches = viewModel.branches.value.orEmpty()
|
||||||
MaterialAlertDialogBuilder(this)
|
if (chaptersCount > 5 || branches.size > 1) {
|
||||||
.setTitle(R.string.save_manga)
|
showSaveConfirmation(it, chaptersCount, branches)
|
||||||
.setMessage(
|
|
||||||
getString(
|
|
||||||
R.string.large_manga_save_confirm,
|
|
||||||
resources.getQuantityString(
|
|
||||||
R.plurals.chapters,
|
|
||||||
chaptersCount,
|
|
||||||
chaptersCount
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.setPositiveButton(R.string.save) { _, _ ->
|
|
||||||
DownloadService.start(this, it)
|
|
||||||
}.show()
|
|
||||||
} else {
|
} else {
|
||||||
DownloadService.start(this, it)
|
DownloadService.start(this, it)
|
||||||
}
|
}
|
||||||
@@ -262,7 +250,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
fun showChapterMissingDialog(chapterId: Long) {
|
fun showChapterMissingDialog(chapterId: Long) {
|
||||||
val remoteManga = viewModel.getRemoteManga()
|
val remoteManga = viewModel.getRemoteManga()
|
||||||
if (remoteManga == null) {
|
if (remoteManga == null) {
|
||||||
binding.snackbar.show(getString( R.string.chapter_is_missing))
|
binding.snackbar.show(getString(R.string.chapter_is_missing))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
MaterialAlertDialogBuilder(this).apply {
|
MaterialAlertDialogBuilder(this).apply {
|
||||||
@@ -328,6 +316,36 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) {
|
||||||
|
val dialogBuilder = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.save_manga)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
if (branches.size > 1) {
|
||||||
|
val items = Array(branches.size) { i -> branches[i].orEmpty() }
|
||||||
|
val currentBranch = viewModel.selectedBranchIndex.value ?: -1
|
||||||
|
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
|
||||||
|
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
|
||||||
|
checkedIndices[i] = checked
|
||||||
|
}.setPositiveButton(R.string.save) { _, _ ->
|
||||||
|
val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] }
|
||||||
|
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
|
||||||
|
if (c.branch in selectedBranches) c.id else null
|
||||||
|
}
|
||||||
|
DownloadService.start(this, manga, chaptersIds)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialogBuilder.setMessage(
|
||||||
|
getString(
|
||||||
|
R.string.large_manga_save_confirm,
|
||||||
|
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||||
|
)
|
||||||
|
).setPositiveButton(R.string.save) { _, _ ->
|
||||||
|
DownloadService.start(this, manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialogBuilder.show()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga): Intent {
|
fun newIntent(context: Context, manga: Manga): Intent {
|
||||||
@@ -340,4 +358,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
|
|||||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
import org.koitharu.kotatsu.utils.ext.iterator
|
import org.koitharu.kotatsu.utils.ext.iterator
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
@@ -88,18 +89,18 @@ class DetailsViewModel(
|
|||||||
|
|
||||||
val branches = mangaData.map {
|
val branches = mangaData.map {
|
||||||
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
|
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
val selectedBranchIndex = combine(
|
val selectedBranchIndex = combine(
|
||||||
branches.asFlow(),
|
branches.asFlow(),
|
||||||
selectedBranch
|
selectedBranch
|
||||||
) { branches, selected ->
|
) { branches, selected ->
|
||||||
branches.indexOf(selected)
|
branches.indexOf(selected)
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
val hasChapters = mangaData.map {
|
val isChaptersEmpty = mangaData.mapNotNull { m ->
|
||||||
!(it?.chapters.isNullOrEmpty())
|
m?.run { chapters.isNullOrEmpty() }
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
|
||||||
|
|
||||||
val chapters = combine(
|
val chapters = combine(
|
||||||
combine(
|
combine(
|
||||||
@@ -134,8 +135,11 @@ class DetailsViewModel(
|
|||||||
loadingJob = doLoad()
|
loadingJob = doLoad()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteLocal(manga: Manga) {
|
fun deleteLocal() {
|
||||||
|
val m = mangaData.value ?: return
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
|
||||||
|
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
|
||||||
val original = localMangaRepository.getRemoteManga(manga)
|
val original = localMangaRepository.getRemoteManga(manga)
|
||||||
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -252,10 +256,10 @@ class DetailsViewModel(
|
|||||||
val dateFormat = settings.getDateFormat()
|
val dateFormat = settings.getDateFormat()
|
||||||
for (i in sourceChapters.indices) {
|
for (i in sourceChapters.indices) {
|
||||||
val chapter = sourceChapters[i]
|
val chapter = sourceChapters[i]
|
||||||
|
val localChapter = chaptersMap.remove(chapter.id)
|
||||||
if (chapter.branch != branch) {
|
if (chapter.branch != branch) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val localChapter = chaptersMap.remove(chapter.id)
|
|
||||||
result += localChapter?.toListItem(
|
result += localChapter?.toListItem(
|
||||||
isCurrent = i == currentIndex,
|
isCurrent = i == currentIndex,
|
||||||
isUnread = i > currentIndex,
|
isUnread = i > currentIndex,
|
||||||
@@ -274,15 +278,19 @@ class DetailsViewModel(
|
|||||||
}
|
}
|
||||||
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
||||||
result.ensureCapacity(result.size + chaptersMap.size)
|
result.ensureCapacity(result.size + chaptersMap.size)
|
||||||
chaptersMap.values.mapTo(result) {
|
chaptersMap.values.mapNotNullTo(result) {
|
||||||
it.toListItem(
|
if (it.branch == branch) {
|
||||||
isCurrent = false,
|
it.toListItem(
|
||||||
isUnread = true,
|
isCurrent = false,
|
||||||
isNew = false,
|
isUnread = true,
|
||||||
isMissing = false,
|
isNew = false,
|
||||||
isDownloaded = false,
|
isMissing = false,
|
||||||
dateFormat = dateFormat,
|
isDownloaded = false,
|
||||||
)
|
dateFormat = dateFormat,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result.sortBy { it.chapter.number }
|
result.sortBy { it.chapter.number }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,10 @@ class ChapterListItem(
|
|||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
var result = chapter.hashCode()
|
var result = chapter.hashCode()
|
||||||
result = 31 * result + flags
|
result = 31 * result + flags
|
||||||
result = 31 * result + uploadDate.hashCode()
|
result = 31 * result + (uploadDate?.hashCode() ?: 0)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val FLAG_UNREAD = 2
|
const val FLAG_UNREAD = 2
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.download.domain
|
package org.koitharu.kotatsu.download.domain
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Scale
|
import coil.size.Scale
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
@@ -18,8 +16,9 @@ import org.koitharu.kotatsu.BuildConfig
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.local.data.MangaZip
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
|
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
@@ -28,11 +27,11 @@ import org.koitharu.kotatsu.utils.ext.deleteAwait
|
|||||||
import org.koitharu.kotatsu.utils.ext.referer
|
import org.koitharu.kotatsu.utils.ext.referer
|
||||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||||
private const val MAX_PARALLEL_DOWNLOADS = 2
|
|
||||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||||
private const val TEMP_PAGE_FILE = "page.tmp"
|
private const val SLOWDOWN_DELAY = 200L
|
||||||
|
|
||||||
class DownloadManager(
|
class DownloadManager(
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
@@ -41,9 +40,10 @@ class DownloadManager(
|
|||||||
private val okHttp: OkHttpClient,
|
private val okHttp: OkHttpClient,
|
||||||
private val cache: PagesCache,
|
private val cache: PagesCache,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val connectivityManager = context.applicationContext.getSystemService(
|
private val connectivityManager = context.getSystemService(
|
||||||
Context.CONNECTIVITY_SERVICE
|
Context.CONNECTIVITY_SERVICE
|
||||||
) as ConnectivityManager
|
) as ConnectivityManager
|
||||||
private val coverWidth = context.resources.getDimensionPixelSize(
|
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||||
@@ -52,7 +52,7 @@ class DownloadManager(
|
|||||||
private val coverHeight = context.resources.getDimensionPixelSize(
|
private val coverHeight = context.resources.getDimensionPixelSize(
|
||||||
androidx.core.R.dimen.compat_notification_large_icon_max_height
|
androidx.core.R.dimen.compat_notification_large_icon_max_height
|
||||||
)
|
)
|
||||||
private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
|
private val semaphore = Semaphore(settings.downloadsParallelism)
|
||||||
|
|
||||||
fun downloadManga(
|
fun downloadManga(
|
||||||
manga: Manga,
|
manga: Manga,
|
||||||
@@ -74,34 +74,26 @@ class DownloadManager(
|
|||||||
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
||||||
@Suppress("NAME_SHADOWING") var manga = manga
|
@Suppress("NAME_SHADOWING") var manga = manga
|
||||||
val chaptersIdsSet = chaptersIds?.toMutableSet()
|
val chaptersIdsSet = chaptersIds?.toMutableSet()
|
||||||
|
val cover = loadCover(manga)
|
||||||
|
outState.value = DownloadState.Queued(startId, manga, cover)
|
||||||
|
localMangaRepository.lockManga(manga.id)
|
||||||
semaphore.acquire()
|
semaphore.acquire()
|
||||||
coroutineContext[WakeLockNode]?.acquire()
|
coroutineContext[WakeLockNode]?.acquire()
|
||||||
outState.value = DownloadState.Preparing(startId, manga, null)
|
outState.value = DownloadState.Preparing(startId, manga, null)
|
||||||
var cover: Drawable? = null
|
|
||||||
val destination = localMangaRepository.getOutputDir()
|
val destination = localMangaRepository.getOutputDir()
|
||||||
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
|
||||||
var output: MangaZip? = null
|
val tempFileName = "${manga.id}_$startId.tmp"
|
||||||
|
var output: CbzMangaOutput? = null
|
||||||
try {
|
try {
|
||||||
if (manga.source == MangaSource.LOCAL) {
|
if (manga.source == MangaSource.LOCAL) {
|
||||||
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
|
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
|
||||||
}
|
}
|
||||||
val repo = MangaRepository(manga.source)
|
val repo = MangaRepository(manga.source)
|
||||||
cover = runCatching {
|
|
||||||
imageLoader.execute(
|
|
||||||
ImageRequest.Builder(context)
|
|
||||||
.data(manga.coverUrl)
|
|
||||||
.referer(manga.publicUrl)
|
|
||||||
.size(coverWidth, coverHeight)
|
|
||||||
.scale(Scale.FILL)
|
|
||||||
.build()
|
|
||||||
).drawable
|
|
||||||
}.getOrNull()
|
|
||||||
outState.value = DownloadState.Preparing(startId, manga, cover)
|
outState.value = DownloadState.Preparing(startId, manga, cover)
|
||||||
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
|
||||||
output = MangaZip.findInDir(destination, data)
|
output = CbzMangaOutput.get(destination, data)
|
||||||
output.prepare(data)
|
|
||||||
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
val coverUrl = data.largeCoverUrl ?: data.coverUrl
|
||||||
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
|
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
|
||||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||||
}
|
}
|
||||||
val chapters = checkNotNull(
|
val chapters = checkNotNull(
|
||||||
@@ -118,22 +110,29 @@ class DownloadManager(
|
|||||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||||
val pages = repo.getPages(chapter)
|
val pages = repo.getPages(chapter)
|
||||||
for ((pageIndex, page) in pages.withIndex()) {
|
for ((pageIndex, page) in pages.withIndex()) {
|
||||||
failsafe@ do {
|
var retryCounter = 0
|
||||||
|
failsafe@ while (true) {
|
||||||
try {
|
try {
|
||||||
val url = repo.getPageUrl(page)
|
val url = repo.getPageUrl(page)
|
||||||
val file = cache[url] ?: downloadFile(url, page.referer, destination)
|
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
|
||||||
output.addPage(
|
output.addPage(
|
||||||
chapter = chapter,
|
chapter = chapter,
|
||||||
file = file,
|
file = file,
|
||||||
pageNumber = pageIndex,
|
pageNumber = pageIndex,
|
||||||
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||||
)
|
)
|
||||||
|
break@failsafe
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
|
||||||
connectivityManager.waitForNetwork()
|
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||||
continue@failsafe
|
delay(DOWNLOAD_ERROR_DELAY)
|
||||||
|
connectivityManager.waitForNetwork()
|
||||||
|
retryCounter++
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} while (false)
|
}
|
||||||
|
|
||||||
outState.value = DownloadState.Progress(
|
outState.value = DownloadState.Progress(
|
||||||
startId, data, cover,
|
startId, data, cover,
|
||||||
@@ -142,12 +141,15 @@ class DownloadManager(
|
|||||||
totalPages = pages.size,
|
totalPages = pages.size,
|
||||||
currentPage = pageIndex,
|
currentPage = pageIndex,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (settings.isDownloadsSlowdownEnabled) {
|
||||||
|
delay(SLOWDOWN_DELAY)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
outState.value = DownloadState.PostProcessing(startId, data, cover)
|
||||||
if (!output.compress()) {
|
output.mergeWithExisting()
|
||||||
throw RuntimeException("Cannot create target file")
|
output.finalize()
|
||||||
}
|
|
||||||
val localManga = localMangaRepository.getFromFile(output.file)
|
val localManga = localMangaRepository.getFromFile(output.file)
|
||||||
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
outState.value = DownloadState.Done(startId, data, cover, localManga)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
@@ -161,14 +163,15 @@ class DownloadManager(
|
|||||||
} finally {
|
} finally {
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
output?.cleanup()
|
output?.cleanup()
|
||||||
File(destination, TEMP_PAGE_FILE).deleteAwait()
|
File(destination, tempFileName).deleteAwait()
|
||||||
}
|
}
|
||||||
coroutineContext[WakeLockNode]?.release()
|
coroutineContext[WakeLockNode]?.release()
|
||||||
semaphore.release()
|
semaphore.release()
|
||||||
|
localMangaRepository.unlockManga(manga.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
|
private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header(CommonHeaders.REFERER, referer)
|
.header(CommonHeaders.REFERER, referer)
|
||||||
@@ -176,26 +179,14 @@ class DownloadManager(
|
|||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
val call = okHttp.newCall(request)
|
val call = okHttp.newCall(request)
|
||||||
var attempts = MAX_DOWNLOAD_ATTEMPTS
|
val file = File(destination, tempFileName)
|
||||||
val file = File(destination, TEMP_PAGE_FILE)
|
val response = call.clone().await()
|
||||||
while (true) {
|
runInterruptible(Dispatchers.IO) {
|
||||||
try {
|
file.outputStream().use { out ->
|
||||||
val response = call.clone().await()
|
checkNotNull(response.body).byteStream().copyTo(out)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return file
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
|
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
|
||||||
@@ -208,4 +199,35 @@ class DownloadManager(
|
|||||||
error = throwable,
|
error = throwable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun loadCover(manga: Manga) = runCatching {
|
||||||
|
imageLoader.execute(
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(manga.coverUrl)
|
||||||
|
.referer(manga.publicUrl)
|
||||||
|
.size(coverWidth, coverHeight)
|
||||||
|
.scale(Scale.FILL)
|
||||||
|
.build()
|
||||||
|
).drawable
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val context: Context,
|
||||||
|
private val imageLoader: ImageLoader,
|
||||||
|
private val okHttp: OkHttpClient,
|
||||||
|
private val cache: PagesCache,
|
||||||
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun create(coroutineScope: CoroutineScope) = DownloadManager(
|
||||||
|
coroutineScope = coroutineScope,
|
||||||
|
context = context,
|
||||||
|
imageLoader = imageLoader,
|
||||||
|
okHttp = okHttp,
|
||||||
|
cache = cache,
|
||||||
|
localMangaRepository = localMangaRepository,
|
||||||
|
settings = settings,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.text.format.DateUtils
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -49,7 +50,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
|||||||
builder.setSilent(true)
|
builder.setSilent(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun create(state: DownloadState): Notification {
|
fun create(state: DownloadState, timeLeft: Long): Notification {
|
||||||
builder.setContentTitle(state.manga.title)
|
builder.setContentTitle(state.manga.title)
|
||||||
builder.setContentText(context.getString(R.string.manga_downloading_))
|
builder.setContentText(context.getString(R.string.manga_downloading_))
|
||||||
builder.setProgress(1, 0, true)
|
builder.setProgress(1, 0, true)
|
||||||
@@ -117,7 +118,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
|||||||
}
|
}
|
||||||
is DownloadState.Progress -> {
|
is DownloadState.Progress -> {
|
||||||
builder.setProgress(state.max, state.progress, false)
|
builder.setProgress(state.max, state.progress, false)
|
||||||
builder.setContentText((state.percent * 100).format() + "%")
|
if (timeLeft > 0L) {
|
||||||
|
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
|
||||||
|
builder.setContentText(eta)
|
||||||
|
} else {
|
||||||
|
val percent = (state.percent * 100).format()
|
||||||
|
builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
|
||||||
|
}
|
||||||
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
builder.setStyle(null)
|
builder.setStyle(null)
|
||||||
builder.setOngoing(true)
|
builder.setOngoing(true)
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ import android.widget.Toast
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
|
||||||
import kotlinx.coroutines.flow.transformWhile
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
@@ -32,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
|
|||||||
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.utils.ext.throttle
|
import org.koitharu.kotatsu.utils.ext.throttle
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||||
|
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class DownloadService : BaseService() {
|
class DownloadService : BaseService() {
|
||||||
@@ -46,16 +44,12 @@ class DownloadService : BaseService() {
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
isRunning = true
|
||||||
notificationSwitcher = ForegroundNotificationSwitcher(this)
|
notificationSwitcher = ForegroundNotificationSwitcher(this)
|
||||||
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||||
downloadManager = DownloadManager(
|
downloadManager = get<DownloadManager.Factory>().create(
|
||||||
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
||||||
context = this,
|
|
||||||
imageLoader = get(),
|
|
||||||
okHttp = get(),
|
|
||||||
cache = get(),
|
|
||||||
localMangaRepository = get(),
|
|
||||||
)
|
)
|
||||||
DownloadNotification.createChannel(this)
|
DownloadNotification.createChannel(this)
|
||||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||||
@@ -88,6 +82,7 @@ class DownloadService : BaseService() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
unregisterReceiver(controlReceiver)
|
unregisterReceiver(controlReceiver)
|
||||||
binder = null
|
binder = null
|
||||||
|
isRunning = false
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,13 +99,22 @@ class DownloadService : BaseService() {
|
|||||||
private fun listenJob(job: ProgressJob<DownloadState>) {
|
private fun listenJob(job: ProgressJob<DownloadState>) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val startId = job.progressValue.startId
|
val startId = job.progressValue.startId
|
||||||
|
val timeLeftEstimator = TimeLeftEstimator()
|
||||||
val notification = DownloadNotification(this@DownloadService, startId)
|
val notification = DownloadNotification(this@DownloadService, startId)
|
||||||
notificationSwitcher.notify(startId, notification.create(job.progressValue))
|
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
|
||||||
job.progressAsFlow()
|
job.progressAsFlow()
|
||||||
|
.onEach { state ->
|
||||||
|
if (state is DownloadState.Progress) {
|
||||||
|
timeLeftEstimator.tick(value = state.progress, total = state.max)
|
||||||
|
} else {
|
||||||
|
timeLeftEstimator.emptyTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
|
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
|
||||||
.whileActive()
|
.whileActive()
|
||||||
.collect { state ->
|
.collect { state ->
|
||||||
notificationSwitcher.notify(startId, notification.create(state))
|
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
|
||||||
|
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
|
||||||
}
|
}
|
||||||
job.join()
|
job.join()
|
||||||
(job.progressValue as? DownloadState.Done)?.let {
|
(job.progressValue as? DownloadState.Done)?.let {
|
||||||
@@ -124,7 +128,7 @@ class DownloadService : BaseService() {
|
|||||||
if (job.isCancelled) {
|
if (job.isCancelled) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
notification.create(job.progressValue)
|
notification.create(job.progressValue, -1L)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
@@ -160,11 +164,12 @@ class DownloadService : BaseService() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val ACTION_DOWNLOAD_COMPLETE =
|
var isRunning: Boolean = false
|
||||||
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
private set
|
||||||
|
|
||||||
private const val ACTION_DOWNLOAD_CANCEL =
|
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||||
"${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_MANGA = "manga"
|
||||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ val favouritesModule
|
|||||||
viewModel { categoryId ->
|
viewModel { categoryId ->
|
||||||
FavouritesListViewModel(categoryId.get(), get(), get(), get())
|
FavouritesListViewModel(categoryId.get(), get(), get(), get())
|
||||||
}
|
}
|
||||||
viewModel { FavouritesCategoriesViewModel(get()) }
|
viewModel { FavouritesCategoriesViewModel(get(), get()) }
|
||||||
viewModel { manga ->
|
viewModel { manga ->
|
||||||
MangaCategoriesViewModel(manga.get(), get())
|
MangaCategoriesViewModel(manga.get(), get())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
|||||||
createdAt = System.currentTimeMillis(),
|
createdAt = System.currentTimeMillis(),
|
||||||
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
|
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
|
||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
order = SortOrder.UPDATED.name,
|
order = SortOrder.NEWEST.name,
|
||||||
)
|
)
|
||||||
val id = db.favouriteCategoriesDao.insert(entity)
|
val id = db.favouriteCategoriesDao.insert(entity)
|
||||||
return entity.toFavouriteCategory(id)
|
return entity.toFavouriteCategory(id)
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import org.koitharu.kotatsu.databinding.FragmentFavouritesBinding
|
|||||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
|
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesActivity
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
|
import org.koitharu.kotatsu.favourites.ui.categories.CategoriesEditDelegate
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
|
import org.koitharu.kotatsu.favourites.ui.categories.FavouritesCategoriesViewModel
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||||
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.AppBarOwner
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||||
import java.util.*
|
import org.koitharu.kotatsu.utils.ext.resolveDp
|
||||||
|
|
||||||
class FavouritesContainerFragment :
|
class FavouritesContainerFragment :
|
||||||
BaseFragment<FragmentFavouritesBinding>(),
|
BaseFragment<FragmentFavouritesBinding>(),
|
||||||
@@ -52,15 +52,15 @@ class FavouritesContainerFragment :
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
val adapter = FavouritesPagerAdapter(this, this)
|
val adapter = FavouritesPagerAdapter(this, this)
|
||||||
viewModel.categories.value?.let {
|
viewModel.visibleCategories.value?.let {
|
||||||
adapter.replaceData(wrapCategories(it))
|
adapter.replaceData(it)
|
||||||
}
|
}
|
||||||
binding.pager.adapter = adapter
|
binding.pager.adapter = adapter
|
||||||
pagerAdapter = adapter
|
pagerAdapter = adapter
|
||||||
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
|
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
|
||||||
actionModeDelegate.addListener(this, viewLifecycleOwner)
|
actionModeDelegate.addListener(this, viewLifecycleOwner)
|
||||||
|
|
||||||
viewModel.categories.observe(viewLifecycleOwner, ::onCategoriesChanged)
|
viewModel.visibleCategories.observe(viewLifecycleOwner, ::onCategoriesChanged)
|
||||||
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
viewModel.onError.observe(viewLifecycleOwner, ::onError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,8 @@ class FavouritesContainerFragment :
|
|||||||
top = headerHeight - insets.top
|
top = headerHeight - insets.top
|
||||||
)
|
)
|
||||||
binding.pager.updatePadding(
|
binding.pager.updatePadding(
|
||||||
top = -headerHeight
|
// 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active)
|
||||||
|
top = -headerHeight + resources.resolveDp(8)
|
||||||
)
|
)
|
||||||
binding.tabs.apply {
|
binding.tabs.apply {
|
||||||
updatePadding(
|
updatePadding(
|
||||||
@@ -98,8 +99,8 @@ class FavouritesContainerFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
|
private fun onCategoriesChanged(categories: List<CategoryListModel>) {
|
||||||
pagerAdapter?.replaceData(wrapCategories(categories))
|
pagerAdapter?.replaceData(categories)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
@@ -121,26 +122,11 @@ class FavouritesContainerFragment :
|
|||||||
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
Snackbar.make(binding.pager, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean {
|
override fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean {
|
||||||
val menuRes = if (category.id == 0L) R.menu.popup_category_empty else R.menu.popup_category
|
when (item) {
|
||||||
val menu = PopupMenu(tabView.context, tabView)
|
is CategoryListModel.All -> showAllCategoriesMenu(tabView)
|
||||||
menu.inflate(menuRes)
|
is CategoryListModel.CategoryItem -> showCategoryMenu(tabView, item.category)
|
||||||
createOrderSubmenu(menu.menu, category)
|
|
||||||
menu.setOnMenuItemClickListener {
|
|
||||||
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@setOnMenuItemClickListener false
|
|
||||||
else -> {
|
|
||||||
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
|
|
||||||
?: return@setOnMenuItemClickListener false
|
|
||||||
viewModel.setCategoryOrder(category.id, order)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
menu.show()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,13 +142,6 @@ class FavouritesContainerFragment :
|
|||||||
viewModel.createCategory(name)
|
viewModel.createCategory(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun wrapCategories(categories: List<FavouriteCategory>): List<FavouriteCategory> {
|
|
||||||
val data = ArrayList<FavouriteCategory>(categories.size + 1)
|
|
||||||
data += FavouriteCategory(0L, getString(R.string.all_favourites), -1, SortOrder.NEWEST, Date())
|
|
||||||
data += categories
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
|
private fun createOrderSubmenu(menu: Menu, category: FavouriteCategory) {
|
||||||
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
val submenu = menu.findItem(R.id.action_order)?.subMenu ?: return
|
||||||
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
|
for ((i, item) in CategoriesActivity.SORT_ORDERS.withIndex()) {
|
||||||
@@ -180,6 +159,40 @@ class FavouritesContainerFragment :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showCategoryMenu(tabView: View, category: FavouriteCategory) {
|
||||||
|
val menu = PopupMenu(tabView.context, tabView)
|
||||||
|
menu.inflate(R.menu.popup_category)
|
||||||
|
createOrderSubmenu(menu.menu, category)
|
||||||
|
menu.setOnMenuItemClickListener {
|
||||||
|
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@setOnMenuItemClickListener false
|
||||||
|
else -> {
|
||||||
|
val order = CategoriesActivity.SORT_ORDERS.getOrNull(it.order)
|
||||||
|
?: return@setOnMenuItemClickListener false
|
||||||
|
viewModel.setCategoryOrder(category.id, order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
menu.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAllCategoriesMenu(tabView: View) {
|
||||||
|
val menu = PopupMenu(tabView.context, tabView)
|
||||||
|
menu.inflate(R.menu.popup_category_all)
|
||||||
|
menu.setOnMenuItemClickListener {
|
||||||
|
when (it.itemId) {
|
||||||
|
R.id.action_create -> editDelegate.createCategory()
|
||||||
|
R.id.action_hide -> viewModel.setAllCategoriesVisible(false)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
menu.show()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newInstance() = FavouritesContainerFragment()
|
fun newInstance() = FavouritesContainerFragment()
|
||||||
|
|||||||
@@ -7,14 +7,16 @@ import androidx.recyclerview.widget.DiffUtil
|
|||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
import com.google.android.material.tabs.TabLayout
|
import com.google.android.material.tabs.TabLayout
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||||
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
|
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
|
||||||
|
|
||||||
class FavouritesPagerAdapter(
|
class FavouritesPagerAdapter(
|
||||||
fragment: Fragment,
|
fragment: Fragment,
|
||||||
private val longClickListener: FavouritesTabLongClickListener
|
private val longClickListener: FavouritesTabLongClickListener
|
||||||
) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
|
) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
|
||||||
TabLayoutMediator.TabConfigurationStrategy, View.OnLongClickListener {
|
TabLayoutMediator.TabConfigurationStrategy,
|
||||||
|
View.OnLongClickListener {
|
||||||
|
|
||||||
private val differ = AsyncListDiffer(this, DiffCallback())
|
private val differ = AsyncListDiffer(this, DiffCallback())
|
||||||
|
|
||||||
@@ -35,12 +37,15 @@ class FavouritesPagerAdapter(
|
|||||||
|
|
||||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||||
val item = differ.currentList[position]
|
val item = differ.currentList[position]
|
||||||
tab.text = item.title
|
tab.text = when (item) {
|
||||||
|
is CategoryListModel.All -> tab.view.context.getString(R.string.all_favourites)
|
||||||
|
is CategoryListModel.CategoryItem -> item.category.title
|
||||||
|
}
|
||||||
tab.view.tag = item.id
|
tab.view.tag = item.id
|
||||||
tab.view.setOnLongClickListener(this)
|
tab.view.setOnLongClickListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun replaceData(data: List<FavouriteCategory>) {
|
fun replaceData(data: List<CategoryListModel>) {
|
||||||
differ.submitList(data)
|
differ.submitList(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,16 +55,22 @@ class FavouritesPagerAdapter(
|
|||||||
return longClickListener.onTabLongClick(v, item)
|
return longClickListener.onTabLongClick(v, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
|
private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
|
||||||
|
|
||||||
override fun areItemsTheSame(
|
override fun areItemsTheSame(
|
||||||
oldItem: FavouriteCategory,
|
oldItem: CategoryListModel,
|
||||||
newItem: FavouriteCategory
|
newItem: CategoryListModel
|
||||||
): Boolean = oldItem.id == newItem.id
|
): Boolean = when {
|
||||||
|
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> true
|
||||||
|
oldItem is CategoryListModel.CategoryItem && newItem is CategoryListModel.CategoryItem -> {
|
||||||
|
oldItem.category.id == newItem.category.id
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
override fun areContentsTheSame(
|
override fun areContentsTheSame(
|
||||||
oldItem: FavouriteCategory,
|
oldItem: CategoryListModel,
|
||||||
newItem: FavouriteCategory
|
newItem: CategoryListModel
|
||||||
): Boolean = oldItem.id == newItem.id && oldItem.title == newItem.title
|
): Boolean = oldItem == newItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.favourites.ui
|
package org.koitharu.kotatsu.favourites.ui
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||||
|
|
||||||
fun interface FavouritesTabLongClickListener {
|
fun interface FavouritesTabLongClickListener {
|
||||||
|
|
||||||
fun onTabLongClick(tabView: View, category: FavouriteCategory): Boolean
|
fun onTabLongClick(tabView: View, item: CategoryListModel): Boolean
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.koitharu.kotatsu.favourites.ui.categories
|
||||||
|
|
||||||
|
interface AllCategoriesToggleListener {
|
||||||
|
|
||||||
|
fun onAllCategoriesToggle(isVisible: Boolean)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
|||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
import org.koitharu.kotatsu.core.ui.titleRes
|
import org.koitharu.kotatsu.core.ui.titleRes
|
||||||
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
|
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.utils.ext.measureHeight
|
import org.koitharu.kotatsu.utils.ext.measureHeight
|
||||||
@@ -29,7 +30,7 @@ class CategoriesActivity :
|
|||||||
BaseActivity<ActivityCategoriesBinding>(),
|
BaseActivity<ActivityCategoriesBinding>(),
|
||||||
OnListItemClickListener<FavouriteCategory>,
|
OnListItemClickListener<FavouriteCategory>,
|
||||||
View.OnClickListener,
|
View.OnClickListener,
|
||||||
CategoriesEditDelegate.CategoriesEditCallback {
|
CategoriesEditDelegate.CategoriesEditCallback, AllCategoriesToggleListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
private val viewModel by viewModel<FavouritesCategoriesViewModel>()
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ class CategoriesActivity :
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
|
setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
adapter = CategoriesAdapter(this)
|
adapter = CategoriesAdapter(this, this)
|
||||||
editDelegate = CategoriesEditDelegate(this, this)
|
editDelegate = CategoriesEditDelegate(this, this)
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
@@ -49,7 +50,7 @@ class CategoriesActivity :
|
|||||||
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
|
reorderHelper = ItemTouchHelper(ReorderHelperCallback())
|
||||||
reorderHelper.attachToRecyclerView(binding.recyclerView)
|
reorderHelper.attachToRecyclerView(binding.recyclerView)
|
||||||
|
|
||||||
viewModel.categories.observe(this, ::onCategoriesChanged)
|
viewModel.allCategories.observe(this, ::onCategoriesChanged)
|
||||||
viewModel.onError.observe(this, ::onError)
|
viewModel.onError.observe(this, ::onError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ class CategoriesActivity :
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onAllCategoriesToggle(isVisible: Boolean) {
|
||||||
|
viewModel.setAllCategoriesVisible(isVisible)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
binding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
binding.fabAdd.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
rightMargin = topMargin + insets.right
|
rightMargin = topMargin + insets.right
|
||||||
@@ -97,7 +102,7 @@ class CategoriesActivity :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCategoriesChanged(categories: List<FavouriteCategory>) {
|
private fun onCategoriesChanged(categories: List<CategoryListModel>) {
|
||||||
adapter.items = categories
|
adapter.items = categories
|
||||||
binding.textViewHolder.isVisible = categories.isEmpty()
|
binding.textViewHolder.isVisible = categories.isEmpty()
|
||||||
}
|
}
|
||||||
@@ -138,13 +143,19 @@ class CategoriesActivity :
|
|||||||
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
|
ItemTouchHelper.DOWN or ItemTouchHelper.UP, 0
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
||||||
|
|
||||||
override fun onMove(
|
override fun onMove(
|
||||||
recyclerView: RecyclerView,
|
recyclerView: RecyclerView,
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
target: RecyclerView.ViewHolder,
|
target: RecyclerView.ViewHolder,
|
||||||
): Boolean = true
|
): Boolean = viewHolder.itemViewType == target.itemViewType
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
|
override fun canDropOver(
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
current: RecyclerView.ViewHolder,
|
||||||
|
target: RecyclerView.ViewHolder,
|
||||||
|
): Boolean = current.itemViewType == target.itemViewType
|
||||||
|
|
||||||
override fun onMoved(
|
override fun onMoved(
|
||||||
recyclerView: RecyclerView,
|
recyclerView: RecyclerView,
|
||||||
@@ -158,6 +169,8 @@ class CategoriesActivity :
|
|||||||
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
|
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
|
||||||
viewModel.reorderCategories(fromPos, toPos)
|
viewModel.reorderCategories(fromPos, toPos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun isLongPressDragEnabled(): Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import androidx.recyclerview.widget.DiffUtil
|
|||||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.allCategoriesAD
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.categoryAD
|
||||||
|
|
||||||
class CategoriesAdapter(
|
class CategoriesAdapter(
|
||||||
onItemClickListener: OnListItemClickListener<FavouriteCategory>,
|
onItemClickListener: OnListItemClickListener<FavouriteCategory>,
|
||||||
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
|
allCategoriesToggleListener: AllCategoriesToggleListener,
|
||||||
|
) : AsyncListDifferDelegationAdapter<CategoryListModel>(DiffCallback()) {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
delegatesManager.addDelegate(categoryAD(onItemClickListener))
|
delegatesManager.addDelegate(categoryAD(onItemClickListener))
|
||||||
|
.addDelegate(allCategoriesAD(allCategoriesToggleListener))
|
||||||
setHasStableIds(true)
|
setHasStableIds(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,28 +23,23 @@ class CategoriesAdapter(
|
|||||||
return items[position].id
|
return items[position].id
|
||||||
}
|
}
|
||||||
|
|
||||||
private class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
|
private class DiffCallback : DiffUtil.ItemCallback<CategoryListModel>() {
|
||||||
|
|
||||||
override fun areItemsTheSame(
|
override fun areItemsTheSame(
|
||||||
oldItem: FavouriteCategory,
|
oldItem: CategoryListModel,
|
||||||
newItem: FavouriteCategory,
|
newItem: CategoryListModel,
|
||||||
): Boolean {
|
): Boolean = oldItem.id == newItem.id
|
||||||
return oldItem.id == newItem.id
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areContentsTheSame(
|
override fun areContentsTheSame(
|
||||||
oldItem: FavouriteCategory,
|
oldItem: CategoryListModel,
|
||||||
newItem: FavouriteCategory,
|
newItem: CategoryListModel,
|
||||||
): Boolean {
|
): Boolean = oldItem == newItem
|
||||||
return oldItem.id == newItem.id && oldItem.title == newItem.title
|
|
||||||
&& oldItem.order == newItem.order
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(
|
override fun getChangePayload(
|
||||||
oldItem: FavouriteCategory,
|
oldItem: CategoryListModel,
|
||||||
newItem: FavouriteCategory,
|
newItem: CategoryListModel,
|
||||||
): Any? = when {
|
): Any? = when {
|
||||||
oldItem.title == newItem.title && oldItem.order != newItem.order -> newItem.order
|
oldItem is CategoryListModel.All && newItem is CategoryListModel.All -> Unit
|
||||||
else -> super.getChangePayload(oldItem, newItem)
|
else -> super.getChangePayload(oldItem, newItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,36 @@ package org.koitharu.kotatsu.favourites.ui.categories
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class FavouritesCategoriesViewModel(
|
class FavouritesCategoriesViewModel(
|
||||||
private val repository: FavouritesRepository
|
private val repository: FavouritesRepository,
|
||||||
|
private val settings: AppSettings,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private var reorderJob: Job? = null
|
private var reorderJob: Job? = null
|
||||||
|
|
||||||
val categories = repository.observeCategories()
|
val allCategories = combine(
|
||||||
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
repository.observeCategories(),
|
||||||
|
observeAllCategoriesVisible(),
|
||||||
|
) { list, showAll ->
|
||||||
|
mapCategories(list, showAll, true)
|
||||||
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
|
val visibleCategories = combine(
|
||||||
|
repository.observeCategories(),
|
||||||
|
observeAllCategoriesVisible(),
|
||||||
|
) { list, showAll ->
|
||||||
|
mapCategories(list, showAll, showAll)
|
||||||
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
fun createCategory(name: String) {
|
fun createCategory(name: String) {
|
||||||
launchJob {
|
launchJob {
|
||||||
@@ -42,14 +58,40 @@ class FavouritesCategoriesViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAllCategoriesVisible(isVisible: Boolean) {
|
||||||
|
settings.isAllFavouritesVisible = isVisible
|
||||||
|
}
|
||||||
|
|
||||||
fun reorderCategories(oldPos: Int, newPos: Int) {
|
fun reorderCategories(oldPos: Int, newPos: Int) {
|
||||||
val prevJob = reorderJob
|
val prevJob = reorderJob
|
||||||
reorderJob = launchJob(Dispatchers.Default) {
|
reorderJob = launchJob(Dispatchers.Default) {
|
||||||
prevJob?.join()
|
prevJob?.join()
|
||||||
val items = categories.value ?: error("This should not happen")
|
val items = allCategories.value ?: error("This should not happen")
|
||||||
val ids = items.mapTo(ArrayList(items.size)) { it.id }
|
val ids = items.mapTo(ArrayList(items.size)) { it.id }
|
||||||
Collections.swap(ids, oldPos, newPos)
|
Collections.swap(ids, oldPos, newPos)
|
||||||
|
ids.remove(0L)
|
||||||
repository.reorderCategories(ids)
|
repository.reorderCategories(ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun mapCategories(
|
||||||
|
categories: List<FavouriteCategory>,
|
||||||
|
isAllCategoriesVisible: Boolean,
|
||||||
|
withAllCategoriesItem: Boolean,
|
||||||
|
): List<CategoryListModel> {
|
||||||
|
val result = ArrayList<CategoryListModel>(categories.size + 1)
|
||||||
|
if (withAllCategoriesItem) {
|
||||||
|
result.add(CategoryListModel.All(isAllCategoriesVisible))
|
||||||
|
}
|
||||||
|
categories.mapTo(result) {
|
||||||
|
CategoryListModel.CategoryItem(it)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeAllCategoriesVisible() = settings.observe()
|
||||||
|
.filter { it == AppSettings.KEY_ALL_FAVOURITES_VISIBLE }
|
||||||
|
.map { settings.isAllFavouritesVisible }
|
||||||
|
.onStart { emit(settings.isAllFavouritesVisible) }
|
||||||
|
.distinctUntilChanged()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
||||||
|
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
|
||||||
|
import org.koitharu.kotatsu.favourites.ui.categories.AllCategoriesToggleListener
|
||||||
|
|
||||||
|
fun allCategoriesAD(
|
||||||
|
allCategoriesToggleListener: AllCategoriesToggleListener,
|
||||||
|
) = adapterDelegateViewBinding<CategoryListModel.All, CategoryListModel, ItemCategoriesAllBinding>(
|
||||||
|
{ inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) }
|
||||||
|
) {
|
||||||
|
|
||||||
|
binding.imageViewToggle.setOnClickListener {
|
||||||
|
allCategoriesToggleListener.onAllCategoriesToggle(!item.isVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind {
|
||||||
|
binding.imageViewToggle.isChecked = item.isVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.favourites.ui.categories
|
package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
||||||
|
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
@@ -8,23 +8,23 @@ import org.koitharu.kotatsu.databinding.ItemCategoryBinding
|
|||||||
|
|
||||||
fun categoryAD(
|
fun categoryAD(
|
||||||
clickListener: OnListItemClickListener<FavouriteCategory>
|
clickListener: OnListItemClickListener<FavouriteCategory>
|
||||||
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryBinding>(
|
) = adapterDelegateViewBinding<CategoryListModel.CategoryItem, CategoryListModel, ItemCategoryBinding>(
|
||||||
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }
|
{ inflater, parent -> ItemCategoryBinding.inflate(inflater, parent, false) }
|
||||||
) {
|
) {
|
||||||
|
|
||||||
binding.imageViewMore.setOnClickListener {
|
binding.imageViewMore.setOnClickListener {
|
||||||
clickListener.onItemClick(item, it)
|
clickListener.onItemClick(item.category, it)
|
||||||
}
|
}
|
||||||
@Suppress("ClickableViewAccessibility")
|
@Suppress("ClickableViewAccessibility")
|
||||||
binding.imageViewHandle.setOnTouchListener { v, event ->
|
binding.imageViewHandle.setOnTouchListener { v, event ->
|
||||||
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||||
clickListener.onItemLongClick(item, itemView)
|
clickListener.onItemLongClick(item.category, itemView)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.textViewTitle.text = item.title
|
binding.textViewTitle.text = item.category.title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = category.id.hashCode()
|
||||||
|
result = 31 * result + category.title.hashCode()
|
||||||
|
result = 31 * result + category.order.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
|
|||||||
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
|
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
|
||||||
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
||||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||||
|
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
@@ -243,8 +244,11 @@ abstract class MangaListFragment :
|
|||||||
ListMode.LIST -> {
|
ListMode.LIST -> {
|
||||||
layoutManager = FitHeightLinearLayoutManager(context)
|
layoutManager = FitHeightLinearLayoutManager(context)
|
||||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
||||||
addItemDecoration(SpacingItemDecoration(spacing))
|
val decoration = TypedSpacingItemDecoration(
|
||||||
updatePadding(left = spacing, right = spacing)
|
MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0,
|
||||||
|
fallbackSpacing = spacing
|
||||||
|
)
|
||||||
|
addItemDecoration(decoration)
|
||||||
}
|
}
|
||||||
ListMode.DETAILED_LIST -> {
|
ListMode.DETAILED_LIST -> {
|
||||||
layoutManager = FitHeightLinearLayoutManager(context)
|
layoutManager = FitHeightLinearLayoutManager(context)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
|
|||||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||||
0x74
|
0x74
|
||||||
)
|
)
|
||||||
|
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
hasBackground = false
|
hasBackground = false
|
||||||
@@ -51,21 +52,24 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
|
|||||||
bounds: RectF,
|
bounds: RectF,
|
||||||
state: RecyclerView.State,
|
state: RecyclerView.State,
|
||||||
) {
|
) {
|
||||||
val radius = (child as? CardView)?.radius ?: 0f
|
val isCard = child is CardView
|
||||||
|
val radius = (child as? CardView)?.radius ?: defaultRadius
|
||||||
paint.color = fillColor
|
paint.color = fillColor
|
||||||
paint.style = Paint.Style.FILL
|
paint.style = Paint.Style.FILL
|
||||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||||
paint.color = strokeColor
|
paint.color = strokeColor
|
||||||
paint.style = Paint.Style.STROKE
|
paint.style = Paint.Style.STROKE
|
||||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||||
checkIcon?.run {
|
if (isCard) {
|
||||||
setBounds(
|
checkIcon?.run {
|
||||||
(bounds.left + iconOffset).toInt(),
|
setBounds(
|
||||||
(bounds.top + iconOffset).toInt(),
|
(bounds.left + iconOffset).toInt(),
|
||||||
(bounds.left + iconOffset + intrinsicWidth).toInt(),
|
(bounds.top + iconOffset).toInt(),
|
||||||
(bounds.top + iconOffset + intrinsicHeight).toInt(),
|
(bounds.left + iconOffset + intrinsicWidth).toInt(),
|
||||||
)
|
(bounds.top + iconOffset + intrinsicHeight).toInt(),
|
||||||
draw(canvas)
|
)
|
||||||
|
draw(canvas)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.Disposable
|
import coil.request.Disposable
|
||||||
|
import coil.size.Scale
|
||||||
import coil.util.CoilUtils
|
import coil.util.CoilUtils
|
||||||
import com.google.android.material.badge.BadgeDrawable
|
import com.google.android.material.badge.BadgeDrawable
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
@@ -43,6 +44,7 @@ fun mangaGridItemAD(
|
|||||||
.fallback(R.drawable.ic_placeholder)
|
.fallback(R.drawable.ic_placeholder)
|
||||||
.error(R.drawable.ic_placeholder)
|
.error(R.drawable.ic_placeholder)
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
|
.scale(Scale.FILL)
|
||||||
.lifecycle(lifecycleOwner)
|
.lifecycle(lifecycleOwner)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
badge = itemView.bindBadge(badge, item.counter)
|
badge = itemView.bindBadge(badge, item.counter)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.Disposable
|
import coil.request.Disposable
|
||||||
|
import coil.size.Scale
|
||||||
import coil.util.CoilUtils
|
import coil.util.CoilUtils
|
||||||
import com.google.android.material.badge.BadgeDrawable
|
import com.google.android.material.badge.BadgeDrawable
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
@@ -44,6 +45,7 @@ fun mangaListDetailedItemAD(
|
|||||||
.placeholder(R.drawable.ic_placeholder)
|
.placeholder(R.drawable.ic_placeholder)
|
||||||
.fallback(R.drawable.ic_placeholder)
|
.fallback(R.drawable.ic_placeholder)
|
||||||
.error(R.drawable.ic_placeholder)
|
.error(R.drawable.ic_placeholder)
|
||||||
|
.scale(Scale.FILL)
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.lifecycle(lifecycleOwner)
|
.lifecycle(lifecycleOwner)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.adapter
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.Disposable
|
import coil.request.Disposable
|
||||||
|
import coil.size.Scale
|
||||||
import coil.util.CoilUtils
|
import coil.util.CoilUtils
|
||||||
import com.google.android.material.badge.BadgeDrawable
|
import com.google.android.material.badge.BadgeDrawable
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
@@ -44,6 +45,7 @@ fun mangaListItemAD(
|
|||||||
.placeholder(R.drawable.ic_placeholder)
|
.placeholder(R.drawable.ic_placeholder)
|
||||||
.fallback(R.drawable.ic_placeholder)
|
.fallback(R.drawable.ic_placeholder)
|
||||||
.error(R.drawable.ic_placeholder)
|
.error(R.drawable.ic_placeholder)
|
||||||
|
.scale(Scale.FILL)
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.lifecycle(lifecycleOwner)
|
.lifecycle(lifecycleOwner)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package org.koitharu.kotatsu.local
|
|||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
import org.koitharu.kotatsu.download.domain.DownloadManager
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
import org.koitharu.kotatsu.local.ui.LocalListViewModel
|
||||||
import org.koitharu.kotatsu.utils.ExternalStorageHelper
|
|
||||||
|
|
||||||
val localModule
|
val localModule
|
||||||
get() = module {
|
get() = module {
|
||||||
@@ -14,7 +14,7 @@ val localModule
|
|||||||
single { LocalStorageManager(androidContext(), get()) }
|
single { LocalStorageManager(androidContext(), get()) }
|
||||||
single { LocalMangaRepository(get()) }
|
single { LocalMangaRepository(get()) }
|
||||||
|
|
||||||
factory { ExternalStorageHelper(androidContext()) }
|
factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
viewModel { LocalListViewModel(get(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,11 @@ import coil.fetch.FetchResult
|
|||||||
import coil.fetch.Fetcher
|
import coil.fetch.Fetcher
|
||||||
import coil.fetch.SourceResult
|
import coil.fetch.SourceResult
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
|
import java.util.zip.ZipFile
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
class CbzFetcher : Fetcher<Uri> {
|
class CbzFetcher : Fetcher<Uri> {
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import java.util.*
|
|||||||
class CbzFilter : FilenameFilter {
|
class CbzFilter : FilenameFilter {
|
||||||
|
|
||||||
override fun accept(dir: File, name: String): Boolean {
|
override fun accept(dir: File, name: String): Boolean {
|
||||||
|
return isFileSupported(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFileSupported(name: String): Boolean {
|
||||||
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
return ext == "cbz" || ext == "zip"
|
return ext == "cbz" || ext == "zip"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.ContentResolver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -11,7 +12,6 @@ import okhttp3.Cache
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.utils.ext.computeSize
|
import org.koitharu.kotatsu.utils.ext.computeSize
|
||||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
private const val DIR_NAME = "manga"
|
private const val DIR_NAME = "manga"
|
||||||
private const val CACHE_DISK_PERCENTAGE = 0.02
|
private const val CACHE_DISK_PERCENTAGE = 0.02
|
||||||
@@ -71,7 +71,7 @@ class LocalStorageManager(
|
|||||||
private fun getAvailableStorageDirs(): MutableSet<File> {
|
private fun getAvailableStorageDirs(): MutableSet<File> {
|
||||||
val result = LinkedHashSet<File>()
|
val result = LinkedHashSet<File>()
|
||||||
result += File(context.filesDir, DIR_NAME)
|
result += File(context.filesDir, DIR_NAME)
|
||||||
result += context.getExternalFilesDirs(DIR_NAME)
|
context.getExternalFilesDirs(DIR_NAME).filterNotNullTo(result)
|
||||||
result.retainAll { it.exists() || it.mkdirs() }
|
result.retainAll { it.exists() || it.mkdirs() }
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -87,8 +87,8 @@ class LocalStorageManager(
|
|||||||
private fun getCacheDirs(subDir: String): MutableSet<File> {
|
private fun getCacheDirs(subDir: String): MutableSet<File> {
|
||||||
val result = LinkedHashSet<File>()
|
val result = LinkedHashSet<File>()
|
||||||
result += File(context.cacheDir, subDir)
|
result += File(context.cacheDir, subDir)
|
||||||
context.externalCacheDirs.mapTo(result) {
|
context.externalCacheDirs.mapNotNullTo(result) {
|
||||||
File(it, subDir)
|
File(it ?: return@mapNotNullTo null, subDir)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -110,4 +110,4 @@ class LocalStorageManager(
|
|||||||
private fun File.isWriteable() = runCatching {
|
private fun File.isWriteable() = runCatching {
|
||||||
canWrite()
|
canWrite()
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
@@ -28,14 +28,17 @@ class MangaIndex(source: String?) {
|
|||||||
json.put("state", manga.state?.name)
|
json.put("state", manga.state?.name)
|
||||||
json.put("source", manga.source.name)
|
json.put("source", manga.source.name)
|
||||||
json.put("cover_large", manga.largeCoverUrl)
|
json.put("cover_large", manga.largeCoverUrl)
|
||||||
json.put("tags", JSONArray().also { a ->
|
json.put(
|
||||||
for (tag in manga.tags) {
|
"tags",
|
||||||
val jo = JSONObject()
|
JSONArray().also { a ->
|
||||||
jo.put("key", tag.key)
|
for (tag in manga.tags) {
|
||||||
jo.put("title", tag.title)
|
val jo = JSONObject()
|
||||||
a.put(jo)
|
jo.put("key", tag.key)
|
||||||
|
jo.put("title", tag.title)
|
||||||
|
a.put(jo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
if (!append || !json.has("chapters")) {
|
if (!append || !json.has("chapters")) {
|
||||||
json.put("chapters", JSONObject())
|
json.put("chapters", JSONObject())
|
||||||
}
|
}
|
||||||
@@ -84,11 +87,15 @@ class MangaIndex(source: String?) {
|
|||||||
jo.put("uploadDate", chapter.uploadDate)
|
jo.put("uploadDate", chapter.uploadDate)
|
||||||
jo.put("scanlator", chapter.scanlator)
|
jo.put("scanlator", chapter.scanlator)
|
||||||
jo.put("branch", chapter.branch)
|
jo.put("branch", chapter.branch)
|
||||||
jo.put("entries", "%03d\\d{3}".format(chapter.number))
|
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number))
|
||||||
chapters.put(chapter.id.toString(), jo)
|
chapters.put(chapter.id.toString(), jo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun removeChapter(id: Long): Boolean {
|
||||||
|
return json.getJSONObject("chapters").remove(id.toString()) != null
|
||||||
|
}
|
||||||
|
|
||||||
fun setCoverEntry(name: String) {
|
fun setCoverEntry(name: String) {
|
||||||
json.put("cover_entry", name)
|
json.put("cover_entry", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.local.data
|
|
||||||
|
|
||||||
import androidx.annotation.CheckResult
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
|
||||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
class MangaZip(val file: File) {
|
|
||||||
|
|
||||||
private val writableCbz = WritableCbzFile(file)
|
|
||||||
|
|
||||||
private var index = MangaIndex(null)
|
|
||||||
|
|
||||||
suspend fun prepare(manga: Manga) {
|
|
||||||
writableCbz.prepare(overwrite = true)
|
|
||||||
index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText())
|
|
||||||
index.setMangaInfo(manga, append = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun cleanup() {
|
|
||||||
writableCbz.cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
@CheckResult
|
|
||||||
suspend fun compress(): Boolean {
|
|
||||||
writableCbz[INDEX_ENTRY].writeText(index.toString())
|
|
||||||
return writableCbz.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addCover(file: File, ext: String) {
|
|
||||||
val name = buildString {
|
|
||||||
append(FILENAME_PATTERN.format(0, 0))
|
|
||||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
|
||||||
append('.')
|
|
||||||
append(ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writableCbz.put(name, file)
|
|
||||||
index.setCoverEntry(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
|
||||||
val name = buildString {
|
|
||||||
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
|
|
||||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
|
||||||
append('.')
|
|
||||||
append(ext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writableCbz.put(name, file)
|
|
||||||
index.addChapter(chapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val FILENAME_PATTERN = "%03d%03d"
|
|
||||||
|
|
||||||
const val INDEX_ENTRY = "index.json"
|
|
||||||
|
|
||||||
fun findInDir(root: File, manga: Manga): MangaZip {
|
|
||||||
val name = manga.title.toFileNameSafe() + ".cbz"
|
|
||||||
val file = File(root, name)
|
|
||||||
return MangaZip(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,13 @@ package org.koitharu.kotatsu.local.data
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.tomclaw.cache.DiskLruCache
|
import com.tomclaw.cache.DiskLruCache
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||||
import org.koitharu.kotatsu.utils.FileSize
|
import org.koitharu.kotatsu.utils.FileSize
|
||||||
import org.koitharu.kotatsu.utils.ext.subdir
|
import org.koitharu.kotatsu.utils.ext.subdir
|
||||||
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||||
import java.io.File
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
class PagesCache(context: Context) {
|
class PagesCache(context: Context) {
|
||||||
|
|
||||||
@@ -60,4 +60,4 @@ class PagesCache(context: Context) {
|
|||||||
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
|
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FilenameFilter
|
||||||
|
|
||||||
|
class TempFileFilter : FilenameFilter {
|
||||||
|
|
||||||
|
override fun accept(dir: File, name: String): Boolean {
|
||||||
|
return name.endsWith(".tmp", ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.local.data
|
|
||||||
|
|
||||||
import androidx.annotation.CheckResult
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.util.zip.ZipEntry
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
import java.util.zip.ZipOutputStream
|
|
||||||
|
|
||||||
class WritableCbzFile(private val file: File) {
|
|
||||||
|
|
||||||
private val dir = File(file.parentFile, file.nameWithoutExtension)
|
|
||||||
|
|
||||||
suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
|
|
||||||
if (!dir.list().isNullOrEmpty()) {
|
|
||||||
if (overwrite) {
|
|
||||||
dir.deleteRecursively()
|
|
||||||
} else {
|
|
||||||
throw IllegalStateException("Dir ${dir.name} is not empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!dir.exists()) {
|
|
||||||
dir.mkdir()
|
|
||||||
}
|
|
||||||
if (!file.exists()) {
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
ZipInputStream(FileInputStream(file)).use { zip ->
|
|
||||||
var entry = zip.nextEntry
|
|
||||||
while (entry != null && currentCoroutineContext().isActive) {
|
|
||||||
val target = File(dir.path + File.separator + entry.name)
|
|
||||||
runInterruptible {
|
|
||||||
target.parentFile?.mkdirs()
|
|
||||||
target.outputStream().use { out ->
|
|
||||||
zip.copyTo(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
zip.closeEntry()
|
|
||||||
entry = zip.nextEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun cleanup() = withContext(Dispatchers.IO) {
|
|
||||||
dir.deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
@CheckResult
|
|
||||||
suspend fun flush() = withContext(Dispatchers.IO) {
|
|
||||||
val tempFile = File(file.path + ".tmp")
|
|
||||||
if (tempFile.exists()) {
|
|
||||||
tempFile.deleteAwait()
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
runInterruptible {
|
|
||||||
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
|
|
||||||
dir.listFiles()?.forEach {
|
|
||||||
zipFile(it, it.name, zip)
|
|
||||||
}
|
|
||||||
zip.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tempFile.renameTo(file)
|
|
||||||
} finally {
|
|
||||||
if (tempFile.exists()) {
|
|
||||||
tempFile.deleteAwait()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun get(name: String) = File(dir, name)
|
|
||||||
|
|
||||||
suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) {
|
|
||||||
file.copyTo(this[name], overwrite = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
|
|
||||||
if (fileToZip.isDirectory) {
|
|
||||||
if (fileName.endsWith("/")) {
|
|
||||||
zipOut.putNextEntry(ZipEntry(fileName))
|
|
||||||
} else {
|
|
||||||
zipOut.putNextEntry(ZipEntry("$fileName/"))
|
|
||||||
}
|
|
||||||
zipOut.closeEntry()
|
|
||||||
fileToZip.listFiles()?.forEach { childFile ->
|
|
||||||
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
FileInputStream(fileToZip).use { fis ->
|
|
||||||
val zipEntry = ZipEntry(fileName)
|
|
||||||
zipOut.putNextEntry(zipEntry)
|
|
||||||
fis.copyTo(zipOut)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<Long>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,22 @@ package org.koitharu.kotatsu.local.domain
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.collection.ArraySet
|
import androidx.collection.ArraySet
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.local.data.CbzFilter
|
import org.koitharu.kotatsu.local.data.CbzFilter
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
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.model.*
|
||||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||||
import org.koitharu.kotatsu.utils.AlphanumComparator
|
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.deleteAwait
|
||||||
import org.koitharu.kotatsu.utils.ext.readText
|
import org.koitharu.kotatsu.utils.ext.readText
|
||||||
import org.koitharu.kotatsu.utils.ext.resolveName
|
import org.koitharu.kotatsu.utils.ext.resolveName
|
||||||
@@ -27,11 +27,15 @@ import java.io.IOException
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
private const val MAX_PARALLELISM = 4
|
||||||
|
|
||||||
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
|
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
|
||||||
|
|
||||||
override val source = MangaSource.LOCAL
|
override val source = MangaSource.LOCAL
|
||||||
private val filenameFilter = CbzFilter()
|
private val filenameFilter = CbzFilter()
|
||||||
|
private val locks = CompositeMutex<Long>()
|
||||||
|
|
||||||
override suspend fun getList(
|
override suspend fun getList(
|
||||||
offset: Int,
|
offset: Int,
|
||||||
@@ -39,27 +43,43 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?
|
||||||
): List<Manga> {
|
): List<Manga> {
|
||||||
require(offset == 0) {
|
if (offset > 0) {
|
||||||
"LocalMangaRepository does not support pagination"
|
return emptyList()
|
||||||
}
|
}
|
||||||
val files = getAllFiles()
|
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 {
|
override suspend fun getDetails(manga: Manga) = when {
|
||||||
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
|
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
|
||||||
"Manga is not local or saved"
|
"Manga is not local or saved"
|
||||||
}
|
}
|
||||||
manga.chapters == null -> getFromFile(Uri.parse(manga.url).toFile())
|
else -> getFromFile(Uri.parse(manga.url).toFile())
|
||||||
else -> manga
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||||
return runInterruptible(Dispatchers.IO){
|
return runInterruptible(Dispatchers.IO) {
|
||||||
val uri = Uri.parse(chapter.url)
|
val uri = Uri.parse(chapter.url)
|
||||||
val file = uri.toFile()
|
val file = uri.toFile()
|
||||||
val zip = ZipFile(file)
|
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()
|
var entries = zip.entries().asSequence()
|
||||||
entries = if (index != null) {
|
entries = if (index != null) {
|
||||||
val pattern = index.getChapterNamesPattern(chapter)
|
val pattern = index.getChapterNamesPattern(chapter)
|
||||||
@@ -94,10 +114,25 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
return file.deleteAwait()
|
return file.deleteAwait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) {
|
||||||
|
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")
|
@SuppressLint("DefaultLocale")
|
||||||
fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
|
fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
|
||||||
val fileUri = file.toUri().toString()
|
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 index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
val info = index?.getMangaInfo()
|
val info = index?.getMangaInfo()
|
||||||
if (index != null && info != null) {
|
if (index != null && info != null) {
|
||||||
@@ -158,7 +193,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
}.getOrNull() ?: return null
|
}.getOrNull() ?: return null
|
||||||
return runInterruptible(Dispatchers.IO) {
|
return runInterruptible(Dispatchers.IO) {
|
||||||
ZipFile(file).use { zip ->
|
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)
|
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
index?.getMangaInfo()
|
index?.getMangaInfo()
|
||||||
}
|
}
|
||||||
@@ -170,7 +205,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
return runInterruptible(Dispatchers.IO) {
|
return runInterruptible(Dispatchers.IO) {
|
||||||
for (file in files) {
|
for (file in files) {
|
||||||
val index = ZipFile(file).use { zip ->
|
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)
|
entry?.let(zip::readText)?.let(::MangaIndex)
|
||||||
} ?: continue
|
} ?: continue
|
||||||
val info = index.getMangaInfo() ?: 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<Manga?> = async(context) {
|
||||||
|
runInterruptible {
|
||||||
|
runCatching { getFromFile(file) }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
|
private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
|
||||||
|
|
||||||
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
|
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
|
||||||
@@ -211,7 +255,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val name = contentResolver.resolveName(uri)
|
val name = contentResolver.resolveName(uri)
|
||||||
?: throw IOException("Cannot fetch name from uri: $uri")
|
?: throw IOException("Cannot fetch name from uri: $uri")
|
||||||
if (!isFileSupported(name)) {
|
if (!filenameFilter.isFileSupported(name)) {
|
||||||
throw UnsupportedFileException("Unsupported file on $uri")
|
throw UnsupportedFileException("Unsupported file on $uri")
|
||||||
}
|
}
|
||||||
val dest = File(
|
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? {
|
suspend fun getOutputDir(): File? {
|
||||||
return storageManager.getDefaultWriteableDir()
|
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 ->
|
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
|
||||||
dir.listFiles(filenameFilter)?.toList().orEmpty()
|
dir.listFiles(filenameFilter)?.toList().orEmpty()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<LocalMangaRepository>()
|
||||||
|
|
||||||
|
override suspend fun processIntent(intent: Intent?) {
|
||||||
|
val manga = intent?.getParcelableExtra<ParcelableManga>(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<Long>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,18 @@ package org.koitharu.kotatsu.local.ui
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import java.io.IOException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
import org.koitharu.kotatsu.core.os.ShortcutsRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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.history.domain.HistoryRepository
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
import org.koitharu.kotatsu.list.ui.MangaListViewModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.*
|
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.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
import org.koitharu.kotatsu.utils.progress.Progress
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
class LocalListViewModel(
|
class LocalListViewModel(
|
||||||
private val repository: LocalMangaRepository,
|
private val repository: LocalMangaRepository,
|
||||||
@@ -64,6 +67,7 @@ class LocalListViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
onRefresh()
|
onRefresh()
|
||||||
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRefresh() {
|
override fun onRefresh() {
|
||||||
@@ -116,4 +120,18 @@ class LocalListViewModel(
|
|||||||
listError.value = e
|
listError.value = e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cleanup() {
|
||||||
|
if (!DownloadService.isRunning) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
repository.cleanup()
|
||||||
|
}.onFailure { error ->
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
error.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|||||||
import com.google.android.material.navigation.NavigationView
|
import com.google.android.material.navigation.NavigationView
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
@@ -49,6 +48,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
|
|||||||
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
||||||
import org.koitharu.kotatsu.settings.AppUpdateChecker
|
import org.koitharu.kotatsu.settings.AppUpdateChecker
|
||||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
|
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||||
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
||||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
|
||||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||||
@@ -384,14 +384,19 @@ class MainActivity :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onFirstStart() {
|
private fun onFirstStart() {
|
||||||
lifecycleScope.launch(Dispatchers.Default) {
|
lifecycleScope.launchWhenResumed {
|
||||||
TrackWorker.setup(applicationContext)
|
val isUpdateSupported = withContext(Dispatchers.Default) {
|
||||||
SuggestionsWorker.setup(applicationContext)
|
TrackWorker.setup(applicationContext)
|
||||||
AppUpdateChecker(this@MainActivity).checkIfNeeded()
|
SuggestionsWorker.setup(applicationContext)
|
||||||
if (!get<AppSettings>().isSourcesSelected) {
|
AppUpdateChecker.isUpdateSupported(this@MainActivity)
|
||||||
withContext(Dispatchers.Main) {
|
}
|
||||||
OnboardDialogFragment.showWelcome(supportFragmentManager)
|
if (isUpdateSupported) {
|
||||||
}
|
AppUpdateChecker(this@MainActivity).checkIfNeeded()
|
||||||
|
}
|
||||||
|
val settings = get<AppSettings>()
|
||||||
|
when {
|
||||||
|
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
|
||||||
|
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class AppProtectHelper(private val settings: AppSettings) : Application.Activity
|
|||||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||||
|
|
||||||
override fun onActivityDestroyed(activity: Activity) {
|
override fun onActivityDestroyed(activity: Activity) {
|
||||||
if (activity !is ProtectActivity && activity.isTaskRoot) {
|
if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) {
|
||||||
restoreLock()
|
restoreLock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package org.koitharu.kotatsu.reader
|
package org.koitharu.kotatsu.reader
|
||||||
|
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
|
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||||
|
|
||||||
val readerModule
|
val readerModule
|
||||||
@@ -12,6 +14,8 @@ val readerModule
|
|||||||
single { MangaDataRepository(get()) }
|
single { MangaDataRepository(get()) }
|
||||||
single { PagesCache(get()) }
|
single { PagesCache(get()) }
|
||||||
|
|
||||||
|
factory { PageSaveHelper(get(), androidContext()) }
|
||||||
|
|
||||||
viewModel { params ->
|
viewModel { params ->
|
||||||
ReaderViewModel(
|
ReaderViewModel(
|
||||||
intent = params[0],
|
intent = params[0],
|
||||||
@@ -21,7 +25,7 @@ val readerModule
|
|||||||
historyRepository = get(),
|
historyRepository = get(),
|
||||||
shortcutsRepository = get(),
|
shortcutsRepository = get(),
|
||||||
settings = get(),
|
settings = get(),
|
||||||
externalStorageHelper = get(),
|
pageSaveHelper = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,6 @@ import android.graphics.BitmapFactory
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.collection.LongSparseArray
|
import androidx.collection.LongSparseArray
|
||||||
import androidx.collection.set
|
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.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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.reader.ui.pager.ReaderPage
|
||||||
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
|
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 PROGRESS_UNDEFINED = -1f
|
||||||
private const val PREFETCH_LIMIT_DEFAULT = 10
|
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() {
|
private fun onIdle() {
|
||||||
synchronized(prefetchQueue) {
|
synchronized(prefetchQueue) {
|
||||||
val page = prefetchQueue.pollFirst() ?: return
|
while (prefetchQueue.isNotEmpty()) {
|
||||||
tasks[page.id] = loadPageAsyncImpl(page)
|
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<Float>): File {
|
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File {
|
||||||
val pageUrl = getRepository(page.source).getPageUrl(page)
|
val pageUrl = getPageUrl(page)
|
||||||
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
|
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
|
||||||
val uri = Uri.parse(pageUrl)
|
val uri = Uri.parse(pageUrl)
|
||||||
return if (uri.scheme == "cbz") {
|
return if (uri.scheme == "cbz") {
|
||||||
|
|||||||
@@ -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<Uri>? = null
|
||||||
|
private val contentResolver = context.contentResolver
|
||||||
|
|
||||||
|
suspend fun savePage(
|
||||||
|
pageLoader: PageLoader,
|
||||||
|
page: MangaPage,
|
||||||
|
saveLauncher: ActivityResultLauncher<String>,
|
||||||
|
): Uri {
|
||||||
|
val pageUrl = pageLoader.getPageUrl(page)
|
||||||
|
val pageFile = pageLoader.loadPage(page, force = false)
|
||||||
|
val proposedName = getProposedFileName(pageUrl, pageFile)
|
||||||
|
val destination = withContext(Dispatchers.Main) {
|
||||||
|
suspendCancellableCoroutine<Uri> { 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import android.view.*
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultCallback
|
import androidx.activity.result.ActivityResultCallback
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.core.view.*
|
import androidx.core.view.*
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -187,10 +186,7 @@ class ReaderActivity :
|
|||||||
R.id.action_save_page -> {
|
R.id.action_save_page -> {
|
||||||
viewModel.getCurrentPage()?.also { page ->
|
viewModel.getCurrentPage()?.also { page ->
|
||||||
viewModel.saveCurrentState(reader?.getCurrentState())
|
viewModel.saveCurrentState(reader?.getCurrentState())
|
||||||
val name = page.url.toUri().run {
|
viewModel.saveCurrentPage(page, savePageRequest)
|
||||||
fragment ?: lastPathSegment ?: ""
|
|
||||||
}
|
|
||||||
savePageRequest.launch(name)
|
|
||||||
} ?: showWaitWhileLoading()
|
} ?: showWaitWhileLoading()
|
||||||
}
|
}
|
||||||
else -> return super.onOptionsItemSelected(item)
|
else -> return super.onOptionsItemSelected(item)
|
||||||
@@ -199,9 +195,7 @@ class ReaderActivity :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onActivityResult(uri: Uri?) {
|
override fun onActivityResult(uri: Uri?) {
|
||||||
if (uri != null) {
|
viewModel.onActivityResult(uri)
|
||||||
viewModel.saveCurrentPage(uri)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui
|
|||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.LongSparseArray
|
import android.util.LongSparseArray
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.*
|
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.domain.PageLoader
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
|
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.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
|
import org.koitharu.kotatsu.utils.ext.IgnoreErrors
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
@@ -40,10 +40,11 @@ class ReaderViewModel(
|
|||||||
private val historyRepository: HistoryRepository,
|
private val historyRepository: HistoryRepository,
|
||||||
private val shortcutsRepository: ShortcutsRepository,
|
private val shortcutsRepository: ShortcutsRepository,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val externalStorageHelper: ExternalStorageHelper,
|
private val pageSaveHelper: PageSaveHelper,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
|
private var pageSaveJob: Job? = null
|
||||||
private val currentState = MutableStateFlow(initialState)
|
private val currentState = MutableStateFlow(initialState)
|
||||||
private val mangaData = MutableStateFlow(intent.manga)
|
private val mangaData = MutableStateFlow(intent.manga)
|
||||||
private val chapters = LongSparseArray<MangaChapter>()
|
private val chapters = LongSparseArray<MangaChapter>()
|
||||||
@@ -54,7 +55,7 @@ class ReaderViewModel(
|
|||||||
val onPageSaved = SingleLiveEvent<Uri?>()
|
val onPageSaved = SingleLiveEvent<Uri?>()
|
||||||
val uiState = combine(
|
val uiState = combine(
|
||||||
mangaData,
|
mangaData,
|
||||||
currentState
|
currentState,
|
||||||
) { manga, state ->
|
) { manga, state ->
|
||||||
val chapter = state?.chapterId?.let(chapters::get)
|
val chapter = state?.chapterId?.let(chapters::get)
|
||||||
ReaderUiState(
|
ReaderUiState(
|
||||||
@@ -137,12 +138,16 @@ class ReaderViewModel(
|
|||||||
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
return pages.filter { it.chapterId == chapterId }.map { it.toMangaPage() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveCurrentPage(destination: Uri) {
|
fun saveCurrentPage(
|
||||||
launchJob(Dispatchers.Default) {
|
page: MangaPage,
|
||||||
|
saveLauncher: ActivityResultLauncher<String>,
|
||||||
|
) {
|
||||||
|
val prevJob = pageSaveJob
|
||||||
|
pageSaveJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
|
prevJob?.cancelAndJoin()
|
||||||
try {
|
try {
|
||||||
val page = getCurrentPage() ?: error("Page not found")
|
val dest = pageSaveHelper.savePage(pageLoader, page, saveLauncher)
|
||||||
externalStorageHelper.savePage(page, destination)
|
onPageSaved.postCall(dest)
|
||||||
onPageSaved.postCall(destination)
|
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -154,6 +159,15 @@ class ReaderViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onActivityResult(uri: Uri?) {
|
||||||
|
if (uri != null) {
|
||||||
|
pageSaveHelper.onActivityResult(uri)
|
||||||
|
} else {
|
||||||
|
pageSaveJob?.cancel()
|
||||||
|
pageSaveJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentPage(): MangaPage? {
|
fun getCurrentPage(): MangaPage? {
|
||||||
val state = currentState.value ?: return null
|
val state = currentState.value ?: return null
|
||||||
return content.value?.pages?.find {
|
return content.value?.pages?.find {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.fragment.app.FragmentManager
|
|||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.getViewModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
|
||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
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.databinding.SheetPagesBinding
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
|
import org.koitharu.kotatsu.reader.ui.ReaderViewModel
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
|
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
|
||||||
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
|
import org.koitharu.kotatsu.utils.BottomSheetToolbarController
|
||||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||||
|
|
||||||
class PagesThumbnailsSheet : BaseBottomSheet<SheetPagesBinding>(),
|
class PagesThumbnailsSheet :
|
||||||
|
BaseBottomSheet<SheetPagesBinding>(),
|
||||||
OnListItemClickListener<MangaPage> {
|
OnListItemClickListener<MangaPage> {
|
||||||
|
|
||||||
private lateinit var thumbnails: List<PageThumbnail>
|
private lateinit var thumbnails: List<PageThumbnail>
|
||||||
private val spanResolver = MangaListSpanResolver()
|
private val spanResolver = MangaListSpanResolver()
|
||||||
private var currentPageIndex = -1
|
private var currentPageIndex = -1
|
||||||
|
private var pageLoader: PageLoader? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -75,11 +81,11 @@ class PagesThumbnailsSheet : BaseBottomSheet<SheetPagesBinding>(),
|
|||||||
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
|
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing))
|
||||||
)
|
)
|
||||||
adapter = PageThumbnailAdapter(
|
adapter = PageThumbnailAdapter(
|
||||||
thumbnails,
|
dataSet = thumbnails,
|
||||||
get(),
|
coil = get(),
|
||||||
viewLifecycleScope,
|
scope = viewLifecycleScope,
|
||||||
get(),
|
loader = getPageLoader(),
|
||||||
this@PagesThumbnailsSheet
|
clickListener = this@PagesThumbnailsSheet
|
||||||
)
|
)
|
||||||
addOnLayoutChangeListener(spanResolver)
|
addOnLayoutChangeListener(spanResolver)
|
||||||
spanResolver.setGridSize(get<AppSettings>().gridSize / 100f, this)
|
spanResolver.setGridSize(get<AppSettings>().gridSize / 100f, this)
|
||||||
@@ -90,14 +96,27 @@ class PagesThumbnailsSheet : BaseBottomSheet<SheetPagesBinding>(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
pageLoader?.close()
|
||||||
|
pageLoader = null
|
||||||
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: MangaPage, view: View) {
|
override fun onItemClick(item: MangaPage, view: View) {
|
||||||
((parentFragment as? OnPageSelectListener)
|
(
|
||||||
?: (activity as? OnPageSelectListener))?.run {
|
(parentFragment as? OnPageSelectListener)
|
||||||
|
?: (activity as? OnPageSelectListener)
|
||||||
|
)?.run {
|
||||||
onPageSelected(item)
|
onPageSelected(item)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getPageLoader(): PageLoader {
|
||||||
|
val viewModel = (activity as? ReaderActivity)?.getViewModel<ReaderViewModel>()
|
||||||
|
return viewModel?.pageLoader ?: PageLoader().also { pageLoader = it }
|
||||||
|
}
|
||||||
|
|
||||||
private inner class ToolbarController(toolbar: Toolbar) : BottomSheetToolbarController(toolbar) {
|
private inner class ToolbarController(toolbar: Toolbar) : BottomSheetToolbarController(toolbar) {
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
super.onStateChanged(bottomSheet, newState)
|
super.onStateChanged(bottomSheet, newState)
|
||||||
@@ -127,6 +146,5 @@ class PagesThumbnailsSheet : BaseBottomSheet<SheetPagesBinding>(),
|
|||||||
putString(ARG_TITLE, title)
|
putString(ARG_TITLE, title)
|
||||||
putInt(ARG_CURRENT, currentPage)
|
putInt(ARG_CURRENT, currentPage)
|
||||||
}.show(fm, TAG)
|
}.show(fm, TAG)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,27 +1,26 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
|
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
|
||||||
|
|
||||||
import androidx.core.net.toUri
|
import android.graphics.drawable.Drawable
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.PixelSize
|
import coil.size.PixelSize
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
|
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
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.referer
|
||||||
import org.koitharu.kotatsu.utils.ext.setTextColorAttr
|
import org.koitharu.kotatsu.utils.ext.setTextColorAttr
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
fun pageThumbnailAD(
|
fun pageThumbnailAD(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
cache: PagesCache,
|
loader: PageLoader,
|
||||||
clickListener: OnListItemClickListener<MangaPage>
|
clickListener: OnListItemClickListener<MangaPage>,
|
||||||
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
|
) = adapterDelegateViewBinding<PageThumbnail, PageThumbnail, ItemPageThumbBinding>(
|
||||||
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }
|
{ inflater, parent -> ItemPageThumbBinding.inflate(inflater, parent, false) }
|
||||||
) {
|
) {
|
||||||
@@ -33,6 +32,29 @@ fun pageThumbnailAD(
|
|||||||
height = (gridWidth * 13f / 18f).toInt()
|
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 {
|
binding.root.setOnClickListener {
|
||||||
clickListener.onItemClick(item.page, itemView)
|
clickListener.onItemClick(item.page, itemView)
|
||||||
}
|
}
|
||||||
@@ -45,22 +67,11 @@ fun pageThumbnailAD(
|
|||||||
setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary)
|
setTextColorAttr(if (item.isCurrent) materialR.attr.colorOnTertiary else android.R.attr.textColorPrimary)
|
||||||
text = (item.number).toString()
|
text = (item.number).toString()
|
||||||
}
|
}
|
||||||
job = scope.launch(Dispatchers.Default + IgnoreErrors) {
|
job = scope.launch {
|
||||||
val url = item.page.preview ?: item.page.url.let {
|
val drawable = runCatching {
|
||||||
val pageUrl = item.repository.getPageUrl(item.page)
|
loadPageThumbnail(item)
|
||||||
cache[pageUrl]?.toUri()?.toString() ?: pageUrl
|
}.getOrNull()
|
||||||
}
|
binding.imageViewThumb.setImageDrawable(drawable)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ import coil.ImageLoader
|
|||||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
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.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||||
|
|
||||||
class PageThumbnailAdapter(
|
class PageThumbnailAdapter(
|
||||||
dataSet: List<PageThumbnail>,
|
dataSet: List<PageThumbnail>,
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
cache: PagesCache,
|
loader: PageLoader,
|
||||||
clickListener: OnListItemClickListener<MangaPage>
|
clickListener: OnListItemClickListener<MangaPage>
|
||||||
) : ListDelegationAdapter<List<PageThumbnail>>() {
|
) : ListDelegationAdapter<List<PageThumbnail>>() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
delegatesManager.addDelegate(pageThumbnailAD(coil, scope, cache, clickListener))
|
delegatesManager.addDelegate(pageThumbnailAD(coil, scope, loader, clickListener))
|
||||||
setItems(dataSet)
|
setItems(dataSet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +137,9 @@ class RemoteListViewModel(
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
listError.value = e
|
listError.value = e
|
||||||
|
if (!mangaList.value.isNullOrEmpty()) {
|
||||||
|
onError.postCall(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ import android.net.Uri
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.annotation.MainThread
|
import androidx.annotation.MainThread
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.android.ext.android.get
|
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.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
|
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
|
||||||
import org.koitharu.kotatsu.utils.FileSize
|
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) {
|
class AppUpdateChecker(private val activity: ComponentActivity) {
|
||||||
|
|
||||||
@@ -61,25 +61,22 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
|
|||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
private fun showUpdateDialog(version: AppVersion) {
|
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)
|
MaterialAlertDialogBuilder(activity)
|
||||||
.setTitle(R.string.app_update_available)
|
.setTitle(R.string.app_update_available)
|
||||||
.setMessage(buildString {
|
.setMessage(message)
|
||||||
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)
|
|
||||||
})
|
|
||||||
.setPositiveButton(R.string.download) { _, _ ->
|
.setPositiveButton(R.string.download) { _, _ ->
|
||||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(version.apkUrl)))
|
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(version.apkUrl)))
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.close, null)
|
.setNegativeButton(R.string.close, null)
|
||||||
|
.setCancelable(false)
|
||||||
.create()
|
.create()
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
@@ -128,4 +125,4 @@ class AppUpdateChecker(private val activity: ComponentActivity) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
|||||||
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
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.utils.ext.getStorageName
|
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||||
|
|
||||||
@@ -29,6 +29,13 @@ class ContentSettingsFragment :
|
|||||||
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
|
||||||
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
|
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
|
||||||
)
|
)
|
||||||
|
findPreference<SliderPreference>(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run {
|
||||||
|
summary = value.toString()
|
||||||
|
setOnPreferenceChangeListener { preference, newValue ->
|
||||||
|
preference.summary = newValue.toString()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
bindRemoteSourcesSummary()
|
bindRemoteSourcesSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +94,7 @@ class ContentSettingsFragment :
|
|||||||
|
|
||||||
private fun bindRemoteSourcesSummary() {
|
private fun bindRemoteSourcesSummary() {
|
||||||
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run {
|
findPreference<Preference>(AppSettings.KEY_REMOTE_SOURCES)?.run {
|
||||||
val total = MangaSource.values().size - 1
|
val total = settings.remoteMangaSources.size
|
||||||
summary = getString(
|
summary = getString(
|
||||||
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
|
R.string.enabled_d_of_d, total - settings.hiddenSources.size, total
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.backup.RestoreRepository
|
|||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.settings.backup.BackupViewModel
|
import org.koitharu.kotatsu.settings.backup.BackupViewModel
|
||||||
import org.koitharu.kotatsu.settings.backup.RestoreViewModel
|
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.onboard.OnboardViewModel
|
||||||
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
|
import org.koitharu.kotatsu.settings.protect.ProtectSetupViewModel
|
||||||
import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
|
import org.koitharu.kotatsu.settings.sources.SourcesSettingsViewModel
|
||||||
@@ -27,4 +28,5 @@ val settingsModule
|
|||||||
viewModel { ProtectSetupViewModel(get()) }
|
viewModel { ProtectSetupViewModel(get()) }
|
||||||
viewModel { OnboardViewModel(get()) }
|
viewModel { OnboardViewModel(get()) }
|
||||||
viewModel { SourcesSettingsViewModel(get()) }
|
viewModel { SourcesSettingsViewModel(get()) }
|
||||||
|
viewModel { NewSourcesViewModel(get()) }
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.settings
|
package org.koitharu.kotatsu.settings
|
||||||
|
|
||||||
import android.view.inputmethod.EditorInfo
|
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.R
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
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.EditTextBindListener
|
||||||
import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider
|
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) {
|
fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMangaRepository) {
|
||||||
val configKeys = repository.getConfigKeys()
|
val configKeys = repository.getConfigKeys()
|
||||||
@@ -19,23 +19,17 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
|
|||||||
is ConfigKey.Domain -> {
|
is ConfigKey.Domain -> {
|
||||||
val presetValues = key.presetValues
|
val presetValues = key.presetValues
|
||||||
if (presetValues.isNullOrEmpty()) {
|
if (presetValues.isNullOrEmpty()) {
|
||||||
EditTextPreference(requireContext()).apply {
|
EditTextPreference(requireContext())
|
||||||
summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
|
|
||||||
setOnBindEditTextListener(
|
|
||||||
EditTextBindListener(
|
|
||||||
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI,
|
|
||||||
hint = key.defaultValue,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
DropDownPreference(requireContext()).apply {
|
AutoCompleteTextViewPreference(requireContext()).apply { entries = presetValues }
|
||||||
entries = presetValues
|
|
||||||
entryValues = entries
|
|
||||||
summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
|
|
||||||
setDefaultValueCompat(key.defaultValue)
|
|
||||||
}
|
|
||||||
}.apply {
|
}.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)
|
setTitle(R.string.domain)
|
||||||
setDialogTitle(R.string.domain)
|
setDialogTitle(R.string.domain)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ import android.app.backup.BackupDataInput
|
|||||||
import android.app.backup.BackupDataOutput
|
import android.app.backup.BackupDataOutput
|
||||||
import android.app.backup.FullBackupDataOutput
|
import android.app.backup.FullBackupDataOutput
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koitharu.kotatsu.core.backup.BackupArchive
|
import org.koitharu.kotatsu.core.backup.*
|
||||||
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.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import java.io.*
|
import java.io.*
|
||||||
|
|
||||||
@@ -46,7 +42,7 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
mode: Long,
|
mode: Long,
|
||||||
mtime: Long
|
mtime: Long
|
||||||
) {
|
) {
|
||||||
if (destination?.name?.endsWith(".bak") == true) {
|
if (destination?.name?.endsWith(".bk.zip") == true) {
|
||||||
restoreBackupFile(data.fileDescriptor, size)
|
restoreBackupFile(data.fileDescriptor, size)
|
||||||
destination.delete()
|
destination.delete()
|
||||||
} else {
|
} else {
|
||||||
@@ -56,14 +52,14 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
|
|
||||||
private fun createBackupFile() = runBlocking {
|
private fun createBackupFile() = runBlocking {
|
||||||
val repository = BackupRepository(MangaDatabase.create(applicationContext))
|
val repository = BackupRepository(MangaDatabase.create(applicationContext))
|
||||||
val backup = BackupArchive.createNew(this@AppBackupAgent)
|
BackupZipOutput(this@AppBackupAgent).use { backup ->
|
||||||
backup.put(repository.createIndex())
|
backup.put(repository.createIndex())
|
||||||
backup.put(repository.dumpHistory())
|
backup.put(repository.dumpHistory())
|
||||||
backup.put(repository.dumpCategories())
|
backup.put(repository.dumpCategories())
|
||||||
backup.put(repository.dumpFavourites())
|
backup.put(repository.dumpFavourites())
|
||||||
backup.flush()
|
backup.finish()
|
||||||
backup.cleanup()
|
backup.file
|
||||||
backup.file
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
|
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
|
||||||
@@ -74,18 +70,15 @@ class AppBackupAgent : BackupAgent() {
|
|||||||
input.copyLimitedTo(output, size)
|
input.copyLimitedTo(output, size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val backup = BackupArchive(tempFile)
|
val backup = BackupZipInput(tempFile)
|
||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
backup.unpack()
|
|
||||||
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
|
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
|
||||||
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
||||||
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
runBlocking(NonCancellable) {
|
backup.close()
|
||||||
backup.cleanup()
|
|
||||||
}
|
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package org.koitharu.kotatsu.settings.backup
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
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.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
import org.koitharu.kotatsu.utils.progress.Progress
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -19,23 +19,25 @@ class BackupViewModel(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
launchLoadingJob {
|
launchLoadingJob {
|
||||||
val backup = BackupArchive.createNew(context)
|
val file = BackupZipOutput(context).use { backup ->
|
||||||
backup.put(repository.createIndex())
|
backup.put(repository.createIndex())
|
||||||
|
|
||||||
progress.value = Progress(0, 3)
|
progress.value = Progress(0, 3)
|
||||||
backup.put(repository.dumpHistory())
|
backup.put(repository.dumpHistory())
|
||||||
|
|
||||||
progress.value = Progress(1, 3)
|
progress.value = Progress(1, 3)
|
||||||
backup.put(repository.dumpCategories())
|
backup.put(repository.dumpCategories())
|
||||||
|
|
||||||
progress.value = Progress(2, 3)
|
progress.value = Progress(2, 3)
|
||||||
backup.put(repository.dumpFavourites())
|
backup.put(repository.dumpFavourites())
|
||||||
|
|
||||||
progress.value = Progress(3, 3)
|
progress.value = Progress(3, 3)
|
||||||
backup.flush()
|
backup.finish()
|
||||||
progress.value = null
|
progress.value = null
|
||||||
backup.cleanup()
|
backup.close()
|
||||||
onBackupDone.call(backup.file)
|
backup.file
|
||||||
|
}
|
||||||
|
onBackupDone.call(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,19 +3,17 @@ package org.koitharu.kotatsu.settings.backup
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
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.BackupEntry
|
||||||
|
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
||||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||||
import org.koitharu.kotatsu.core.backup.RestoreRepository
|
import org.koitharu.kotatsu.core.backup.RestoreRepository
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.progress.Progress
|
import org.koitharu.kotatsu.utils.progress.Progress
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
class RestoreViewModel(
|
class RestoreViewModel(
|
||||||
uri: Uri?,
|
uri: Uri?,
|
||||||
@@ -40,10 +38,9 @@ class RestoreViewModel(
|
|||||||
input.copyTo(output)
|
input.copyTo(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackupArchive(tempFile)
|
BackupZipInput(tempFile)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
backup.unpack()
|
|
||||||
val result = CompositeResult()
|
val result = CompositeResult()
|
||||||
|
|
||||||
progress.value = Progress(0, 3)
|
progress.value = Progress(0, 3)
|
||||||
@@ -58,10 +55,8 @@ class RestoreViewModel(
|
|||||||
progress.value = Progress(3, 3)
|
progress.value = Progress(3, 3)
|
||||||
onRestoreDone.call(result)
|
onRestoreDone.call(result)
|
||||||
} finally {
|
} finally {
|
||||||
withContext(NonCancellable) {
|
backup.close()
|
||||||
backup.cleanup()
|
backup.file.delete()
|
||||||
backup.file.delete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<DialogOnboardBinding>(),
|
||||||
|
SourceConfigListener,
|
||||||
|
DialogInterface.OnClickListener {
|
||||||
|
|
||||||
|
private val viewModel by viewModel<NewSourcesViewModel>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<SourceConfigItem>>()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,10 @@ import org.koitharu.kotatsu.utils.ext.observeNotNull
|
|||||||
import org.koitharu.kotatsu.utils.ext.showAllowStateLoss
|
import org.koitharu.kotatsu.utils.ext.showAllowStateLoss
|
||||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||||
|
|
||||||
class OnboardDialogFragment : AlertDialogFragment<DialogOnboardBinding>(),
|
class OnboardDialogFragment :
|
||||||
OnListItemClickListener<SourceLocale>, DialogInterface.OnClickListener {
|
AlertDialogFragment<DialogOnboardBinding>(),
|
||||||
|
OnListItemClickListener<SourceLocale>,
|
||||||
|
DialogInterface.OnClickListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<OnboardViewModel>()
|
private val viewModel by viewModel<OnboardViewModel>()
|
||||||
private var isWelcome: Boolean = false
|
private var isWelcome: Boolean = false
|
||||||
@@ -53,6 +55,7 @@ class OnboardDialogFragment : AlertDialogFragment<DialogOnboardBinding>(),
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
val adapter = SourceLocalesAdapter(this)
|
val adapter = SourceLocalesAdapter(this)
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
binding.textViewTitle.setText(R.string.onboard_text)
|
||||||
viewModel.list.observeNotNull(viewLifecycleOwner) {
|
viewModel.list.observeNotNull(viewLifecycleOwner) {
|
||||||
adapter.items = it
|
adapter.items = it
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,9 @@ class OnboardViewModel(
|
|||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private val allSources = MangaSource.values().filterNot { x -> x == MangaSource.LOCAL }
|
private val allSources = settings.remoteMangaSources
|
||||||
|
|
||||||
private val locales = allSources.mapTo(ArraySet()) {
|
private val locales = allSources.mapTo(ArraySet()) { it.locale }
|
||||||
it.locale
|
|
||||||
}
|
|
||||||
|
|
||||||
private val selectedLocales = locales.toMutableSet()
|
private val selectedLocales = locales.toMutableSet()
|
||||||
|
|
||||||
@@ -57,6 +55,7 @@ class OnboardViewModel(
|
|||||||
settings.hiddenSources = allSources.filterNot { x ->
|
settings.hiddenSources = allSources.filterNot { x ->
|
||||||
x.locale in selectedLocales
|
x.locale in selectedLocales
|
||||||
}.mapToSet { x -> x.name }
|
}.mapToSet { x -> x.name }
|
||||||
|
settings.markKnownSources(settings.newSources)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rebuildList() {
|
private fun rebuildList() {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class SourcesSettingsViewModel(
|
|||||||
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isEnabled != true) return false
|
||||||
snapshot.move(oldPos, newPos)
|
snapshot.move(oldPos, newPos)
|
||||||
settings.sourcesOrder = snapshot.mapNotNull {
|
settings.sourcesOrder = snapshot.mapNotNull {
|
||||||
(it as? SourceConfigItem.SourceItem)?.source?.ordinal
|
(it as? SourceConfigItem.SourceItem)?.source?.name
|
||||||
}
|
}
|
||||||
buildList()
|
buildList()
|
||||||
return true
|
return true
|
||||||
@@ -111,9 +111,6 @@ class SourcesSettingsViewModel(
|
|||||||
if (enabledSources?.size != sources.size) {
|
if (enabledSources?.size != sources.size) {
|
||||||
result += SourceConfigItem.Header(R.string.available_sources)
|
result += SourceConfigItem.Header(R.string.available_sources)
|
||||||
for ((key, list) in map) {
|
for ((key, list) in map) {
|
||||||
val locale = if (key != null) {
|
|
||||||
Locale(key)
|
|
||||||
} else null
|
|
||||||
list.sortBy { it.ordinal }
|
list.sortBy { it.ordinal }
|
||||||
val isExpanded = key in expandedGroups
|
val isExpanded = key in expandedGroups
|
||||||
result += SourceConfigItem.LocaleGroup(
|
result += SourceConfigItem.LocaleGroup(
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ fun sourceConfigDraggableItemDelegate(
|
|||||||
on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable }
|
on = { item, _, _ -> item is SourceConfigItem.SourceItem && item.isDraggable }
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val eventListener = object : View.OnClickListener, View.OnTouchListener,
|
val eventListener = object :
|
||||||
|
View.OnClickListener,
|
||||||
|
View.OnTouchListener,
|
||||||
CompoundButton.OnCheckedChangeListener {
|
CompoundButton.OnCheckedChangeListener {
|
||||||
override fun onClick(v: View?) = listener.onItemSettingsClick(item)
|
override fun onClick(v: View?) = listener.onItemSettingsClick(item)
|
||||||
|
|
||||||
|
|||||||
@@ -11,18 +11,19 @@ import androidx.activity.result.contract.ActivityResultContract
|
|||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||||
import org.koitharu.kotatsu.browser.BrowserCallback
|
import org.koitharu.kotatsu.browser.BrowserCallback
|
||||||
import org.koitharu.kotatsu.browser.BrowserClient
|
import org.koitharu.kotatsu.browser.BrowserClient
|
||||||
import org.koitharu.kotatsu.browser.ProgressChromeClient
|
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.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.utils.TaggedActivityResult
|
import org.koitharu.kotatsu.utils.TaggedActivityResult
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
|
|||||||
}
|
}
|
||||||
with(binding.webView.settings) {
|
with(binding.webView.settings) {
|
||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
|
userAgentString = UserAgentInterceptor.userAgentChrome
|
||||||
}
|
}
|
||||||
binding.webView.webViewClient = BrowserClient(this)
|
binding.webView.webViewClient = BrowserClient(this)
|
||||||
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
||||||
|
|||||||
@@ -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<String> = emptyArray()
|
||||||
|
|
||||||
|
init {
|
||||||
|
super.setOnBindEditTextListener(autoCompleteBindListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEntries(@ArrayRes arrayResId: Int) {
|
||||||
|
this.entries = context.resources.getStringArray(arrayResId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEntries(entries: Collection<String>) {
|
||||||
|
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<View>(R.id.dropdown)?.setOnClickListener {
|
||||||
|
editText.showDropDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||||
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
|
||||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
|
||||||
import org.koitharu.kotatsu.databinding.FragmentFeedBinding
|
import org.koitharu.kotatsu.databinding.FragmentFeedBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||||
@@ -57,7 +57,11 @@ class FeedFragment :
|
|||||||
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
|
||||||
paddingHorizontal = spacing
|
paddingHorizontal = spacing
|
||||||
paddingVertical = resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer)
|
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)
|
viewModel.content.observe(viewLifecycleOwner, this::onListChanged)
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import kotlinx.coroutines.flow.filterNotNull
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.core.ui.DateTimeAgo
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
import org.koitharu.kotatsu.list.ui.model.*
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
|
||||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
|
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
|
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class FeedViewModel(
|
class FeedViewModel(
|
||||||
private val repository: TrackingRepository
|
private val repository: TrackingRepository
|
||||||
@@ -34,8 +35,8 @@ class FeedViewModel(
|
|||||||
hasNextPage
|
hasNextPage
|
||||||
) { list, isHasNextPage ->
|
) { list, isHasNextPage ->
|
||||||
buildList(list.size + 2) {
|
buildList(list.size + 2) {
|
||||||
add(header)
|
|
||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
|
add(header)
|
||||||
add(
|
add(
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = R.drawable.ic_feed,
|
icon = R.drawable.ic_feed,
|
||||||
@@ -45,7 +46,7 @@ class FeedViewModel(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
list.mapTo(this) { it.toFeedItem() }
|
list.mapListTo(this)
|
||||||
if (isHasNextPage) {
|
if (isHasNextPage) {
|
||||||
add(LoadingFooter)
|
add(LoadingFooter)
|
||||||
}
|
}
|
||||||
@@ -85,4 +86,29 @@ class FeedViewModel(
|
|||||||
onFeedCleared.postCall(Unit)
|
onFeedCleared.postCall(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun List<TrackingLogItem>.mapListTo(destination: MutableList<ListModel>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,11 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
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.adapter.*
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
|
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
|
||||||
|
import kotlin.jvm.internal.Intrinsics
|
||||||
|
|
||||||
class FeedAdapter(
|
class FeedAdapter(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
@@ -24,6 +25,7 @@ class FeedAdapter(
|
|||||||
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
|
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
|
||||||
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener))
|
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(listener))
|
||||||
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
|
.addDelegate(ITEM_TYPE_HEADER, listHeaderAD())
|
||||||
|
.addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD())
|
||||||
}
|
}
|
||||||
|
|
||||||
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
|
||||||
@@ -32,6 +34,9 @@ class FeedAdapter(
|
|||||||
oldItem is FeedItem && newItem is FeedItem -> {
|
oldItem is FeedItem && newItem is FeedItem -> {
|
||||||
oldItem.id == newItem.id
|
oldItem.id == newItem.id
|
||||||
}
|
}
|
||||||
|
oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
|
||||||
|
oldItem == newItem
|
||||||
|
}
|
||||||
else -> oldItem.javaClass == newItem.javaClass
|
else -> oldItem.javaClass == newItem.javaClass
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,5 +54,6 @@ class FeedAdapter(
|
|||||||
const val ITEM_TYPE_ERROR_FOOTER = 4
|
const val ITEM_TYPE_ERROR_FOOTER = 4
|
||||||
const val ITEM_TYPE_EMPTY = 5
|
const val ITEM_TYPE_EMPTY = 5
|
||||||
const val ITEM_TYPE_HEADER = 6
|
const val ITEM_TYPE_HEADER = 6
|
||||||
|
const val ITEM_TYPE_DATE_HEADER = 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,23 +3,23 @@ package org.koitharu.kotatsu.tracker.ui.adapter
|
|||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.Disposable
|
import coil.request.Disposable
|
||||||
|
import coil.size.Scale
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
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.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
|
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
|
||||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.utils.ext.newImageRequest
|
import org.koitharu.kotatsu.utils.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.utils.ext.textAndVisible
|
|
||||||
|
|
||||||
fun feedItemAD(
|
fun feedItemAD(
|
||||||
coil: ImageLoader,
|
coil: ImageLoader,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
clickListener: OnListItemClickListener<Manga>
|
clickListener: OnListItemClickListener<Manga>
|
||||||
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemTracklogBinding>(
|
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>(
|
||||||
{ inflater, parent -> ItemTracklogBinding.inflate(inflater, parent, false) }
|
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) }
|
||||||
) {
|
) {
|
||||||
|
|
||||||
var imageRequest: Disposable? = null
|
var imageRequest: Disposable? = null
|
||||||
@@ -35,16 +35,15 @@ fun feedItemAD(
|
|||||||
.fallback(R.drawable.ic_placeholder)
|
.fallback(R.drawable.ic_placeholder)
|
||||||
.error(R.drawable.ic_placeholder)
|
.error(R.drawable.ic_placeholder)
|
||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
|
.scale(Scale.FILL)
|
||||||
.lifecycle(lifecycleOwner)
|
.lifecycle(lifecycleOwner)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
binding.textViewTitle.text = item.title
|
binding.textViewTitle.text = item.title
|
||||||
binding.badge.text = item.subtitle
|
binding.textViewSummary.text = context.resources.getQuantityString(
|
||||||
binding.textViewChapters.text = item.chapters
|
R.plurals.new_chapters,
|
||||||
binding.textViewTruncated.textAndVisible = if (item.truncated > 0) {
|
item.count,
|
||||||
getString(R.string._and_x_more, item.truncated)
|
item.count,
|
||||||
} else {
|
)
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onViewRecycled {
|
onViewRecycled {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ data class FeedItem(
|
|||||||
val id: Long,
|
val id: Long,
|
||||||
val imageUrl: String,
|
val imageUrl: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val subtitle: String,
|
|
||||||
val chapters: CharSequence,
|
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
val truncated: Int,
|
val count: Int,
|
||||||
) : ListModel
|
) : ListModel
|
||||||
@@ -2,26 +2,10 @@ package org.koitharu.kotatsu.tracker.ui.model
|
|||||||
|
|
||||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||||
|
|
||||||
fun TrackingLogItem.toFeedItem(): FeedItem {
|
fun TrackingLogItem.toFeedItem() = FeedItem(
|
||||||
val truncate = chapters.size > MAX_CHAPTERS
|
id = id,
|
||||||
val chaptersString = if (truncate) {
|
imageUrl = manga.coverUrl,
|
||||||
chapters.joinToString(
|
title = manga.title,
|
||||||
separator = "\n",
|
count = chapters.size,
|
||||||
limit = MAX_CHAPTERS - 1,
|
manga = manga,
|
||||||
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
|
|
||||||
@@ -2,15 +2,16 @@ package org.koitharu.kotatsu.utils
|
|||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
open class BottomSheetToolbarController(
|
open class BottomSheetToolbarController(
|
||||||
protected val toolbar: Toolbar,
|
protected val toolbar: Toolbar,
|
||||||
) : BottomSheetBehavior.BottomSheetCallback() {
|
) : BottomSheetBehavior.BottomSheetCallback() {
|
||||||
|
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
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)
|
toolbar.setNavigationIcon(materialR.drawable.abc_ic_clear_material)
|
||||||
} else {
|
} else {
|
||||||
toolbar.navigationIcon = null
|
toolbar.navigationIcon = null
|
||||||
|
|||||||
@@ -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<T : Any> : Set<T> {
|
||||||
|
|
||||||
|
private val data = HashMap<T, MutableList<CancellableContinuation<Unit>>>()
|
||||||
|
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<T>): Boolean {
|
||||||
|
return elements.all { x -> data.containsKey(x) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isEmpty(): Boolean {
|
||||||
|
return data.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun iterator(): Iterator<T> {
|
||||||
|
return data.keys.iterator()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun lock(element: T) {
|
||||||
|
waitForRemoval(element)
|
||||||
|
mutex.withLock {
|
||||||
|
val lastValue = data.put(element, LinkedList<CancellableContinuation<Unit>>())
|
||||||
|
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<Unit> { continuation ->
|
||||||
|
list.add(continuation)
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
list.remove(continuation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user