Compare commits

...

60 Commits

Author SHA1 Message Date
Zakhar Timoshenko
fa150e45ff [Issue template] Update version 2022-05-06 20:15:29 +03:00
Koitharu
de9c1017b3 Update parsers 2022-05-06 15:45:20 +03:00
Koitharu
2709d40fc0 Fix BottomSheet edge-to-edge mode 2022-05-06 13:51:55 +03:00
Koitharu
45b42ad5bd Revert "Fix bottom sheet navbar color"
This reverts commit fdd4f5abca.
2022-05-06 12:57:37 +03:00
Koitharu
b759f8d0a0 Fix pages filename #151 2022-05-05 16:46:44 +03:00
Koitharu
23e7aa2aaa Fix images scale type 2022-05-05 15:57:05 +03:00
Koitharu
fdd4f5abca Fix bottom sheet navbar color 2022-05-05 15:51:30 +03:00
Koitharu
c695468aec Fix cold launch 2022-05-05 15:43:07 +03:00
Koitharu
9166716f2a Update version 2022-05-05 15:21:10 +03:00
Zakhar Timoshenko
3407e74e99 Fix FavouriteCategoriesDialog toolbar in album orientation 2022-05-05 15:16:30 +03:00
Koitharu
4c5314fe59 Update parsers 2022-05-02 14:53:31 +03:00
Koitharu
96be49aa83 Update monochrome launcher icon 2022-05-02 09:39:01 +03:00
Koitharu
28b556121b Show new sources on app startup 2022-05-02 09:35:21 +03:00
Koitharu
558c19e526 Update parsers and version 2022-05-01 09:24:45 +03:00
Luiz-bro
59c2d20311 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (280 of 280 strings)

Co-authored-by: Luiz-bro <luiznneto1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2022-05-01 09:18:16 +03:00
Luna Jernberg
fa1f2cbf51 Translated using Weblate (Swedish)
Currently translated at 98.5% (275 of 279 strings)

Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
2022-05-01 09:18:16 +03:00
J. Lavoie
de8739f143 Translated using Weblate (Finnish)
Currently translated at 99.6% (279 of 280 strings)

Translated using Weblate (French)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (German)

Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Finnish)

Currently translated at 99.6% (278 of 279 strings)

Translated using Weblate (French)

Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (Italian)

Currently translated at 100.0% (279 of 279 strings)

Translated using Weblate (German)

Currently translated at 100.0% (279 of 279 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2022-05-01 09:18:16 +03:00
kuragehime
9aa28f6fd2 Translated using Weblate (Japanese)
Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (279 of 279 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-05-01 09:18:16 +03:00
Oğuz Ersen
a2b1699047 Translated using Weblate (Turkish)
Currently translated at 100.0% (280 of 280 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (279 of 279 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-05-01 09:18:16 +03:00
mondstern
2dce65a448 Translated using Weblate (German)
Currently translated at 98.9% (276 of 279 strings)

Co-authored-by: mondstern <mondstern@snopyta.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2022-05-01 09:18:16 +03:00
Koitharu
3d68d7c818 Reuse PageLoader in PagesThumbnailsSheet 2022-04-29 12:45:28 +03:00
Koitharu
4987d43042 Fix pages saving #151 2022-04-29 11:41:14 +03:00
Koitharu
684b494edb Fix concurrent manga downloading #154 2022-04-29 10:07:21 +03:00
Koitharu
714b708fa9 Fix npe on getExternalFilesDirs #158 2022-04-29 09:04:41 +03:00
Koitharu
c462c19a8b Option to hide 'All categories' tab from favourites 2022-04-28 16:46:55 +03:00
Koitharu
e34acf010e Update parsers 2022-04-23 19:31:02 +03:00
Koitharu
0fb29174c5 Fix webview useragent 2022-04-23 19:31:01 +03:00
Xtimms
ca45774cdb Merge remote-tracking branch 'origin/devel' into devel 2022-04-23 19:16:46 +03:00
Xtimms
cccc2c4fe4 Create issue template 2022-04-23 19:16:15 +03:00
Koitharu
c73af2d45f Update version 2022-04-23 15:37:16 +03:00
lowak
acf7102d07 Translated using Weblate (Swedish)
Currently translated at 100.0% (274 of 274 strings)

Co-authored-by: lowak <lowak@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
2022-04-23 15:33:11 +03:00
Koitharu
75fcd31758 Fix locking app on screen rotation 2022-04-23 15:31:52 +03:00
Koitharu
7bffb5f22d Select source domains using AutoCompleteTextView 2022-04-23 15:20:57 +03:00
Koitharu
c220bd5517 Merge branch 'feature/direct-download' into devel 2022-04-23 13:57:24 +03:00
Koitharu
7c827b45d5 Merge branch 'feature/improve-list' into devel 2022-04-23 13:55:03 +03:00
Koitharu
e91d9ee38e Fix list selection corners 2022-04-23 13:54:48 +03:00
Koitharu
b6a86a6538 Cache and reuse RemoteMangaRepository instances 2022-04-23 10:39:33 +03:00
Koitharu
16b6b6c071 Add dummy manga parser for development 2022-04-23 10:31:10 +03:00
Xtimms
695feef4a6 Improve simple manga list 2022-04-21 20:15:59 +03:00
Koitharu
6bf4e0cf89 Use PageLoader for thumbnails 2022-04-20 12:57:07 +03:00
Koitharu
44d8d0f246 Fix search keyboard #150 2022-04-20 12:17:32 +03:00
Koitharu
e617e8d6d3 Add password toggle to ProtectActivity 2022-04-20 12:06:49 +03:00
Koitharu
1f411b7530 Cleanup temporary files 2022-04-20 11:50:55 +03:00
Koitharu
d64bd9d9d3 Estimate remeaning download time 2022-04-20 11:18:33 +03:00
Koitharu
f33dc8f797 Update feed ui 2022-04-20 09:25:46 +03:00
Koitharu
e63ae12c8c Delete local chapters in a service 2022-04-19 16:04:24 +03:00
Koitharu
cbd3d439cd Support multiple branches in saved manga 2022-04-19 14:35:02 +03:00
Koitharu
83eb0d9f23 Fix isLoading live data 2022-04-19 12:53:45 +03:00
Koitharu
3c739eed8e Fix empty chapters label 2022-04-19 12:43:51 +03:00
Koitharu
d77646adf1 Fix duplicate zip entry error 2022-04-19 11:32:42 +03:00
Koitharu
5b5e6cba57 Fix download error retry 2022-04-19 11:08:55 +03:00
Koitharu
8fc9b27840 Option to slowdown downloads and configure parallelism 2022-04-19 10:28:05 +03:00
Koitharu
fa536220eb Search and parallelism in LocalMangaRepository.getList 2022-04-19 09:25:12 +03:00
Koitharu
98f16774c4 Delete whole manga if all chapters are removed 2022-04-19 08:22:54 +03:00
Koitharu
ce8f57c3ca Disable update checking if not supported #147 2022-04-18 20:11:23 +03:00
Koitharu
be66106336 Removing selected chapters from local storage 2022-04-18 20:00:43 +03:00
Koitharu
16c8641a07 Fix concurrent downloading #146 2022-04-18 16:46:35 +03:00
Koitharu
d3e9ce874a Download manga to cbz directly 2022-04-18 16:42:37 +03:00
Koitharu
aaf9c6a0bf Update parsers 2022-04-18 09:26:48 +03:00
Koitharu
c2276eb2cb Fix cover size changes in details 2022-04-17 10:24:49 +03:00
149 changed files with 2623 additions and 1053 deletions

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View 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
View 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

View 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
View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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" />

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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?)
}

View File

@@ -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
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}

View File

@@ -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,
)
} }
} }

View File

@@ -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
} }
} }
} }

View File

@@ -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
} }

View File

@@ -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"

View File

@@ -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)
} }
} }
} }

View 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
}
}

View File

@@ -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
} }

View File

@@ -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)
} }
} }
} }

View File

@@ -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 }
} }

View File

@@ -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

View File

@@ -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,
)
}
} }

View File

@@ -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)

View File

@@ -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"

View File

@@ -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())
} }

View File

@@ -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)

View File

@@ -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()

View File

@@ -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
} }
} }

View File

@@ -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
} }

View File

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

View File

@@ -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 {

View File

@@ -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)
} }
} }

View File

@@ -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()
} }

View File

@@ -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
}
}

View File

@@ -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
} }
} }

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -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)
}
} }
} }
} }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()) }
} }

View File

@@ -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> {

View File

@@ -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"
} }

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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)
}
}
}

View 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()
} }
} }
} }

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View 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()
} }

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}
}
}
} }

View File

@@ -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)
} }
} }
} }

View File

@@ -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()
} }
} }

View File

@@ -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(),
) )
} }
} }

View File

@@ -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") {

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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)
} }
} }

View File

@@ -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)
}
} }
} }

View File

@@ -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)
} }
} }

View File

@@ -137,6 +137,9 @@ class RemoteListViewModel(
e.printStackTrace() e.printStackTrace()
} }
listError.value = e listError.value = e
if (!mangaList.value.isNullOrEmpty()) {
onError.postCall(e)
}
} }
} }
} }

View File

@@ -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) {
} }
} }
} }
} }

View File

@@ -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
) )

View File

@@ -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()) }
} }

View File

@@ -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)
} }

View File

@@ -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()
} }
} }

View File

@@ -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)
} }
} }
} }

View 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()
}
} }
} }
} }

View File

@@ -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)
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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
} }

View File

@@ -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() {

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()
}
}
}
}

View File

@@ -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)

View File

@@ -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)
}
}
} }

View File

@@ -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
} }
} }

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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