Compare commits
156 Commits
v3.0
...
v3.3-beta1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5abf5d3367 | ||
|
|
0dc4e63b7a | ||
|
|
95d7ca5264 | ||
|
|
317252e1dd | ||
|
|
d9044b2d03 | ||
|
|
b6ae4e2b41 | ||
|
|
fce31df121 | ||
|
|
d5c1d86313 | ||
|
|
46df41504c | ||
|
|
48e232e04e | ||
|
|
58ff7c9235 | ||
|
|
730d664b91 | ||
|
|
36634ecca1 | ||
|
|
10c03ff01a | ||
|
|
e85b9db118 | ||
|
|
f6b0a7c780 | ||
|
|
3e785a2555 | ||
|
|
1cbb825892 | ||
|
|
161bc5f69d | ||
|
|
b17237eb6b | ||
|
|
4771882f50 | ||
|
|
345a1379ae | ||
|
|
33ab7f4d95 | ||
|
|
2a97cb34d7 | ||
|
|
03cbd8410f | ||
|
|
3c54bdd003 | ||
|
|
ba0a94e525 | ||
|
|
b439e0c2c2 | ||
|
|
f9281850ad | ||
|
|
4d5d25834e | ||
|
|
9e706ea096 | ||
|
|
46fe2bb8ac | ||
|
|
6405523232 | ||
|
|
930819ffa2 | ||
|
|
fa150e45ff | ||
|
|
400a2b14f7 | ||
|
|
a40322b2e7 | ||
|
|
de9c1017b3 | ||
|
|
2709d40fc0 | ||
|
|
45b42ad5bd | ||
|
|
878df24a64 | ||
|
|
b759f8d0a0 | ||
|
|
23e7aa2aaa | ||
|
|
fdd4f5abca | ||
|
|
c695468aec | ||
|
|
9166716f2a | ||
|
|
3407e74e99 | ||
|
|
6969f40fa0 | ||
|
|
11fc8b6642 | ||
|
|
4e4024c182 | ||
|
|
1d1931f721 | ||
|
|
ffad6a4ae6 | ||
|
|
4c5314fe59 | ||
|
|
96be49aa83 | ||
|
|
28b556121b | ||
|
|
558c19e526 | ||
|
|
59c2d20311 | ||
|
|
fa1f2cbf51 | ||
|
|
de8739f143 | ||
|
|
9aa28f6fd2 | ||
|
|
a2b1699047 | ||
|
|
2dce65a448 | ||
|
|
3d68d7c818 | ||
|
|
4987d43042 | ||
|
|
684b494edb | ||
|
|
714b708fa9 | ||
|
|
c462c19a8b | ||
|
|
e34acf010e | ||
|
|
0fb29174c5 | ||
|
|
ca45774cdb | ||
|
|
cccc2c4fe4 | ||
|
|
c73af2d45f | ||
|
|
acf7102d07 | ||
|
|
75fcd31758 | ||
|
|
7bffb5f22d | ||
|
|
c220bd5517 | ||
|
|
7c827b45d5 | ||
|
|
e91d9ee38e | ||
|
|
b6a86a6538 | ||
|
|
16b6b6c071 | ||
|
|
695feef4a6 | ||
|
|
6bf4e0cf89 | ||
|
|
44d8d0f246 | ||
|
|
e617e8d6d3 | ||
|
|
1f411b7530 | ||
|
|
d64bd9d9d3 | ||
|
|
f33dc8f797 | ||
|
|
e63ae12c8c | ||
|
|
cbd3d439cd | ||
|
|
83eb0d9f23 | ||
|
|
3c739eed8e | ||
|
|
d77646adf1 | ||
|
|
5b5e6cba57 | ||
|
|
8fc9b27840 | ||
|
|
fa536220eb | ||
|
|
98f16774c4 | ||
|
|
ce8f57c3ca | ||
|
|
be66106336 | ||
|
|
16c8641a07 | ||
|
|
d3e9ce874a | ||
|
|
aaf9c6a0bf | ||
|
|
c2276eb2cb | ||
|
|
5fbae1256b | ||
|
|
d61ba80bf6 | ||
|
|
74c9fa9488 | ||
|
|
ce732ccca0 | ||
|
|
6b99e360e0 | ||
|
|
1c73d54a94 | ||
|
|
36e21caf96 | ||
|
|
f7f9c53466 | ||
|
|
3f2ee2a925 | ||
|
|
b1c069f62f | ||
|
|
22d48fce8f | ||
|
|
6b2666c701 | ||
|
|
414f438762 | ||
|
|
54f60040b5 | ||
|
|
b0515033da | ||
|
|
d37eb07301 | ||
|
|
c2fa27712c | ||
|
|
10a2589c10 | ||
|
|
05eb96e7c0 | ||
|
|
15a08ad6ae | ||
|
|
3e437c2ecb | ||
|
|
fea667b87c | ||
|
|
93eaaac084 | ||
|
|
a60df582a2 | ||
|
|
aec1d4e0d6 | ||
|
|
507f2e883c | ||
|
|
5e82c75893 | ||
|
|
9c9a389aa5 | ||
|
|
1b3af70690 | ||
|
|
2e17efe82b | ||
|
|
5bed854b9c | ||
|
|
7262b403f0 | ||
|
|
a6fcbefc7b | ||
|
|
7f9ea0efa0 | ||
|
|
934861322e | ||
|
|
e008fbab9b | ||
|
|
2cd9ea19fd | ||
|
|
699a249620 | ||
|
|
6c87d5b0bc | ||
|
|
c92bdae842 | ||
|
|
6ca9608a80 | ||
|
|
8f9c0cbff1 | ||
|
|
cc6b114e4d | ||
|
|
3d5c2123d4 | ||
|
|
36b4e16b7c | ||
|
|
3ebd074e93 | ||
|
|
e9b2b545a4 | ||
|
|
cca6d5fa04 | ||
|
|
36a7a3ebbc | ||
|
|
48ec9a1ea9 | ||
|
|
76a9a0d1ab | ||
|
|
f2175b40c0 | ||
|
|
85b992ca32 | ||
|
|
41fb351fe0 |
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠️ Source issue
|
||||
url: https://github.com/nv95/kotatsu-parsers/issues/new
|
||||
about: Issues and requests for sources should be opened in the kotatsu-parsers repository instead
|
||||
93
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
Normal file
93
.github/ISSUE_TEMPLATE/report_issue.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: 🐞 Issue report
|
||||
description: Report an issue in Kotatsu
|
||||
labels: [bug]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: reproduce-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide an example of the issue.
|
||||
placeholder: |
|
||||
Example:
|
||||
1. First step
|
||||
2. Second step
|
||||
3. Issue here
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: Explain what you should expect to happen.
|
||||
placeholder: |
|
||||
Example:
|
||||
"This should happen..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: Explain what actually happens.
|
||||
placeholder: |
|
||||
Example:
|
||||
"This happened instead..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: kotatsu-version
|
||||
attributes:
|
||||
label: Kotatsu version
|
||||
description: You can find your Kotatsu version in **Settings → About**.
|
||||
placeholder: |
|
||||
Example: "3.2.3"
|
||||
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.3](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
39
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/request_feature.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: ⭐ Feature request
|
||||
description: Suggest a feature to improve Kotatsu
|
||||
labels: [feature request]
|
||||
body:
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Describe your suggested feature
|
||||
description: How can Kotatsu be improved?
|
||||
placeholder: |
|
||||
Example:
|
||||
"It should work like this..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: other-details
|
||||
attributes:
|
||||
label: Other details
|
||||
placeholder: |
|
||||
Additional details and attachments.
|
||||
|
||||
- type: checkboxes
|
||||
id: acknowledgements
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
description: Read this carefully, we will close and ignore your issue if you skimmed through this.
|
||||
options:
|
||||
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue.
|
||||
required: true
|
||||
- label: I have written a short but informative title.
|
||||
required: true
|
||||
- label: If this is an issue with a source, I should be opening an issue in the [parsers repository](https://github.com/nv95/kotatsu-parsers/issues/new).
|
||||
required: true
|
||||
- label: I have updated the app to version **[3.2.3](https://github.com/nv95/Kotatsu/releases/latest)**.
|
||||
required: true
|
||||
- label: I will fill out all of the requested information in this form.
|
||||
required: true
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,6 +9,8 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/kotlinScripting.xml
|
||||
/.idea/deploymentTargetDropDown.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
17
.idea/deploymentTargetDropDown.xml
generated
17
.idea/deploymentTargetDropDown.xml
generated
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<targetSelectedWithDropDown>
|
||||
<Target>
|
||||
<type value="QUICK_BOOT_TARGET" />
|
||||
<deviceKey>
|
||||
<Key>
|
||||
<type value="VIRTUAL_DEVICE_PATH" />
|
||||
<value value="$USER_HOME$/.android/avd/Pixel_API_S.avd" />
|
||||
</Key>
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</targetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2021-02-19T19:02:37.198775Z" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -7,7 +7,7 @@
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="Embedded JDK" />
|
||||
<option name="gradleJvm" value="Android Studio default JDK" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
||||
7
.idea/inspectionProfiles/Project_Default.xml
generated
7
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,8 +1,13 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
|
||||
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="KotlinFunctionArgumentsHelper" enabled="true" level="INFORMATION" enabled_by_default="true">
|
||||
<option name="withoutDefaultValues" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
|
||||
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
|
||||
11
.travis.yml
11
.travis.yml
@@ -1,11 +0,0 @@
|
||||
language: android
|
||||
dist: trusty
|
||||
android:
|
||||
components:
|
||||
- android-30
|
||||
- build-tools-30.0.3
|
||||
- platform-tools-30.0.5
|
||||
- tools
|
||||
before_install:
|
||||
- yes | sdkmanager "platforms;android-30"
|
||||
script: ./gradlew -Dorg.gradle.jvmargs=-Xmx1536m assembleDebug lintDebug
|
||||
12
README.md
12
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Kotatsu is a free and open source manga reader for Android.
|
||||
|
||||
  [](https://travis-ci.org/nv95/Kotatsu)  [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
|
||||
   [](https://hosted.weblate.org/engage/kotatsu/) [](http://4pda.ru/forum/index.php?showtopic=697669) [](https://discord.gg/NNJ5RgVBC5)
|
||||
|
||||
### Download
|
||||
|
||||
@@ -25,6 +25,8 @@ Download APK from Github Releases:
|
||||
* Tablet-optimized material design UI
|
||||
* Standard and Webtoon-optimized reader
|
||||
* Notifications about new chapters with updates feed
|
||||
* Available in multiple languages
|
||||
* Password protect access to the app
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -35,6 +37,14 @@ Download APK from Github Releases:
|
||||
|  |  |
|
||||
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
|
||||
### Localization
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/kotatsu/">
|
||||
<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages, please head over to the Weblate <a href="https://hosted.weblate.org/engage/kotatsu/">project page</a>
|
||||
|
||||
### License
|
||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 32
|
||||
versionCode 400
|
||||
versionName '3.0'
|
||||
versionCode 408
|
||||
versionName '3.3-beta1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -49,14 +49,15 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
freeCompilerArgs += [
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||
]
|
||||
}
|
||||
lint {
|
||||
abortOnError false
|
||||
disable 'MissingTranslation', 'PrivateResource'
|
||||
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources = true
|
||||
@@ -65,12 +66,12 @@ android {
|
||||
}
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||
implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') {
|
||||
implementation('com.github.nv95:kotatsu-parsers:05a93e2380') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.7.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||
@@ -86,7 +87,7 @@ dependencies {
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||
implementation 'com.google.android.material:material:1.6.0-beta01'
|
||||
implementation 'com.google.android.material:material:1.7.0-alpha01'
|
||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
||||
|
||||
@@ -95,21 +96,22 @@ dependencies {
|
||||
kapt 'androidx.room:room-compiler:2.4.2'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
|
||||
implementation 'com.squareup.okio:okio:3.0.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
|
||||
implementation 'com.squareup.okio:okio:3.1.0'
|
||||
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
|
||||
implementation 'io.insert-koin:koin-android:3.1.5'
|
||||
implementation 'io.coil-kt:coil-base:1.4.0'
|
||||
implementation 'io.insert-koin:koin-android:3.2.0'
|
||||
implementation 'io.coil-kt:coil-base:2.0.0'
|
||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
|
||||
testImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("", null)
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
): List<Manga> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.newParser
|
||||
|
||||
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||
return if (source == MangaSource.DUMMY) {
|
||||
DummyParser(loaderContext)
|
||||
} else {
|
||||
source.newParser(loaderContext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
fun Throwable.printStackTraceDebug() = printStackTrace()
|
||||
@@ -8,23 +8,23 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Kotatsu"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
|
||||
android:exported="true">
|
||||
@@ -53,20 +53,12 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
||||
android:label="@string/search" />
|
||||
<activity android:name="org.koitharu.kotatsu.search.ui.MangaListActivity"
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.search.ui.MangaListActivity"
|
||||
android:label="@string/search_manga" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
|
||||
android:label="@string/settings" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.reader.ui.SimpleSettingsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/settings">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MANAGE_NETWORK_USAGE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||
@@ -87,7 +79,8 @@
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/manga_shelf">
|
||||
android:label="@string/manga_shelf"
|
||||
android:theme="@style/Theme.Kotatsu.DialogWhenLarge">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
@@ -104,12 +97,18 @@
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||
android:label="@string/downloads" />
|
||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
|
||||
android:label="@string/downloads"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
|
||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity"
|
||||
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
|
||||
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
|
||||
<service
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koitharu.kotatsu.bookmarks.bookmarksModule
|
||||
import org.koitharu.kotatsu.core.db.databaseModule
|
||||
import org.koitharu.kotatsu.core.github.githubModule
|
||||
import org.koitharu.kotatsu.core.network.networkModule
|
||||
@@ -67,6 +68,7 @@ class KotatsuApp : Application() {
|
||||
readerModule,
|
||||
appWidgetModule,
|
||||
suggestionsModule,
|
||||
bookmarksModule,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,19 @@ package org.koitharu.kotatsu.base.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.*
|
||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
|
||||
class MangaDataRepository(private val db: MangaDatabase) {
|
||||
|
||||
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
|
||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||
val tags = manga.tags.toEntities()
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
||||
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||
db.preferencesDao.upsert(
|
||||
MangaPrefsEntity(
|
||||
mangaId = manga.id,
|
||||
@@ -37,21 +34,19 @@ class MangaDataRepository(private val db: MangaDatabase) {
|
||||
|
||||
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
||||
intent.manga != null -> intent.manga
|
||||
intent.mangaId != 0L -> db.mangaDao.find(intent.mangaId)?.toManga()
|
||||
intent.mangaId != 0L -> findMangaById(intent.mangaId)
|
||||
else -> null // TODO resolve uri
|
||||
}
|
||||
|
||||
suspend fun storeManga(manga: Manga) {
|
||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
||||
val tags = manga.tags.toEntities()
|
||||
db.withTransaction {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
||||
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findTags(source: MangaSource): Set<MangaTag> {
|
||||
return db.tagsDao.findTags(source.name).mapToSet {
|
||||
it.toMangaTag()
|
||||
}
|
||||
return db.tagsDao.findTags(source.name).toMangaTags()
|
||||
}
|
||||
}
|
||||
@@ -9,54 +9,58 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.utils.ext.medianOrNull
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipFile
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object MangaUtils : KoinComponent {
|
||||
|
||||
private const val MIN_WEBTOON_RATIO = 2
|
||||
|
||||
/**
|
||||
* Automatic determine type of manga by page size
|
||||
* @return ReaderMode.WEBTOON if page is wide
|
||||
*/
|
||||
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? {
|
||||
try {
|
||||
val page = pages.medianOrNull() ?: return null
|
||||
val url = MangaRepository(page.source).getPageUrl(page)
|
||||
val uri = Uri.parse(url)
|
||||
val size = if (uri.scheme == "cbz") {
|
||||
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean {
|
||||
val pageIndex = (pages.size * 0.3).roundToInt()
|
||||
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
|
||||
val url = MangaRepository(page.source).getPageUrl(page)
|
||||
val uri = Uri.parse(url)
|
||||
val size = if (uri.scheme == "cbz") {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
val zip = ZipFile(uri.schemeSpecificPart)
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
zip.getInputStream(entry).use {
|
||||
getBitmapSize(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header(CommonHeaders.REFERER, page.referer)
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
|
||||
.build()
|
||||
get<OkHttpClient>().newCall(request).await().use {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
val zip = ZipFile(uri.schemeSpecificPart)
|
||||
val entry = zip.getEntry(uri.fragment)
|
||||
zip.getInputStream(entry).use {
|
||||
getBitmapSize(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header(CommonHeaders.REFERER, page.referer)
|
||||
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
|
||||
.build()
|
||||
get<OkHttpClient>().newCall(request).await().use {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
getBitmapSize(it.body?.byteStream())
|
||||
}
|
||||
getBitmapSize(it.body?.byteStream())
|
||||
}
|
||||
}
|
||||
return size.width * 2 < size.height
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return null
|
||||
}
|
||||
return size.width * MIN_WEBTOON_RATIO < size.height
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -69,4 +73,4 @@ object MangaUtils : KoinComponent {
|
||||
check(imageHeight > 0 && imageWidth > 0)
|
||||
return Size(imageWidth, imageHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.base.domain
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||
|
||||
fun interface ReversibleHandle {
|
||||
|
||||
suspend fun reverse()
|
||||
}
|
||||
|
||||
fun ReversibleHandle.reverseAsync() = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
reverse()
|
||||
}
|
||||
|
||||
operator fun ReversibleHandle.plus(other: ReversibleHandle) = ReversibleHandle {
|
||||
this.reverse()
|
||||
other.reverse()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.view.KeyEvent
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.ActionBarContextView
|
||||
@@ -20,11 +21,13 @@ import androidx.viewbinding.ViewBinding
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
protected lateinit var binding: B
|
||||
@@ -36,6 +39,8 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
@Suppress("LeakingThis")
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
|
||||
val actionModeDelegate = ActionModeDelegate()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val settings = get<AppSettings>()
|
||||
when {
|
||||
@@ -90,8 +95,10 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
return isNight && get<AppSettings>().isAmoledTheme
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
super.onSupportActionModeStarted(mode)
|
||||
actionModeDelegate.onSupportActionModeStarted(mode)
|
||||
val insets = ViewCompat.getRootWindowInsets(binding.root)
|
||||
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
||||
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
|
||||
@@ -100,6 +107,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onSupportActionModeFinished(mode: ActionMode) {
|
||||
super.onSupportActionModeFinished(mode)
|
||||
actionModeDelegate.onSupportActionModeFinished(mode)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if ( // https://issuetracker.google.com/issues/139738913
|
||||
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
|
||||
@@ -111,4 +124,4 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,12 @@ import android.view.ViewGroup.LayoutParams
|
||||
import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.R as materialR
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
||||
|
||||
@@ -43,7 +44,9 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return if (resources.getBoolean(R.bool.is_tablet)) {
|
||||
AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
|
||||
} else super.onCreateDialog(savedInstanceState)
|
||||
} else {
|
||||
AppBottomSheetDialog(requireContext(), theme)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
|
||||
@@ -6,10 +6,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
|
||||
abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
Fragment(),
|
||||
WindowInsetsDelegate.WindowInsetsListener {
|
||||
|
||||
private var viewBinding: B? = null
|
||||
@@ -23,6 +25,9 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
||||
@Suppress("LeakingThis")
|
||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||
|
||||
protected val actionModeDelegate: ActionModeDelegate
|
||||
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
@@ -47,4 +52,4 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
||||
protected fun bindingOrNull() = viewBinding
|
||||
|
||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.viewbinding.ViewBinding
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
@@ -18,7 +20,8 @@ private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
|
||||
abstract class BaseFullscreenActivity<B : ViewBinding> : BaseActivity<B>(),
|
||||
abstract class BaseFullscreenActivity<B : ViewBinding> :
|
||||
BaseActivity<B>(),
|
||||
View.OnSystemUiVisibilityChangeListener {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -35,16 +38,19 @@ abstract class BaseFullscreenActivity<B : ViewBinding> : BaseActivity<B>(),
|
||||
showSystemUI()
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
|
||||
@Deprecated("Deprecated in Java")
|
||||
final override fun onSystemUiVisibilityChange(visibility: Int) {
|
||||
onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0)
|
||||
}
|
||||
|
||||
// TODO WindowInsetsControllerCompat works incorrect
|
||||
@Suppress("DEPRECATION")
|
||||
protected fun hideSystemUI() {
|
||||
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
protected fun showSystemUI() {
|
||||
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN
|
||||
}
|
||||
|
||||
@@ -6,14 +6,18 @@ import androidx.annotation.CallSuper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
import org.koitharu.kotatsu.settings.SettingsHeadersFragment
|
||||
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(),
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
WindowInsetsDelegate.WindowInsetsListener,
|
||||
RecyclerViewOwner {
|
||||
|
||||
@@ -39,16 +43,20 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : Pre
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (titleId != 0) {
|
||||
activity?.setTitle(titleId)
|
||||
setTitle(getString(titleId))
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
listView.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UsePropertyAccessSyntax")
|
||||
protected fun setTitle(title: CharSequence) {
|
||||
(parentFragment as? SettingsHeadersFragment)?.setTitle(title)
|
||||
?: activity?.setTitle(title)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
package org.koitharu.kotatsu.base.ui
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlinx.coroutines.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
|
||||
abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
val onError = SingleLiveEvent<Throwable>()
|
||||
val isLoading = MutableLiveData(false)
|
||||
protected val loadingCounter = CountedBooleanLiveData()
|
||||
protected val errorEvent = SingleLiveEvent<Throwable>()
|
||||
|
||||
val onError: LiveData<Throwable>
|
||||
get() = errorEvent
|
||||
|
||||
val isLoading: LiveData<Boolean>
|
||||
get() = loadingCounter
|
||||
|
||||
protected fun launchJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
@@ -25,20 +32,18 @@ abstract class BaseViewModel : ViewModel() {
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||
isLoading.postValue(true)
|
||||
loadingCounter.increment()
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
isLoading.postValue(false)
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||
if (BuildConfig.DEBUG) {
|
||||
throwable.printStackTrace()
|
||||
}
|
||||
throwable.printStackTraceDebug()
|
||||
if (throwable !is CancellationException) {
|
||||
onError.postCall(throwable)
|
||||
errorEvent.postCall(throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.base.ui
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
abstract class CoroutineIntentService : BaseService() {
|
||||
|
||||
private val mutex = Mutex()
|
||||
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
launchCoroutine(intent, startId)
|
||||
return Service.START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
|
||||
mutex.withLock {
|
||||
try {
|
||||
withContext(dispatcher) {
|
||||
processIntent(intent)
|
||||
}
|
||||
} finally {
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract suspend fun processIntent(intent: Intent?)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.koitharu.kotatsu.base.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.view.View
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
|
||||
class AppBottomSheetDialog(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
|
||||
|
||||
/**
|
||||
* https://github.com/material-components/material-components-android/issues/2582
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onAttachedToWindow() {
|
||||
val window = window
|
||||
val initialSystemUiVisibility = window?.decorView?.systemUiVisibility ?: 0
|
||||
super.onAttachedToWindow()
|
||||
if (window != null) {
|
||||
// If the navigation bar is translucent at all, the BottomSheet should be edge to edge
|
||||
val drawEdgeToEdge = edgeToEdgeEnabled && Color.alpha(window.navigationBarColor) < 0xFF
|
||||
if (drawEdgeToEdge) {
|
||||
// Copied from super.onAttachedToWindow:
|
||||
val edgeToEdgeFlags = View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
// Fix super-class's window flag bug by respecting the intial system UI visibility:
|
||||
window.decorView.systemUiVisibility = edgeToEdgeFlags or initialSystemUiVisibility
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.base.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.utils.ext.inflate
|
||||
import java.io.File
|
||||
|
||||
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||
@@ -66,7 +66,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
||||
val volumes = getAvailableVolumes(storageManager)
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: parent.inflate(R.layout.item_storage)
|
||||
val view = convertView ?: LayoutInflater.from(parent.context).inflate(R.layout.item_storage, parent, false)
|
||||
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
||||
view.tag = it
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.base.ui.list
|
||||
|
||||
import android.view.View
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.View.OnLongClickListener
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||
|
||||
class AdapterDelegateClickListenerAdapter<I>(
|
||||
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<I, *>,
|
||||
private val clickListener: OnListItemClickListener<I>,
|
||||
) : OnClickListener, OnLongClickListener {
|
||||
|
||||
override fun onClick(v: View) {
|
||||
clickListener.onItemClick(adapterDelegate.item, v)
|
||||
}
|
||||
|
||||
override fun onLongClick(v: View): Boolean {
|
||||
return clickListener.onItemLongClick(adapterDelegate.item, v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package org.koitharu.kotatsu.base.ui.list.decor
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import androidx.core.view.children
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.NO_ID
|
||||
|
||||
abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val bounds = Rect()
|
||||
private val boundsF = RectF()
|
||||
private val selection = HashSet<Long>()
|
||||
|
||||
protected var hasBackground: Boolean = true
|
||||
protected var hasForeground: Boolean = false
|
||||
protected var isIncludeDecorAndMargins: Boolean = true
|
||||
|
||||
val checkedItemsCount: Int
|
||||
get() = selection.size
|
||||
|
||||
val checkedItemsIds: Set<Long>
|
||||
get() = selection
|
||||
|
||||
fun toggleItemChecked(id: Long) {
|
||||
if (!selection.remove(id)) {
|
||||
selection.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemIsChecked(id: Long, isChecked: Boolean) {
|
||||
if (isChecked) {
|
||||
selection.add(id)
|
||||
} else {
|
||||
selection.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkAll(ids: Collection<Long>) {
|
||||
selection.addAll(ids)
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
selection.clear()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (hasBackground) {
|
||||
doDraw(canvas, parent, state, false)
|
||||
} else {
|
||||
super.onDraw(canvas, parent, state)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (hasForeground) {
|
||||
doDraw(canvas, parent, state, true)
|
||||
} else {
|
||||
super.onDrawOver(canvas, parent, state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun doDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State, isOver: Boolean) {
|
||||
val checkpoint = canvas.save()
|
||||
if (parent.clipToPadding) {
|
||||
canvas.clipRect(
|
||||
parent.paddingLeft, parent.paddingTop, parent.width - parent.paddingRight,
|
||||
parent.height - parent.paddingBottom
|
||||
)
|
||||
}
|
||||
|
||||
for (child in parent.children) {
|
||||
val itemId = getItemId(parent, child)
|
||||
if (itemId != NO_ID && itemId in selection) {
|
||||
if (isIncludeDecorAndMargins) {
|
||||
parent.getDecoratedBoundsWithMargins(child, bounds)
|
||||
} else {
|
||||
bounds.set(child.left, child.top, child.right, child.bottom)
|
||||
}
|
||||
boundsF.set(bounds)
|
||||
boundsF.offset(child.translationX, child.translationY)
|
||||
if (isOver) {
|
||||
onDrawForeground(canvas, parent, child, boundsF, state)
|
||||
} else {
|
||||
onDrawBackground(canvas, parent, child, boundsF, state)
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas.restoreToCount(checkpoint)
|
||||
}
|
||||
|
||||
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child)
|
||||
|
||||
protected open fun onDrawBackground(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State,
|
||||
) = Unit
|
||||
|
||||
protected open fun onDrawForeground(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State,
|
||||
) = Unit
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.base.ui.list.decor
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.util.SparseIntArray
|
||||
import android.view.View
|
||||
import androidx.core.util.getOrDefault
|
||||
import androidx.core.util.set
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class TypedSpacingItemDecoration(
|
||||
vararg spacingMapping: Pair<Int, Int>,
|
||||
private val fallbackSpacing: Int = 0,
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
private val mapping = SparseIntArray(spacingMapping.size)
|
||||
|
||||
init {
|
||||
spacingMapping.forEach { (k, v) -> mapping[k] = v }
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
val itemType = parent.getChildViewHolder(view)?.itemViewType
|
||||
val spacing = if (itemType == null) {
|
||||
fallbackSpacing
|
||||
} else {
|
||||
mapping.getOrDefault(itemType, fallbackSpacing)
|
||||
}
|
||||
outRect.set(spacing, spacing, spacing, spacing)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
class ActionModeDelegate {
|
||||
|
||||
private var activeActionMode: ActionMode? = null
|
||||
private var listeners: MutableList<ActionModeListener>? = null
|
||||
|
||||
val isActionModeStarted: Boolean
|
||||
get() = activeActionMode != null
|
||||
|
||||
fun onSupportActionModeStarted(mode: ActionMode) {
|
||||
activeActionMode = mode
|
||||
listeners?.forEach { it.onActionModeStarted(mode) }
|
||||
}
|
||||
|
||||
fun onSupportActionModeFinished(mode: ActionMode) {
|
||||
activeActionMode = null
|
||||
listeners?.forEach { it.onActionModeFinished(mode) }
|
||||
}
|
||||
|
||||
fun addListener(listener: ActionModeListener) {
|
||||
if (listeners == null) {
|
||||
listeners = ArrayList()
|
||||
}
|
||||
checkNotNull(listeners).add(listener)
|
||||
}
|
||||
|
||||
fun removeListener(listener: ActionModeListener) {
|
||||
listeners?.remove(listener)
|
||||
}
|
||||
|
||||
fun addListener(listener: ActionModeListener, owner: LifecycleOwner) {
|
||||
addListener(listener)
|
||||
owner.lifecycle.addObserver(ListenerLifecycleObserver(listener))
|
||||
}
|
||||
|
||||
private inner class ListenerLifecycleObserver(
|
||||
private val listener: ActionModeListener,
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
super.onDestroy(owner)
|
||||
removeListener(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.appcompat.view.ActionMode
|
||||
|
||||
interface ActionModeListener {
|
||||
|
||||
fun onActionModeStarted(mode: ActionMode)
|
||||
|
||||
fun onActionModeFinished(mode: ActionMode)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class CountedBooleanLiveData : LiveData<Boolean>(false) {
|
||||
|
||||
private val counter = AtomicInteger(0)
|
||||
|
||||
@AnyThread
|
||||
fun increment() {
|
||||
if (counter.getAndIncrement() == 0) {
|
||||
postValue(true)
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun decrement() {
|
||||
if (counter.decrementAndGet() == 0) {
|
||||
postValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun reset() {
|
||||
if (counter.getAndSet(0) != 0) {
|
||||
postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.koitharu.kotatsu.base.ui.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout.Behavior
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
|
||||
|
||||
class ShrinkOnScrollBehavior : Behavior<ExtendedFloatingActionButton> {
|
||||
|
||||
@Suppress("unused") constructor() : super()
|
||||
@Suppress("unused") constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
|
||||
|
||||
override fun onStartNestedScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: ExtendedFloatingActionButton,
|
||||
directTargetChild: View,
|
||||
target: View,
|
||||
axes: Int,
|
||||
type: Int
|
||||
): Boolean {
|
||||
return axes == ViewCompat.SCROLL_AXIS_VERTICAL
|
||||
}
|
||||
|
||||
override fun onNestedScroll(
|
||||
coordinatorLayout: CoordinatorLayout,
|
||||
child: ExtendedFloatingActionButton,
|
||||
target: View,
|
||||
dxConsumed: Int,
|
||||
dyConsumed: Int,
|
||||
dxUnconsumed: Int,
|
||||
dyUnconsumed: Int,
|
||||
type: Int,
|
||||
consumed: IntArray
|
||||
) {
|
||||
if (dyConsumed > 0) {
|
||||
if (child.isExtended) {
|
||||
child.shrink()
|
||||
}
|
||||
} else if (dyConsumed < 0) {
|
||||
if (!child.isExtended) {
|
||||
child.extend()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.os.Parcelable.Creator
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.Checkable
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
@@ -61,6 +62,12 @@ class CheckableImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
class ToggleOnClickListener : OnClickListener {
|
||||
override fun onClick(view: View) {
|
||||
(view as? Checkable)?.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnCheckedChangeListener {
|
||||
|
||||
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
|
||||
|
||||
@@ -13,12 +13,12 @@ import android.graphics.drawable.shapes.RectShape
|
||||
import android.util.AttributeSet
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.AppCompatCheckedTextView
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import com.google.android.material.ripple.RippleUtils
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
class ListItemTextView @JvmOverloads constructor(
|
||||
@@ -36,8 +36,7 @@ class ListItemTextView @JvmOverloads constructor(
|
||||
|
||||
init {
|
||||
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
|
||||
val itemRippleColor = getColorStateList(R.styleable.ListItemTextView_rippleColor)
|
||||
?: getRippleColorFallback(context)
|
||||
val itemRippleColor = getRippleColor(context)
|
||||
val shape = createShapeDrawable(this)
|
||||
background = RippleDrawable(
|
||||
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
|
||||
@@ -108,7 +107,7 @@ class ListItemTextView @JvmOverloads constructor(
|
||||
ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0),
|
||||
).build()
|
||||
val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
|
||||
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundTint)
|
||||
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundFillColor)
|
||||
return InsetDrawable(
|
||||
shapeDrawable,
|
||||
ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0),
|
||||
@@ -118,9 +117,8 @@ class ListItemTextView @JvmOverloads constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun getRippleColorFallback(context: Context): ColorStateList {
|
||||
return context.obtainStyledAttributes(intArrayOf(android.R.attr.colorControlHighlight)).use {
|
||||
it.getColorStateList(0)
|
||||
} ?: ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
private fun getRippleColor(context: Context): ColorStateList {
|
||||
return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
|
||||
?: ColorStateList.valueOf(Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.base.ui.widgets
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
class WindowInsetHolder @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
@AttrRes defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private var desiredHeight = 0
|
||||
private var desiredWidth = 0
|
||||
|
||||
@SuppressLint("RtlHardcoded")
|
||||
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
|
||||
val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this)
|
||||
.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val gravity = getLayoutGravity()
|
||||
val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) {
|
||||
Gravity.LEFT -> barsInsets.left
|
||||
Gravity.RIGHT -> barsInsets.right
|
||||
else -> 0
|
||||
}
|
||||
val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) {
|
||||
Gravity.TOP -> barsInsets.top
|
||||
Gravity.BOTTOM -> barsInsets.bottom
|
||||
else -> 0
|
||||
}
|
||||
if (newWidth != desiredWidth || newHeight != desiredHeight) {
|
||||
desiredWidth = newWidth
|
||||
desiredHeight = newHeight
|
||||
requestLayout()
|
||||
}
|
||||
return super.dispatchApplyWindowInsets(insets)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
|
||||
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
|
||||
super.onMeasure(
|
||||
if (desiredWidth == 0 || widthMode == MeasureSpec.EXACTLY) {
|
||||
widthMeasureSpec
|
||||
} else {
|
||||
MeasureSpec.makeMeasureSpec(desiredWidth, widthMode)
|
||||
},
|
||||
if (desiredHeight == 0 || heightMode == MeasureSpec.EXACTLY) {
|
||||
heightMeasureSpec
|
||||
} else {
|
||||
MeasureSpec.makeMeasureSpec(desiredHeight, heightMode)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getLayoutGravity(): Int {
|
||||
return when (val lp = layoutParams) {
|
||||
is FrameLayout.LayoutParams -> lp.gravity
|
||||
is LinearLayout.LayoutParams -> lp.gravity
|
||||
is CoordinatorLayout.LayoutParams -> lp.gravity
|
||||
else -> Gravity.NO_GRAVITY
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.bookmarks
|
||||
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
|
||||
val bookmarksModule
|
||||
get() = module {
|
||||
|
||||
factory { BookmarksRepository(get()) }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.bookmarks.data
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
|
||||
@Entity(
|
||||
tableName = "bookmarks",
|
||||
primaryKeys = ["manga_id", "page_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
),
|
||||
]
|
||||
)
|
||||
class BookmarkEntity(
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "page_id", index = true) val pageId: Long,
|
||||
@ColumnInfo(name = "chapter_id") val chapterId: Long,
|
||||
@ColumnInfo(name = "page") val page: Int,
|
||||
@ColumnInfo(name = "scroll") val scroll: Int,
|
||||
@ColumnInfo(name = "image") val imageUrl: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.koitharu.kotatsu.bookmarks.data
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||
|
||||
class BookmarkWithManga(
|
||||
@Embedded val bookmark: BookmarkEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
entityColumn = "manga_id"
|
||||
)
|
||||
val manga: MangaEntity,
|
||||
@Relation(
|
||||
parentColumn = "manga_id",
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.bookmarks.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
abstract class BookmarksDao {
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId AND chapter_id = :chapterId AND page = :page")
|
||||
abstract fun observe(mangaId: Long, chapterId: Long, page: Int): Flow<BookmarkEntity?>
|
||||
|
||||
@Query("SELECT * FROM bookmarks WHERE manga_id = :mangaId ORDER BY created_at DESC")
|
||||
abstract fun observe(mangaId: Long): Flow<List<BookmarkEntity>>
|
||||
|
||||
@Insert
|
||||
abstract suspend fun insert(entity: BookmarkEntity)
|
||||
|
||||
@Delete
|
||||
abstract suspend fun delete(entity: BookmarkEntity)
|
||||
|
||||
@Query("DELETE FROM bookmarks WHERE manga_id = :mangaId AND page_id = :pageId")
|
||||
abstract suspend fun delete(mangaId: Long, pageId: Long)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.koitharu.kotatsu.bookmarks.data
|
||||
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.*
|
||||
|
||||
fun BookmarkWithManga.toBookmark() = bookmark.toBookmark(
|
||||
manga.toManga(tags.toMangaTags())
|
||||
)
|
||||
|
||||
fun BookmarkEntity.toBookmark(manga: Manga) = Bookmark(
|
||||
manga = manga,
|
||||
pageId = pageId,
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll,
|
||||
imageUrl = imageUrl,
|
||||
createdAt = Date(createdAt),
|
||||
)
|
||||
|
||||
fun Bookmark.toEntity() = BookmarkEntity(
|
||||
mangaId = manga.id,
|
||||
pageId = pageId,
|
||||
chapterId = chapterId,
|
||||
page = page,
|
||||
scroll = scroll,
|
||||
imageUrl = imageUrl,
|
||||
createdAt = createdAt.time,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.koitharu.kotatsu.bookmarks.domain
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import java.util.*
|
||||
|
||||
class Bookmark(
|
||||
val manga: Manga,
|
||||
val pageId: Long,
|
||||
val chapterId: Long,
|
||||
val page: Int,
|
||||
val scroll: Int,
|
||||
val imageUrl: String,
|
||||
val createdAt: Date,
|
||||
) {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Bookmark
|
||||
|
||||
if (manga != other.manga) return false
|
||||
if (pageId != other.pageId) return false
|
||||
if (chapterId != other.chapterId) return false
|
||||
if (page != other.page) return false
|
||||
if (scroll != other.scroll) return false
|
||||
if (imageUrl != other.imageUrl) return false
|
||||
if (createdAt != other.createdAt) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = manga.hashCode()
|
||||
result = 31 * result + pageId.hashCode()
|
||||
result = 31 * result + chapterId.hashCode()
|
||||
result = 31 * result + page
|
||||
result = 31 * result + scroll
|
||||
result = 31 * result + imageUrl.hashCode()
|
||||
result = 31 * result + createdAt.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.koitharu.kotatsu.bookmarks.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.bookmarks.data.toBookmark
|
||||
import org.koitharu.kotatsu.bookmarks.data.toEntity
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntities
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.ext.mapItems
|
||||
|
||||
class BookmarksRepository(
|
||||
private val db: MangaDatabase,
|
||||
) {
|
||||
|
||||
fun observeBookmark(manga: Manga, chapterId: Long, page: Int): Flow<Bookmark?> {
|
||||
return db.bookmarksDao.observe(manga.id, chapterId, page).map { it?.toBookmark(manga) }
|
||||
}
|
||||
|
||||
fun observeBookmarks(manga: Manga): Flow<List<Bookmark>> {
|
||||
return db.bookmarksDao.observe(manga.id).mapItems { it.toBookmark(manga) }
|
||||
}
|
||||
|
||||
suspend fun addBookmark(bookmark: Bookmark) {
|
||||
db.withTransaction {
|
||||
val tags = bookmark.manga.tags.toEntities()
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(bookmark.manga.toEntity(), tags)
|
||||
db.bookmarksDao.insert(bookmark.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(mangaId: Long, pageId: Long) {
|
||||
db.bookmarksDao.delete(mangaId, pageId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.size.Scale
|
||||
import coil.util.CoilUtils
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
|
||||
import org.koitharu.kotatsu.utils.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.utils.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.utils.ext.referer
|
||||
|
||||
fun bookmarkListAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Bookmark>,
|
||||
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
|
||||
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
var imageRequest: Disposable? = null
|
||||
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
|
||||
binding.root.setOnClickListener(listener)
|
||||
binding.root.setOnLongClickListener(listener)
|
||||
|
||||
bind {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = binding.imageViewThumb.newImageRequest(item.imageUrl)
|
||||
.referer(item.manga.publicUrl)
|
||||
.placeholder(R.drawable.ic_placeholder)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
.error(R.drawable.ic_placeholder)
|
||||
.allowRgb565(true)
|
||||
.scale(Scale.FILL)
|
||||
.lifecycle(lifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
onViewRecycled {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
CoilUtils.dispose(binding.imageViewThumb)
|
||||
binding.imageViewThumb.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.bookmarks.ui
|
||||
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
|
||||
class BookmarksAdapter(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
clickListener: OnListItemClickListener<Bookmark>,
|
||||
) : AsyncListDifferDelegationAdapter<Bookmark>(
|
||||
DiffCallback(),
|
||||
bookmarkListAD(coil, lifecycleOwner, clickListener)
|
||||
) {
|
||||
|
||||
private class DiffCallback : DiffUtil.ItemCallback<Bookmark>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
|
||||
return oldItem.manga.id == newItem.manga.id && oldItem.chapterId == newItem.chapterId
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Bookmark, newItem: Bookmark): Boolean {
|
||||
return oldItem.imageUrl == newItem.imageUrl
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ import android.view.MenuItem
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.R as materialR
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
@@ -28,6 +29,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
with(binding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = UserAgentInterceptor.userAgent
|
||||
}
|
||||
binding.webView.webViewClient = BrowserClient(this)
|
||||
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
||||
|
||||
@@ -2,15 +2,11 @@ package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
||||
|
||||
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
|
||||
|
||||
private val okHttp by inject<OkHttpClient>(mode = LazyThreadSafetyMode.SYNCHRONIZED)
|
||||
|
||||
override fun onPageFinished(webView: WebView, url: String) {
|
||||
super.onPageFinished(webView, url)
|
||||
callback.onLoadingStateChanged(isLoading = false)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.MutableZipFile
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
|
||||
class BackupArchive(file: File) : MutableZipFile(file) {
|
||||
|
||||
init {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry {
|
||||
val json = withContext(Dispatchers.Default) {
|
||||
JSONArray(getContent(name))
|
||||
}
|
||||
return BackupEntry(name, json)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bak")
|
||||
}
|
||||
BackupArchive(File(dir, filename))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,7 @@ class BackupRepository(private val db: MangaDatabase) {
|
||||
jo.put("sort_key", sortKey)
|
||||
jo.put("title", title)
|
||||
jo.put("order", order)
|
||||
jo.put("track", track)
|
||||
return jo
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class BackupZipInput(val file: File) : Closeable {
|
||||
|
||||
private val zipFile = ZipFile(file)
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
|
||||
val entry = zipFile.getEntry(name)
|
||||
val json = zipFile.getInputStream(entry).use {
|
||||
JSONArray(it.bufferedReader().readText())
|
||||
}
|
||||
BackupEntry(name, json)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
zipFile.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.zip.Deflater
|
||||
|
||||
class BackupZipOutput(val file: File) : Closeable {
|
||||
|
||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
output.put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun finish() {
|
||||
output.finish()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
}
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bk.zip")
|
||||
}
|
||||
BackupZipOutput(File(dir, filename))
|
||||
}
|
||||
@@ -104,6 +104,7 @@ class RestoreRepository(private val db: MangaDatabase) {
|
||||
sortKey = json.getInt("sort_key"),
|
||||
title = json.getString("title"),
|
||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
||||
track = json.getBooleanOrDefault("track", true),
|
||||
)
|
||||
|
||||
private fun parseFavourite(json: JSONObject) = FavouriteEntity(
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import androidx.room.Room
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.db.migrations.*
|
||||
|
||||
val databaseModule
|
||||
get() = module {
|
||||
single {
|
||||
Room.databaseBuilder(
|
||||
androidContext(),
|
||||
MangaDatabase::class.java,
|
||||
"kotatsu-db"
|
||||
).addMigrations(
|
||||
Migration1To2(),
|
||||
Migration2To3(),
|
||||
Migration3To4(),
|
||||
Migration4To5(),
|
||||
Migration5To6(),
|
||||
Migration6To7(),
|
||||
Migration7To8(),
|
||||
Migration8To9(),
|
||||
).addCallback(
|
||||
DatabasePrePopulateCallback(androidContext().resources)
|
||||
).build()
|
||||
}
|
||||
single { MangaDatabase(androidContext()) }
|
||||
}
|
||||
@@ -10,8 +10,8 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
|
||||
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)",
|
||||
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name)
|
||||
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track) VALUES (?,?,?,?,?)",
|
||||
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||
import org.koitharu.kotatsu.bookmarks.data.BookmarksDao
|
||||
import org.koitharu.kotatsu.core.db.dao.*
|
||||
import org.koitharu.kotatsu.core.db.entity.*
|
||||
import org.koitharu.kotatsu.core.db.migrations.*
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||
@@ -17,9 +22,9 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
entities = [
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||
],
|
||||
version = 9
|
||||
version = 11,
|
||||
)
|
||||
abstract class MangaDatabase : RoomDatabase() {
|
||||
|
||||
@@ -40,4 +45,25 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract val trackLogsDao: TrackLogsDao
|
||||
|
||||
abstract val suggestionDao: SuggestionDao
|
||||
}
|
||||
|
||||
abstract val bookmarksDao: BookmarksDao
|
||||
}
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room.databaseBuilder(
|
||||
context,
|
||||
MangaDatabase::class.java,
|
||||
"kotatsu-db"
|
||||
).addMigrations(
|
||||
Migration1To2(),
|
||||
Migration2To3(),
|
||||
Migration3To4(),
|
||||
Migration4To5(),
|
||||
Migration5To6(),
|
||||
Migration6To7(),
|
||||
Migration7To8(),
|
||||
Migration8To9(),
|
||||
Migration9To10(),
|
||||
Migration10To11(),
|
||||
).addCallback(
|
||||
DatabasePrePopulateCallback(context.resources)
|
||||
).build()
|
||||
@@ -12,7 +12,7 @@ abstract class TagsDao {
|
||||
@Query(
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
GROUP BY manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
@@ -22,7 +22,7 @@ abstract class TagsDao {
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source
|
||||
GROUP BY manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
@@ -32,7 +32,7 @@ abstract class TagsDao {
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE tags.source = :source AND title LIKE :query
|
||||
GROUP BY manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
@@ -42,7 +42,7 @@ abstract class TagsDao {
|
||||
"""SELECT tags.* FROM tags
|
||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||
WHERE title LIKE :query
|
||||
GROUP BY manga_tags.tag_id
|
||||
GROUP BY tags.title
|
||||
ORDER BY COUNT(manga_id) DESC
|
||||
LIMIT :limit"""
|
||||
)
|
||||
|
||||
@@ -10,6 +10,9 @@ abstract class TracksDao {
|
||||
@Query("SELECT * FROM tracks")
|
||||
abstract suspend fun findAll(): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
|
||||
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun find(mangaId: Long): TrackEntity?
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.koitharu.kotatsu.core.db.entity
|
||||
|
||||
import java.util.*
|
||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
|
||||
// Entity to model
|
||||
|
||||
fun TagEntity.toMangaTag() = MangaTag(
|
||||
key = this.key,
|
||||
title = this.title.toTitleCase(),
|
||||
source = MangaSource.valueOf(this.source),
|
||||
)
|
||||
|
||||
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
|
||||
|
||||
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
state = this.state?.let { MangaState.valueOf(it) },
|
||||
rating = this.rating,
|
||||
isNsfw = this.isNsfw,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
author = this.author,
|
||||
source = MangaSource.valueOf(this.source),
|
||||
tags = tags
|
||||
)
|
||||
|
||||
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
|
||||
|
||||
fun TrackLogWithManga.toTrackingLogItem() = TrackingLogItem(
|
||||
id = trackLog.id,
|
||||
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
|
||||
manga = manga.toManga(tags.toMangaTags()),
|
||||
createdAt = Date(trackLog.createdAt)
|
||||
)
|
||||
|
||||
// Model to entity
|
||||
|
||||
fun Manga.toEntity() = MangaEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
source = source.name,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
coverUrl = coverUrl,
|
||||
altTitle = altTitle,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
state = state?.name,
|
||||
title = title,
|
||||
author = author,
|
||||
)
|
||||
|
||||
fun MangaTag.toEntity() = TagEntity(
|
||||
title = title,
|
||||
key = key,
|
||||
source = source.name,
|
||||
id = "${key}_${source.name}".longHashCode()
|
||||
)
|
||||
|
||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||
|
||||
// Other
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
|
||||
SortOrder.valueOf(name)
|
||||
}.getOrDefault(fallback)
|
||||
@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
@Entity(tableName = "manga")
|
||||
class MangaEntity(
|
||||
@@ -16,46 +12,11 @@ class MangaEntity(
|
||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||
@ColumnInfo(name = "url") val url: String,
|
||||
@ColumnInfo(name = "public_url") val publicUrl: String,
|
||||
@ColumnInfo(name = "rating") val rating: Float, //normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
|
||||
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
|
||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||
@ColumnInfo(name = "state") val state: String?,
|
||||
@ColumnInfo(name = "author") val author: String?,
|
||||
@ColumnInfo(name = "source") val source: String
|
||||
) {
|
||||
|
||||
fun toManga(tags: Set<MangaTag> = emptySet()) = Manga(
|
||||
id = this.id,
|
||||
title = this.title,
|
||||
altTitle = this.altTitle,
|
||||
state = this.state?.let { MangaState.valueOf(it) },
|
||||
rating = this.rating,
|
||||
isNsfw = this.isNsfw,
|
||||
url = this.url,
|
||||
publicUrl = this.publicUrl,
|
||||
coverUrl = this.coverUrl,
|
||||
largeCoverUrl = this.largeCoverUrl,
|
||||
author = this.author,
|
||||
source = MangaSource.valueOf(this.source),
|
||||
tags = tags
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(manga: Manga) = MangaEntity(
|
||||
id = manga.id,
|
||||
url = manga.url,
|
||||
publicUrl = manga.publicUrl,
|
||||
source = manga.source.name,
|
||||
largeCoverUrl = manga.largeCoverUrl,
|
||||
coverUrl = manga.coverUrl,
|
||||
altTitle = manga.altTitle,
|
||||
rating = manga.rating,
|
||||
isNsfw = manga.isNsfw,
|
||||
state = manga.state?.name,
|
||||
title = manga.title,
|
||||
author = manga.author
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -6,13 +6,15 @@ import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "preferences", foreignKeys = [
|
||||
tableName = "preferences",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
childColumns = ["manga_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)]
|
||||
)
|
||||
]
|
||||
)
|
||||
class MangaPrefsEntity(
|
||||
@PrimaryKey(autoGenerate = false)
|
||||
|
||||
@@ -5,7 +5,8 @@ import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
@Entity(
|
||||
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [
|
||||
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
|
||||
class MangaWithTags(
|
||||
@Embedded val manga: MangaEntity,
|
||||
@@ -12,10 +11,5 @@ class MangaWithTags(
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>
|
||||
) {
|
||||
|
||||
fun toManga() = manga.toManga(tags.mapToSet {
|
||||
it.toMangaTag()
|
||||
})
|
||||
}
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.longHashCode
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
|
||||
@Entity(tableName = "tags")
|
||||
class TagEntity(
|
||||
@@ -15,21 +11,4 @@ class TagEntity(
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "key") val key: String,
|
||||
@ColumnInfo(name = "source") val source: String
|
||||
) {
|
||||
|
||||
fun toMangaTag() = MangaTag(
|
||||
key = this.key,
|
||||
title = this.title.toTitleCase(),
|
||||
source = MangaSource.valueOf(this.source)
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
fun fromMangaTag(tag: MangaTag) = TagEntity(
|
||||
title = tag.title,
|
||||
key = tag.key,
|
||||
source = tag.source.name,
|
||||
id = "${tag.key}_${tag.source.name}".longHashCode()
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "tracks", foreignKeys = [
|
||||
tableName = "tracks",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
|
||||
@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "track_logs", foreignKeys = [
|
||||
tableName = "track_logs",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = MangaEntity::class,
|
||||
parentColumns = ["manga_id"],
|
||||
@@ -20,5 +21,5 @@ class TrackLogEntity(
|
||||
@ColumnInfo(name = "id") val id: Long = 0L,
|
||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||
@ColumnInfo(name = "chapters") val chapters: String,
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
|
||||
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
@@ -3,9 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Junction
|
||||
import androidx.room.Relation
|
||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import java.util.*
|
||||
|
||||
class TrackLogWithManga(
|
||||
@Embedded val trackLog: TrackLogEntity,
|
||||
@@ -19,13 +16,5 @@ class TrackLogWithManga(
|
||||
entityColumn = "tag_id",
|
||||
associateBy = Junction(MangaTagsEntity::class)
|
||||
)
|
||||
val tags: List<TagEntity>
|
||||
) {
|
||||
|
||||
fun toTrackingLogItem() = TrackingLogItem(
|
||||
id = trackLog.id,
|
||||
chapters = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() },
|
||||
manga = manga.toManga(tags.mapToSet { x -> x.toMangaTag() }),
|
||||
createdAt = Date(trackLog.createdAt)
|
||||
)
|
||||
}
|
||||
val tags: List<TagEntity>,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration10To11 : Migration(10, 11) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS `bookmarks` (
|
||||
`manga_id` INTEGER NOT NULL,
|
||||
`page_id` INTEGER NOT NULL,
|
||||
`chapter_id` INTEGER NOT NULL,
|
||||
`page` INTEGER NOT NULL,
|
||||
`scroll` INTEGER NOT NULL,
|
||||
`image` TEXT NOT NULL,
|
||||
`created_at` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`manga_id`, `page_id`),
|
||||
FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )
|
||||
""".trimIndent()
|
||||
)
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_manga_id` ON `bookmarks` (`manga_id`)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_bookmarks_page_id` ON `bookmarks` (`page_id`)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration9To10 : Migration(9, 10) {
|
||||
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `track` INTEGER NOT NULL DEFAULT 1")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,5 @@ import org.koin.dsl.module
|
||||
|
||||
val githubModule
|
||||
get() = module {
|
||||
single {
|
||||
GithubRepository(get())
|
||||
}
|
||||
factory { GithubRepository(get()) }
|
||||
}
|
||||
@@ -54,27 +54,23 @@ class VersionId(
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private fun variantWeight(variantType: String) =
|
||||
when (variantType.lowercase(Locale.ROOT)) {
|
||||
"a", "alpha" -> 1
|
||||
"b", "beta" -> 2
|
||||
"rc" -> 4
|
||||
"" -> 8
|
||||
else -> 0
|
||||
}
|
||||
|
||||
fun parse(versionName: String): VersionId {
|
||||
val parts = versionName.substringBeforeLast('-').split('.')
|
||||
val variant = versionName.substringAfterLast('-', "")
|
||||
return VersionId(
|
||||
major = parts.getOrNull(0)?.toIntOrNull() ?: 0,
|
||||
minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
|
||||
build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
|
||||
variantType = variant.filter(Char::isLetter),
|
||||
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0
|
||||
)
|
||||
}
|
||||
private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
|
||||
"a", "alpha" -> 1
|
||||
"b", "beta" -> 2
|
||||
"rc" -> 4
|
||||
"" -> 8
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
fun VersionId(versionName: String): VersionId {
|
||||
val parts = versionName.substringBeforeLast('-').split('.')
|
||||
val variant = versionName.substringAfterLast('-', "")
|
||||
return VersionId(
|
||||
major = parts.getOrNull(0)?.toIntOrNull() ?: 0,
|
||||
minor = parts.getOrNull(1)?.toIntOrNull() ?: 0,
|
||||
build = parts.getOrNull(2)?.toIntOrNull() ?: 0,
|
||||
variantType = variant.filter(Char::isLetter),
|
||||
variantNumber = variant.filter(Char::isDigit).toIntOrNull() ?: 0,
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import java.util.*
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.*
|
||||
|
||||
@Parcelize
|
||||
data class FavouriteCategory(
|
||||
@@ -12,4 +12,5 @@ data class FavouriteCategory(
|
||||
val sortKey: Int,
|
||||
val order: SortOrder,
|
||||
val createdAt: Date,
|
||||
val isTrackingEnabled: Boolean,
|
||||
) : Parcelable
|
||||
@@ -1,25 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
|
||||
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
|
||||
this
|
||||
} else {
|
||||
Manga(
|
||||
id = id,
|
||||
title = title,
|
||||
altTitle = altTitle,
|
||||
url = url,
|
||||
publicUrl = publicUrl,
|
||||
rating = rating,
|
||||
isNsfw = isNsfw,
|
||||
coverUrl = coverUrl,
|
||||
tags = tags,
|
||||
state = state,
|
||||
author = author,
|
||||
largeCoverUrl = largeCoverUrl,
|
||||
description = description,
|
||||
chapters = null,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||
@@ -4,7 +4,7 @@ import android.os.Parcel
|
||||
import androidx.core.os.ParcelCompat
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
|
||||
fun Manga.writeToParcel(out: Parcel, flags: Int) {
|
||||
fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
|
||||
out.writeLong(id)
|
||||
out.writeString(title)
|
||||
out.writeString(altTitle)
|
||||
@@ -18,7 +18,11 @@ fun Manga.writeToParcel(out: Parcel, flags: Int) {
|
||||
out.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||
out.writeSerializable(state)
|
||||
out.writeString(author)
|
||||
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
||||
if (withChapters) {
|
||||
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
||||
} else {
|
||||
out.writeString(null)
|
||||
}
|
||||
out.writeSerializable(source)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,39 @@ package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
// Limits to avoid TransactionTooLargeException
|
||||
private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size
|
||||
private const val MAX_SAFE_CHAPTERS_COUNT = 40 // this is 100% safe
|
||||
|
||||
class ParcelableManga(
|
||||
val manga: Manga,
|
||||
private val withChapters: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(parcel.readManga())
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG && manga.chapters != null) {
|
||||
Log.w("ParcelableManga", "Passing manga with chapters as Parcelable is dangerous!")
|
||||
}
|
||||
}
|
||||
constructor(parcel: Parcel) : this(parcel.readManga(), true)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
manga.writeToParcel(parcel, flags)
|
||||
val chapters = manga.chapters
|
||||
if (!withChapters || chapters == null) {
|
||||
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||
return
|
||||
}
|
||||
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
|
||||
// fast path
|
||||
manga.writeToParcel(parcel, flags, withChapters = true)
|
||||
return
|
||||
}
|
||||
val tempParcel = Parcel.obtain()
|
||||
manga.writeToParcel(tempParcel, flags, withChapters = true)
|
||||
val size = tempParcel.dataSize()
|
||||
if (size < MAX_SAFE_SIZE) {
|
||||
parcel.appendFrom(tempParcel, 0, size)
|
||||
} else {
|
||||
manga.writeToParcel(parcel, flags, withChapters = false)
|
||||
}
|
||||
tempParcel.recycle()
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
private const val TAG = "CURL"
|
||||
|
||||
class CurlLoggingInterceptor(
|
||||
private val extraCurlOptions: String? = null,
|
||||
) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request: Request = chain.request()
|
||||
var compressed = false
|
||||
val curlCmd = StringBuilder("curl")
|
||||
if (extraCurlOptions != null) {
|
||||
curlCmd.append(" ").append(extraCurlOptions)
|
||||
}
|
||||
curlCmd.append(" -X ").append(request.method)
|
||||
val headers = request.headers
|
||||
var i = 0
|
||||
val count = headers.size
|
||||
while (i < count) {
|
||||
val name = headers.name(i)
|
||||
val value = headers.value(i)
|
||||
if ("Accept-Encoding".equals(name, ignoreCase = true) && "gzip".equals(value,
|
||||
ignoreCase = true)
|
||||
) {
|
||||
compressed = true
|
||||
}
|
||||
curlCmd.append(" -H " + "\"").append(name).append(": ").append(value).append("\"")
|
||||
i++
|
||||
}
|
||||
val requestBody = request.body
|
||||
if (requestBody != null) {
|
||||
val buffer = Buffer()
|
||||
requestBody.writeTo(buffer)
|
||||
val contentType = requestBody.contentType()
|
||||
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||
curlCmd.append(" --data $'")
|
||||
.append(buffer.readString(charset).replace("\n", "\\n"))
|
||||
.append("'")
|
||||
}
|
||||
curlCmd.append(if (compressed) " --compressed " else " ").append(request.url)
|
||||
Log.d(TAG, "╭--- cURL (" + request.url + ")")
|
||||
Log.d(TAG, curlCmd.toString())
|
||||
Log.d(TAG, "╰--- (copy and paste the above line to a terminal)")
|
||||
return chain.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.dnsoverhttps.DnsOverHttps
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import java.net.InetAddress
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class DoHManager(
|
||||
cache: Cache,
|
||||
private val settings: AppSettings,
|
||||
) : Dns {
|
||||
|
||||
private val bootstrapClient = OkHttpClient.Builder().cache(cache).build()
|
||||
|
||||
private var cachedDelegate: Dns? = null
|
||||
private var cachedProvider: DoHProvider? = null
|
||||
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
return getDelegate().lookup(hostname)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getDelegate(): Dns {
|
||||
var delegate = cachedDelegate
|
||||
val provider = settings.dnsOverHttps
|
||||
if (delegate == null || provider != cachedProvider) {
|
||||
delegate = createDelegate(provider)
|
||||
cachedDelegate = delegate
|
||||
cachedProvider = provider
|
||||
}
|
||||
return delegate
|
||||
}
|
||||
|
||||
private fun createDelegate(provider: DoHProvider): Dns = when (provider) {
|
||||
DoHProvider.NONE -> Dns.SYSTEM
|
||||
DoHProvider.GOOGLE -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://dns.google/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
listOfNotNull(
|
||||
tryGetByIp("8.8.4.4"),
|
||||
tryGetByIp("8.8.8.8"),
|
||||
tryGetByIp("2001:4860:4860::8888"),
|
||||
tryGetByIp("2001:4860:4860::8844"),
|
||||
)
|
||||
).build()
|
||||
DoHProvider.CLOUDFLARE -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
listOfNotNull(
|
||||
tryGetByIp("162.159.36.1"),
|
||||
tryGetByIp("162.159.46.1"),
|
||||
tryGetByIp("1.1.1.1"),
|
||||
tryGetByIp("1.0.0.1"),
|
||||
tryGetByIp("162.159.132.53"),
|
||||
tryGetByIp("2606:4700:4700::1111"),
|
||||
tryGetByIp("2606:4700:4700::1001"),
|
||||
tryGetByIp("2606:4700:4700::0064"),
|
||||
tryGetByIp("2606:4700:4700::6400"),
|
||||
)
|
||||
).build()
|
||||
DoHProvider.ADGUARD -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||
.url("https://dns-unfiltered.adguard.com/dns-query".toHttpUrl())
|
||||
.bootstrapDnsHosts(
|
||||
listOfNotNull(
|
||||
tryGetByIp("94.140.14.140"),
|
||||
tryGetByIp("94.140.14.141"),
|
||||
tryGetByIp("2a10:50c0::1:ff"),
|
||||
tryGetByIp("2a10:50c0::2:ff"),
|
||||
)
|
||||
).build()
|
||||
}
|
||||
|
||||
private fun tryGetByIp(ip: String): InetAddress? = try {
|
||||
InetAddress.getByName(ip)
|
||||
} catch (e: UnknownHostException) {
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
enum class DoHProvider {
|
||||
|
||||
NONE, GOOGLE, CLOUDFLARE, ADGUARD
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
val networkModule
|
||||
get() = module {
|
||||
single { AndroidCookieJar() } bind CookieJar::class
|
||||
single {
|
||||
val cache = get<LocalStorageManager>().createHttpCache()
|
||||
OkHttpClient.Builder().apply {
|
||||
connectTimeout(20, TimeUnit.SECONDS)
|
||||
readTimeout(60, TimeUnit.SECONDS)
|
||||
writeTimeout(20, TimeUnit.SECONDS)
|
||||
cookieJar(get())
|
||||
cache(get<LocalStorageManager>().createHttpCache())
|
||||
dns(DoHManager(cache, get()))
|
||||
cache(cache)
|
||||
addInterceptor(UserAgentInterceptor())
|
||||
addInterceptor(CloudFlareInterceptor())
|
||||
if (BuildConfig.DEBUG) {
|
||||
addNetworkInterceptor(CurlLoggingInterceptor())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.os.Build
|
||||
import java.util.*
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import java.util.*
|
||||
|
||||
class UserAgentInterceptor : Interceptor {
|
||||
|
||||
@@ -30,5 +30,14 @@ class UserAgentInterceptor : Interceptor {
|
||||
Build.DEVICE,
|
||||
Locale.getDefault().language
|
||||
)
|
||||
|
||||
val userAgentChrome
|
||||
get() = (
|
||||
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||
).format(
|
||||
Build.VERSION.RELEASE,
|
||||
Build.MODEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import android.content.Context
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.media.ThumbnailUtils
|
||||
import android.os.Build
|
||||
import android.util.Size
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.PixelSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -24,7 +24,7 @@ class ShortcutsRepository(
|
||||
private val context: Context,
|
||||
private val coil: ImageLoader,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val mangaRepository: MangaDataRepository
|
||||
private val mangaRepository: MangaDataRepository,
|
||||
) {
|
||||
|
||||
private val iconSize by lazy {
|
||||
@@ -54,7 +54,7 @@ class ShortcutsRepository(
|
||||
val bmp = coil.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(manga.coverUrl)
|
||||
.size(iconSize)
|
||||
.size(iconSize.width, iconSize.height)
|
||||
.build()
|
||||
).requireBitmap()
|
||||
ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
|
||||
@@ -74,14 +74,14 @@ class ShortcutsRepository(
|
||||
)
|
||||
}
|
||||
|
||||
private fun getIconSize(context: Context): PixelSize {
|
||||
private fun getIconSize(context: Context): Size {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
(context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
|
||||
PixelSize(it.iconMaxWidth, it.iconMaxHeight)
|
||||
Size(it.iconMaxWidth, it.iconMaxHeight)
|
||||
}
|
||||
} else {
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let {
|
||||
PixelSize(it, it)
|
||||
Size(it, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,19 @@ package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.net.Uri
|
||||
import coil.map.Mapper
|
||||
import coil.request.Options
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class FaviconMapper() : Mapper<Uri, HttpUrl> {
|
||||
class FaviconMapper : Mapper<Uri, HttpUrl> {
|
||||
|
||||
override fun map(data: Uri): HttpUrl {
|
||||
override fun map(data: Uri, options: Options): HttpUrl? {
|
||||
if (data.scheme != "favicon") {
|
||||
return null
|
||||
}
|
||||
val mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
|
||||
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
|
||||
return repo.getFaviconUrl().toHttpUrl()
|
||||
}
|
||||
|
||||
override fun handles(data: Uri) = data.scheme == "favicon"
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
@@ -28,11 +30,18 @@ interface MangaRepository {
|
||||
|
||||
companion object : KoinComponent {
|
||||
|
||||
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
|
||||
|
||||
operator fun invoke(source: MangaSource): MangaRepository {
|
||||
return if (source == MangaSource.LOCAL) {
|
||||
get<LocalMangaRepository>()
|
||||
} else {
|
||||
RemoteMangaRepository(source, get())
|
||||
if (source == MangaSource.LOCAL) {
|
||||
return get<LocalMangaRepository>()
|
||||
}
|
||||
cache[source]?.get()?.let { return it }
|
||||
return synchronized(cache) {
|
||||
cache[source]?.get()?.let { return it }
|
||||
val repository = RemoteMangaRepository(MangaParser(source, get()))
|
||||
cache[source] = WeakReference(repository)
|
||||
repository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.*
|
||||
import org.koitharu.kotatsu.parsers.newParser
|
||||
|
||||
class RemoteMangaRepository(
|
||||
override val source: MangaSource,
|
||||
loaderContext: MangaLoaderContext,
|
||||
) : MangaRepository {
|
||||
class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
|
||||
|
||||
private val parser: MangaParser = source.newParser(loaderContext)
|
||||
override val source: MangaSource
|
||||
get() = parser.source
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = parser.sortOrders
|
||||
@@ -28,7 +24,7 @@ class RemoteMangaRepository(
|
||||
offset: Int,
|
||||
query: String?,
|
||||
tags: Set<MangaTag>?,
|
||||
sortOrder: SortOrder?
|
||||
sortOrder: SortOrder?,
|
||||
): List<Manga> = parser.getList(offset, query, tags, sortOrder)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
|
||||
@@ -48,4 +44,4 @@ class RemoteMangaRepository(
|
||||
}
|
||||
|
||||
private fun getConfig() = parser.config as SourceSettings
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import com.google.android.material.color.DynamicColors
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.utils.ext.putEnumValue
|
||||
@@ -28,6 +30,16 @@ class AppSettings(context: Context) {
|
||||
|
||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
|
||||
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
|
||||
remove(MangaSource.LOCAL)
|
||||
if (!BuildConfig.DEBUG) {
|
||||
remove(MangaSource.DUMMY)
|
||||
}
|
||||
}
|
||||
|
||||
val remoteMangaSources: Set<MangaSource>
|
||||
get() = Collections.unmodifiableSet(remoteSources)
|
||||
|
||||
var listMode: ListMode
|
||||
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
|
||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
|
||||
@@ -56,6 +68,10 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||
|
||||
var isAllFavouritesVisible: Boolean
|
||||
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
|
||||
|
||||
val isUpdateCheckingEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
|
||||
|
||||
@@ -63,7 +79,10 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getLong(KEY_APP_UPDATE, 0L)
|
||||
set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
|
||||
|
||||
val trackerNotifications: Boolean
|
||||
val isTrackerEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_ENABLED, true)
|
||||
|
||||
val isTrackerNotificationsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
||||
|
||||
var notificationSound: Uri
|
||||
@@ -80,8 +99,11 @@ class AppSettings(context: Context) {
|
||||
val readerAnimation: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
|
||||
|
||||
val isPreferRtlReader: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_PREFER_RTL, false)
|
||||
val defaultReaderMode: ReaderMode
|
||||
get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD)
|
||||
|
||||
val isReaderModeDetectionEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
|
||||
|
||||
var historyGrouping: Boolean
|
||||
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
|
||||
@@ -104,10 +126,9 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
|
||||
|
||||
var sourcesOrder: List<Int>
|
||||
var sourcesOrder: List<String>
|
||||
get() = prefs.getString(KEY_SOURCES_ORDER, null)
|
||||
?.split('|')
|
||||
?.mapNotNull(String::toIntOrNull)
|
||||
.orEmpty()
|
||||
set(value) = prefs.edit {
|
||||
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
|
||||
@@ -120,6 +141,20 @@ class AppSettings(context: Context) {
|
||||
val isSourcesSelected: Boolean
|
||||
get() = KEY_SOURCES_HIDDEN in prefs
|
||||
|
||||
val newSources: Set<MangaSource>
|
||||
get() {
|
||||
val known = sourcesOrder.toSet()
|
||||
val hidden = hiddenSources
|
||||
return remoteMangaSources
|
||||
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
|
||||
x.name in known || x.name in hidden
|
||||
}
|
||||
}
|
||||
|
||||
fun markKnownSources(sources: Collection<MangaSource>) {
|
||||
sourcesOrder = sourcesOrder + sources.map { it.name }
|
||||
}
|
||||
|
||||
val isPagesNumbersEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||
|
||||
@@ -141,6 +176,12 @@ class AppSettings(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val isDownloadsSlowdownEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
|
||||
|
||||
val downloadsParallelism: Int
|
||||
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
|
||||
|
||||
val isSuggestionsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
||||
|
||||
@@ -151,6 +192,9 @@ class AppSettings(context: Context) {
|
||||
get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) }
|
||||
|
||||
val dnsOverHttps: DoHProvider
|
||||
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
|
||||
|
||||
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
|
||||
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
|
||||
NETWORK_ALWAYS -> true
|
||||
@@ -165,12 +209,23 @@ class AppSettings(context: Context) {
|
||||
else -> SimpleDateFormat(format, Locale.getDefault())
|
||||
}
|
||||
|
||||
fun getSuggestionsTagsBlacklistRegex(): Regex? {
|
||||
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
|
||||
if (string.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val tags = string.split(',')
|
||||
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
|
||||
Regex.escape(tag.trim())
|
||||
}
|
||||
return Regex(regex, RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
||||
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
||||
val list = MangaSource.values().toMutableList()
|
||||
list.remove(MangaSource.LOCAL)
|
||||
val list = remoteSources.toMutableList()
|
||||
val order = sourcesOrder
|
||||
list.sortBy { x ->
|
||||
val e = order.indexOf(x.ordinal)
|
||||
val e = order.indexOf(x.name)
|
||||
if (e == -1) order.size + x.ordinal else e
|
||||
}
|
||||
if (!includeHidden) {
|
||||
@@ -212,7 +267,7 @@ class AppSettings(context: Context) {
|
||||
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||
const val KEY_DATE_FORMAT = "date_format"
|
||||
const val KEY_SOURCES_ORDER = "sources_order"
|
||||
const val KEY_SOURCES_ORDER = "sources_order_2"
|
||||
const val KEY_SOURCES_HIDDEN = "sources_hidden"
|
||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||
@@ -224,15 +279,19 @@ class AppSettings(context: Context) {
|
||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||
const val KEY_READER_SWITCHERS = "reader_switchers"
|
||||
const val KEY_TRACKER_ENABLED = "tracker_enabled"
|
||||
const val KEY_TRACK_SOURCES = "track_sources"
|
||||
const val KEY_TRACK_CATEGORIES = "track_categories"
|
||||
const val KEY_TRACK_WARNING = "track_warning"
|
||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
|
||||
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
|
||||
const val KEY_READER_ANIMATION = "reader_animation"
|
||||
const val KEY_READER_PREFER_RTL = "reader_prefer_rtl"
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||
const val KEY_APP_PASSWORD = "app_password"
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
@@ -247,17 +306,20 @@ class AppSettings(context: Context) {
|
||||
const val KEY_PAGES_PRELOAD = "pages_preload"
|
||||
const val KEY_SUGGESTIONS = "suggestions"
|
||||
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
||||
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
||||
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"
|
||||
const val KEY_DOH = "doh"
|
||||
|
||||
// About
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
const val KEY_APP_GRATITUDES = "about_gratitudes"
|
||||
const val KEY_FEEDBACK_4PDA = "about_feedback_4pda"
|
||||
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
|
||||
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
|
||||
const val KEY_SUPPORT_DEVELOPER = "about_support_developer"
|
||||
|
||||
private const val NETWORK_NEVER = 0
|
||||
private const val NETWORK_ALWAYS = 1
|
||||
@@ -270,4 +332,4 @@ class AppSettings(context: Context) {
|
||||
private val isSamsung
|
||||
get() = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import androidx.lifecycle.liveData
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
|
||||
var lastValue: T = valueProducer()
|
||||
emit(lastValue)
|
||||
observe().collect {
|
||||
if (it == key) {
|
||||
val value = valueProducer()
|
||||
if (value != lastValue) {
|
||||
emit(value)
|
||||
}
|
||||
lastValue = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> AppSettings.observeAsLiveData(
|
||||
context: CoroutineContext,
|
||||
key: String,
|
||||
valueProducer: AppSettings.() -> T
|
||||
) = liveData(context) {
|
||||
emit(valueProducer())
|
||||
observe().collect {
|
||||
if (it == key) {
|
||||
val value = valueProducer()
|
||||
if (value != latestValue) {
|
||||
emit(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,4 @@ enum class ReaderMode(val id: Int) {
|
||||
|
||||
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import kotlin.system.exitProcess
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
|
||||
class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
|
||||
|
||||
@@ -13,7 +14,7 @@ class AppCrashHandler(private val applicationContext: Context) : Thread.Uncaught
|
||||
try {
|
||||
applicationContext.startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
t.printStackTraceDebug()
|
||||
}
|
||||
Log.e("CRASH", e.message, e)
|
||||
exitProcess(1)
|
||||
|
||||
@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.core.ui
|
||||
import android.content.res.Resources
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import java.util.*
|
||||
|
||||
sealed class DateTimeAgo : ListModel {
|
||||
|
||||
@@ -72,9 +75,33 @@ sealed class DateTimeAgo : ListModel {
|
||||
override fun hashCode(): Int = days
|
||||
}
|
||||
|
||||
class Absolute(private val date: Date) : DateTimeAgo() {
|
||||
|
||||
private val day = date.daysDiff(0)
|
||||
|
||||
override fun format(resources: Resources): String {
|
||||
return date.format("d MMMM")
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Absolute
|
||||
|
||||
if (day != other.day) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return day
|
||||
}
|
||||
}
|
||||
|
||||
object LongAgo : DateTimeAgo() {
|
||||
override fun format(resources: Resources): String {
|
||||
return resources.getString(R.string.long_ago)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import coil.ComponentRegistry
|
||||
import coil.ImageLoader
|
||||
import coil.util.CoilUtils
|
||||
import coil.disk.DiskCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.parser.FaviconMapper
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||
|
||||
val uiModule
|
||||
@@ -14,15 +16,23 @@ val uiModule
|
||||
single {
|
||||
val httpClientFactory = {
|
||||
get<OkHttpClient>().newBuilder()
|
||||
.cache(CoilUtils.createDefaultCache(androidContext()))
|
||||
.cache(null)
|
||||
.build()
|
||||
}
|
||||
val diskCacheFactory = {
|
||||
val context = androidContext()
|
||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||
DiskCache.Builder()
|
||||
.directory(rootDir.resolve(CacheDir.THUMBS.dir))
|
||||
.build()
|
||||
}
|
||||
ImageLoader.Builder(androidContext())
|
||||
.okHttpClient(httpClientFactory)
|
||||
.launchInterceptorChainOnMainThread(false)
|
||||
.componentRegistry(
|
||||
.interceptorDispatcher(Dispatchers.Default)
|
||||
.diskCache(diskCacheFactory)
|
||||
.components(
|
||||
ComponentRegistry.Builder()
|
||||
.add(CbzFetcher())
|
||||
.add(CbzFetcher.Factory())
|
||||
.add(FaviconMapper())
|
||||
.build()
|
||||
).build()
|
||||
|
||||
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
118
app/src/main/java/org/koitharu/kotatsu/core/zip/ZipOutput.kt
Normal file
@@ -0,0 +1,118 @@
|
||||
package org.koitharu.kotatsu.core.zip
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
import okio.Closeable
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class ZipOutput(
|
||||
val file: File,
|
||||
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
|
||||
) : Closeable {
|
||||
|
||||
private val entryNames = ArraySet<String>()
|
||||
private var isClosed = false
|
||||
private val output = ZipOutputStream(file.outputStream()).apply {
|
||||
setLevel(compressionLevel)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun put(name: String, file: File): Boolean {
|
||||
return output.appendFile(file, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun put(name: String, content: String): Boolean {
|
||||
return output.appendText(content, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun addDirectory(name: String): Boolean {
|
||||
val entry = if (name.endsWith("/")) {
|
||||
ZipEntry(name)
|
||||
} else {
|
||||
ZipEntry("$name/")
|
||||
}
|
||||
return if (entryNames.add(entry.name)) {
|
||||
output.putNextEntry(entry)
|
||||
output.closeEntry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
|
||||
return if (entryNames.add(entry.name)) {
|
||||
val zipEntry = ZipEntry(entry.name)
|
||||
output.putNextEntry(zipEntry)
|
||||
other.getInputStream(entry).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
output.closeEntry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
output.finish()
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
output.close()
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
|
||||
if (fileToZip.isDirectory) {
|
||||
val entry = if (name.endsWith("/")) {
|
||||
ZipEntry(name)
|
||||
} else {
|
||||
ZipEntry("$name/")
|
||||
}
|
||||
if (!entryNames.add(entry.name)) {
|
||||
return false
|
||||
}
|
||||
putNextEntry(entry)
|
||||
closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
appendFile(childFile, "$name/${childFile.name}")
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
if (!entryNames.add(name)) {
|
||||
return false
|
||||
}
|
||||
val zipEntry = ZipEntry(name)
|
||||
putNextEntry(zipEntry)
|
||||
fis.copyTo(this)
|
||||
closeEntry()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
|
||||
if (!entryNames.add(name)) {
|
||||
return false
|
||||
}
|
||||
val zipEntry = ZipEntry(name)
|
||||
putNextEntry(zipEntry)
|
||||
content.byteInputStream().copyTo(this)
|
||||
closeEntry()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,6 @@ val detailsModule
|
||||
get() = module {
|
||||
|
||||
viewModel { intent ->
|
||||
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get())
|
||||
DetailsViewModel(intent.get(), get(), get(), get(), get(), get(), get(), get())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.details.domain
|
||||
|
||||
class BranchComparator : Comparator<String?> {
|
||||
|
||||
override fun compare(o1: String?, o2: String?): Int = compareValues(o1, o2)
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
@@ -67,8 +68,8 @@ class ChaptersFragment :
|
||||
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
viewModel.hasChapters.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isGone = it
|
||||
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||
binding.textViewHolder.isVisible = it
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
@@ -94,7 +95,7 @@ class ChaptersFragment :
|
||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||
super.onPrepareOptionsMenu(menu)
|
||||
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
|
||||
menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true
|
||||
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
@@ -120,13 +121,7 @@ class ChaptersFragment :
|
||||
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
|
||||
return
|
||||
}
|
||||
val options = ActivityOptions.makeScaleUpAnimation(
|
||||
view,
|
||||
0,
|
||||
0,
|
||||
view.measuredWidth,
|
||||
view.measuredHeight
|
||||
)
|
||||
val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
context = view.context,
|
||||
@@ -154,11 +149,29 @@ class ChaptersFragment :
|
||||
DownloadService.start(
|
||||
context ?: return false,
|
||||
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
|
||||
selectionDecoration?.checkedItemsIds
|
||||
selectionDecoration?.checkedItemsIds?.toSet()
|
||||
)
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
val ids = selectionDecoration?.checkedItemsIds
|
||||
val manga = viewModel.manga.value
|
||||
when {
|
||||
ids.isNullOrEmpty() || manga == null -> Unit
|
||||
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||
else -> {
|
||||
LocalChaptersRemoveService.start(requireContext(), manga, ids)
|
||||
Snackbar.make(
|
||||
binding.recyclerViewChapters,
|
||||
R.string.chapters_will_removed_background,
|
||||
Snackbar.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.action_select_all -> {
|
||||
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
|
||||
selectionDecoration?.checkAll(ids)
|
||||
@@ -178,9 +191,7 @@ class ChaptersFragment :
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
val manga = viewModel.manga.value
|
||||
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||
mode.title = manga?.title
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -190,12 +201,10 @@ class ChaptersFragment :
|
||||
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
||||
x.chapter.source == MangaSource.LOCAL
|
||||
}
|
||||
mode.subtitle = resources.getQuantityString(
|
||||
R.plurals.chapters_from_x,
|
||||
items.size,
|
||||
items.size,
|
||||
chaptersAdapter?.itemCount ?: 0
|
||||
)
|
||||
menu.findItem(R.id.action_delete).isVisible = items.all { x ->
|
||||
x.chapter.source == MangaSource.LOCAL
|
||||
}
|
||||
mode.title = items.size.toString()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
@@ -17,6 +16,7 @@ import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -41,13 +41,16 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
|
||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
|
||||
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy,
|
||||
class DetailsActivity :
|
||||
BaseActivity<ActivityDetailsBinding>(),
|
||||
TabLayoutMediator.TabConfigurationStrategy,
|
||||
AdapterView.OnItemSelectedListener {
|
||||
|
||||
private val viewModel by viewModel<DetailsViewModel> {
|
||||
@@ -80,6 +83,9 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
|
||||
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
|
||||
viewModel.onError.observe(this, ::onError)
|
||||
viewModel.onShowToast.observe(this) {
|
||||
binding.snackbar.show(messageText = getString(it), longDuration = false)
|
||||
}
|
||||
|
||||
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
|
||||
}
|
||||
@@ -163,7 +169,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
R.id.action_share -> {
|
||||
viewModel.manga.value?.let {
|
||||
if (it.source == MangaSource.LOCAL) {
|
||||
ShareHelper(this).shareCbz(Uri.parse(it.url).toFile())
|
||||
ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
|
||||
} else {
|
||||
ShareHelper(this).shareMangaLink(it)
|
||||
}
|
||||
@@ -171,38 +177,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
true
|
||||
}
|
||||
R.id.action_delete -> {
|
||||
viewModel.manga.value?.let { m ->
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_manga)
|
||||
.setMessage(getString(R.string.text_delete_local_manga, m.title))
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.deleteLocal(m)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
val title = viewModel.manga.value?.title.orEmpty()
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.delete_manga)
|
||||
.setMessage(getString(R.string.text_delete_local_manga, title))
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
viewModel.deleteLocal()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
true
|
||||
}
|
||||
R.id.action_save -> {
|
||||
viewModel.manga.value?.let {
|
||||
val chaptersCount = it.chapters?.size ?: 0
|
||||
if (chaptersCount > 5) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.large_manga_save_confirm,
|
||||
resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
chaptersCount,
|
||||
chaptersCount
|
||||
)
|
||||
)
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
DownloadService.start(this, it)
|
||||
}.show()
|
||||
val branches = viewModel.branches.value.orEmpty()
|
||||
if (chaptersCount > 5 || branches.size > 1) {
|
||||
showSaveConfirmation(it, chaptersCount, branches)
|
||||
} else {
|
||||
DownloadService.start(this, it)
|
||||
}
|
||||
@@ -262,7 +253,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
fun showChapterMissingDialog(chapterId: Long) {
|
||||
val remoteManga = viewModel.getRemoteManga()
|
||||
if (remoteManga == null) {
|
||||
binding.snackbar.show(getString( R.string.chapter_is_missing))
|
||||
binding.snackbar.show(getString(R.string.chapter_is_missing))
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(this).apply {
|
||||
@@ -328,11 +319,41 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) {
|
||||
val dialogBuilder = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.save_manga)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
if (branches.size > 1) {
|
||||
val items = Array(branches.size) { i -> branches[i].orEmpty() }
|
||||
val currentBranch = viewModel.selectedBranchIndex.value ?: -1
|
||||
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
|
||||
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
|
||||
checkedIndices[i] = checked
|
||||
}.setPositiveButton(R.string.save) { _, _ ->
|
||||
val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] }
|
||||
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
|
||||
if (c.branch in selectedBranches) c.id else null
|
||||
}
|
||||
DownloadService.start(this, manga, chaptersIds)
|
||||
}
|
||||
} else {
|
||||
dialogBuilder.setMessage(
|
||||
getString(
|
||||
R.string.large_manga_save_confirm,
|
||||
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
|
||||
)
|
||||
).setPositiveButton(R.string.save) { _, _ ->
|
||||
DownloadService.start(this, manga)
|
||||
}
|
||||
}
|
||||
dialogBuilder.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context, manga: Manga): Intent {
|
||||
return Intent(context, DetailsActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
|
||||
}
|
||||
|
||||
fun newIntent(context: Context, mangaId: Long): Intent {
|
||||
@@ -340,4 +361,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import android.os.Bundle
|
||||
import android.text.Spanned
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.*
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
@@ -20,10 +22,14 @@ import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.BookmarksAdapter
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesDialog
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
|
||||
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -40,7 +46,8 @@ class DetailsFragment :
|
||||
BaseFragment<FragmentDetailsBinding>(),
|
||||
View.OnClickListener,
|
||||
View.OnLongClickListener,
|
||||
ChipsView.OnChipClickListener {
|
||||
ChipsView.OnChipClickListener,
|
||||
OnListItemClickListener<Bookmark> {
|
||||
|
||||
private val viewModel by sharedViewModel<DetailsViewModel>()
|
||||
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
|
||||
@@ -68,6 +75,7 @@ class DetailsFragment :
|
||||
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
|
||||
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
|
||||
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
|
||||
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
@@ -75,6 +83,24 @@ class DetailsFragment :
|
||||
inflater.inflate(R.menu.opt_details_info, menu)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
|
||||
startActivity(ReaderActivity.newIntent(view.context, item), options.toBundle())
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
|
||||
val menu = PopupMenu(view.context, view)
|
||||
menu.inflate(R.menu.popup_bookmark)
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_remove -> viewModel.removeBookmark(item)
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onMangaUpdated(manga: Manga) {
|
||||
with(binding) {
|
||||
// Main
|
||||
@@ -175,11 +201,25 @@ class DetailsFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBookmarksChanged(bookmarks: List<Bookmark>) {
|
||||
var adapter = binding.recyclerViewBookmarks.adapter as? BookmarksAdapter
|
||||
binding.groupBookmarks.isGone = bookmarks.isEmpty()
|
||||
if (adapter != null) {
|
||||
adapter.items = bookmarks
|
||||
} else {
|
||||
adapter = BookmarksAdapter(coil, viewLifecycleOwner, this)
|
||||
adapter.items = bookmarks
|
||||
binding.recyclerViewBookmarks.adapter = adapter
|
||||
val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing)
|
||||
binding.recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
when (v.id) {
|
||||
R.id.button_favorite -> {
|
||||
FavouriteCategoriesDialog.show(childFragmentManager, manga)
|
||||
FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
|
||||
}
|
||||
R.id.button_read -> {
|
||||
val chapterId = viewModel.readingHistory.value?.chapterId
|
||||
@@ -205,13 +245,9 @@ class DetailsFragment :
|
||||
)
|
||||
}
|
||||
R.id.imageView_cover -> {
|
||||
val options = ActivityOptions.makeSceneTransitionAnimation(
|
||||
requireActivity(),
|
||||
binding.imageViewCover,
|
||||
binding.imageViewCover.transitionName,
|
||||
)
|
||||
val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
|
||||
startActivity(
|
||||
ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl),
|
||||
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
|
||||
options.toBundle()
|
||||
)
|
||||
}
|
||||
@@ -224,14 +260,16 @@ class DetailsFragment :
|
||||
if (viewModel.readingHistory.value == null) {
|
||||
return false
|
||||
}
|
||||
v.showPopupMenu(R.menu.popup_read) {
|
||||
when (it.itemId) {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
menu.inflate(R.menu.popup_read)
|
||||
menu.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_read -> {
|
||||
val branch = viewModel.selectedBranchValue
|
||||
startActivity(
|
||||
ReaderActivity.newIntent(
|
||||
context = context ?: return@showPopupMenu false,
|
||||
manga = viewModel.manga.value ?: return@showPopupMenu false,
|
||||
context = context ?: return@setOnMenuItemClickListener false,
|
||||
manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
|
||||
state = viewModel.chapters.value?.firstOrNull { c ->
|
||||
c.chapter.branch == branch
|
||||
}?.let { c ->
|
||||
@@ -244,6 +282,7 @@ class DetailsFragment :
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
menu.show()
|
||||
return true
|
||||
}
|
||||
else -> return false
|
||||
@@ -274,20 +313,20 @@ class DetailsFragment :
|
||||
}
|
||||
|
||||
private fun loadCover(manga: Manga) {
|
||||
val currentCover = binding.imageViewCover.drawable
|
||||
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
|
||||
val lastResult = CoilUtils.result(binding.imageViewCover)
|
||||
if (lastResult?.request?.data == imageUrl) {
|
||||
return
|
||||
}
|
||||
val request = ImageRequest.Builder(context ?: return)
|
||||
.target(binding.imageViewCover)
|
||||
if (currentCover != null) {
|
||||
request.data(manga.largeCoverUrl ?: return)
|
||||
.placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey)
|
||||
.fallback(currentCover)
|
||||
} else {
|
||||
request.crossfade(true)
|
||||
.data(manga.coverUrl)
|
||||
.fallback(R.drawable.ic_placeholder)
|
||||
}
|
||||
request.referer(manga.publicUrl)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.referer(manga.publicUrl)
|
||||
.lifecycle(viewLifecycleOwner)
|
||||
.enqueueWith(coil)
|
||||
lastResult?.drawable?.let {
|
||||
request.fallback(it)
|
||||
} ?: request.fallback(R.drawable.ic_placeholder)
|
||||
request.enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,104 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import androidx.lifecycle.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||
import org.koitharu.kotatsu.base.domain.MangaIntent
|
||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.details.domain.BranchComparator
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||
import org.koitharu.kotatsu.utils.ext.iterator
|
||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
||||
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import java.io.IOException
|
||||
|
||||
class DetailsViewModel(
|
||||
private val intent: MangaIntent,
|
||||
intent: MangaIntent,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
favouritesRepository: FavouritesRepository,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
mangaDataRepository: MangaDataRepository,
|
||||
private val bookmarksRepository: BookmarksRepository,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val delegate = MangaDetailsDelegate(
|
||||
intent = intent,
|
||||
settings = settings,
|
||||
mangaDataRepository = mangaDataRepository,
|
||||
historyRepository = historyRepository,
|
||||
localMangaRepository = localMangaRepository,
|
||||
)
|
||||
|
||||
private var loadingJob: Job
|
||||
private val mangaData = MutableStateFlow(intent.manga)
|
||||
private val selectedBranch = MutableStateFlow<String?>(null)
|
||||
|
||||
private val history = mangaData.mapNotNull { it?.id }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { mangaId ->
|
||||
historyRepository.observeOne(mangaId)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
val onShowToast = SingleLiveEvent<Int>()
|
||||
|
||||
private val favourite = mangaData.mapNotNull { it?.id }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { mangaId ->
|
||||
favouritesRepository.observeCategoriesIds(mangaId).map { it.isNotEmpty() }
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
private val history = historyRepository.observeOne(delegate.mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
private val newChapters = mangaData.mapNotNull { it?.id }
|
||||
.distinctUntilChanged()
|
||||
.mapLatest { mangaId ->
|
||||
trackingRepository.getNewChaptersCount(mangaId)
|
||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, 0)
|
||||
|
||||
// Remote manga for saved and saved for remote
|
||||
private val relatedManga = MutableStateFlow<Manga?>(null)
|
||||
private val chaptersQuery = MutableStateFlow("")
|
||||
|
||||
private val chaptersReversed = settings.observe()
|
||||
.filter { it == AppSettings.KEY_REVERSE_CHAPTERS }
|
||||
.map { settings.chaptersReverse }
|
||||
.onStart { emit(settings.chaptersReverse) }
|
||||
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
|
||||
val manga = mangaData.filterNotNull()
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
val favouriteCategories = favourite
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
val newChaptersCount = newChapters
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
val readingHistory = history
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
val isChaptersReversed = chaptersReversed
|
||||
.asLiveData(viewModelScope.coroutineContext)
|
||||
private val newChapters = viewModelScope.async(Dispatchers.Default) {
|
||||
trackingRepository.getNewChaptersCount(delegate.mangaId)
|
||||
}
|
||||
|
||||
private val chaptersQuery = MutableStateFlow("")
|
||||
|
||||
private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
|
||||
val manga = delegate.manga.filterNotNull().asLiveData(viewModelScope.coroutineContext)
|
||||
val favouriteCategories = favourite.asLiveData(viewModelScope.coroutineContext)
|
||||
val newChaptersCount = liveData(viewModelScope.coroutineContext) { emit(newChapters.await()) }
|
||||
val readingHistory = history.asLiveData(viewModelScope.coroutineContext)
|
||||
val isChaptersReversed = chaptersReversed.asLiveData(viewModelScope.coroutineContext)
|
||||
|
||||
val bookmarks = delegate.manga.flatMapLatest {
|
||||
if (it != null) bookmarksRepository.observeBookmarks(it) else flowOf(emptyList())
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val onMangaRemoved = SingleLiveEvent<Manga>()
|
||||
|
||||
val branches = mangaData.map {
|
||||
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
val branches: LiveData<List<String?>> = delegate.manga.map {
|
||||
val chapters = it?.chapters ?: return@map emptyList()
|
||||
chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val selectedBranchIndex = combine(
|
||||
branches.asFlow(),
|
||||
selectedBranch
|
||||
delegate.selectedBranch
|
||||
) { branches, selected ->
|
||||
branches.indexOf(selected)
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val hasChapters = mangaData.map {
|
||||
!(it?.chapters.isNullOrEmpty())
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
val isChaptersEmpty: LiveData<Boolean> = delegate.manga.map { m ->
|
||||
m != null && m.chapters.isNullOrEmpty()
|
||||
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
|
||||
|
||||
val chapters = combine(
|
||||
combine(
|
||||
mangaData.map { it?.chapters.orEmpty() },
|
||||
relatedManga,
|
||||
history.map { it?.chapterId },
|
||||
newChapters,
|
||||
selectedBranch
|
||||
) { chapters, related, currentId, newCount, branch ->
|
||||
val relatedChapters = related?.chapters
|
||||
if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
|
||||
mapChaptersWithSource(chapters, relatedChapters, currentId, newCount, branch)
|
||||
} else {
|
||||
mapChapters(chapters, relatedChapters, currentId, newCount, branch)
|
||||
}
|
||||
delegate.manga,
|
||||
delegate.relatedManga,
|
||||
history,
|
||||
delegate.selectedBranch,
|
||||
) { manga, related, history, branch ->
|
||||
delegate.mapChapters(manga, related, history, newChapters.await(), branch)
|
||||
},
|
||||
chaptersReversed,
|
||||
chaptersQuery,
|
||||
@@ -123,7 +107,7 @@ class DetailsViewModel(
|
||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||
|
||||
val selectedBranchValue: String?
|
||||
get() = selectedBranch.value
|
||||
get() = delegate.selectedBranch.value
|
||||
|
||||
init {
|
||||
loadingJob = doLoad()
|
||||
@@ -134,8 +118,15 @@ class DetailsViewModel(
|
||||
loadingJob = doLoad()
|
||||
}
|
||||
|
||||
fun deleteLocal(manga: Manga) {
|
||||
fun deleteLocal() {
|
||||
val m = delegate.manga.value
|
||||
if (m == null) {
|
||||
onShowToast.call(R.string.file_not_found)
|
||||
return
|
||||
}
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
|
||||
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
|
||||
val original = localMangaRepository.getRemoteManga(manga)
|
||||
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
||||
runCatching {
|
||||
@@ -145,16 +136,23 @@ class DetailsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeBookmark(bookmark: Bookmark) {
|
||||
launchJob {
|
||||
bookmarksRepository.removeBookmark(bookmark.manga.id, bookmark.pageId)
|
||||
onShowToast.call(R.string.bookmark_removed)
|
||||
}
|
||||
}
|
||||
|
||||
fun setChaptersReversed(newValue: Boolean) {
|
||||
settings.chaptersReverse = newValue
|
||||
}
|
||||
|
||||
fun setSelectedBranch(branch: String?) {
|
||||
selectedBranch.value = branch
|
||||
delegate.selectedBranch.value = branch
|
||||
}
|
||||
|
||||
fun getRemoteManga(): Manga? {
|
||||
return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
|
||||
return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
|
||||
}
|
||||
|
||||
fun performChapterSearch(query: String?) {
|
||||
@@ -162,7 +160,7 @@ class DetailsViewModel(
|
||||
}
|
||||
|
||||
fun onDownloadComplete(downloadedManga: Manga) {
|
||||
val currentManga = mangaData.value ?: return
|
||||
val currentManga = delegate.manga.value ?: return
|
||||
if (currentManga.id != downloadedManga.id) {
|
||||
return
|
||||
}
|
||||
@@ -173,135 +171,16 @@ class DetailsViewModel(
|
||||
runCatching {
|
||||
localMangaRepository.getDetails(downloadedManga)
|
||||
}.onSuccess {
|
||||
relatedManga.value = it
|
||||
delegate.relatedManga.value = it
|
||||
}.onFailure {
|
||||
if (BuildConfig.DEBUG) {
|
||||
it.printStackTrace()
|
||||
}
|
||||
it.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
|
||||
var manga = mangaDataRepository.resolveIntent(intent)
|
||||
?: throw MangaNotFoundException("Cannot find manga")
|
||||
mangaData.value = manga
|
||||
manga = MangaRepository(manga.source).getDetails(manga)
|
||||
// find default branch
|
||||
val hist = historyRepository.getOne(manga)
|
||||
selectedBranch.value = if (hist != null) {
|
||||
manga.chapters?.find { it.id == hist.chapterId }?.branch
|
||||
} else {
|
||||
predictBranch(manga.chapters)
|
||||
}
|
||||
mangaData.value = manga
|
||||
relatedManga.value = runCatching {
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
|
||||
MangaRepository(m.source).getDetails(m)
|
||||
} else {
|
||||
localMangaRepository.findSavedManga(manga)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun mapChapters(
|
||||
chapters: List<MangaChapter>,
|
||||
downloadedChapters: List<MangaChapter>?,
|
||||
currentId: Long?,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val result = ArrayList<ChapterListItem>(chapters.size)
|
||||
val dateFormat = settings.getDateFormat()
|
||||
val currentIndex = chapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = chapters.size - newCount
|
||||
val downloadedIds = downloadedChapters?.mapToSet { it.id }
|
||||
for (i in chapters.indices) {
|
||||
val chapter = chapters[i]
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
result += chapter.toListItem(
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = false,
|
||||
isDownloaded = downloadedIds?.contains(chapter.id) == true,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun mapChaptersWithSource(
|
||||
chapters: List<MangaChapter>,
|
||||
sourceChapters: List<MangaChapter>,
|
||||
currentId: Long?,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
|
||||
val result = ArrayList<ChapterListItem>(sourceChapters.size)
|
||||
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = sourceChapters.size - newCount
|
||||
val dateFormat = settings.getDateFormat()
|
||||
for (i in sourceChapters.indices) {
|
||||
val chapter = sourceChapters[i]
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
val localChapter = chaptersMap.remove(chapter.id)
|
||||
result += localChapter?.toListItem(
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
) ?: chapter.toListItem(
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = true,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
}
|
||||
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
||||
result.ensureCapacity(result.size + chaptersMap.size)
|
||||
chaptersMap.values.mapTo(result) {
|
||||
it.toListItem(
|
||||
isCurrent = false,
|
||||
isUnread = true,
|
||||
isNew = false,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
}
|
||||
result.sortBy { it.chapter.number }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun predictBranch(chapters: List<MangaChapter>?): String? {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val groups = chapters.groupBy { it.branch }
|
||||
for (locale in LocaleListCompat.getAdjustedDefault()) {
|
||||
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
language = locale.getDisplayName(locale).toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
}
|
||||
return groups.maxByOrNull { it.value.size }?.key
|
||||
delegate.doLoad()
|
||||
}
|
||||
|
||||
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||
import org.koitharu.kotatsu.base.domain.MangaIntent
|
||||
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||
import org.koitharu.kotatsu.utils.ext.iterator
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
|
||||
class MangaDetailsDelegate(
|
||||
private val intent: MangaIntent,
|
||||
private val settings: AppSettings,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val localMangaRepository: LocalMangaRepository,
|
||||
) {
|
||||
|
||||
private val mangaData = MutableStateFlow(intent.manga)
|
||||
|
||||
val selectedBranch = MutableStateFlow<String?>(null)
|
||||
// Remote manga for saved and saved for remote
|
||||
val relatedManga = MutableStateFlow<Manga?>(null)
|
||||
val manga: StateFlow<Manga?>
|
||||
get() = mangaData
|
||||
val mangaId = intent.manga?.id ?: intent.mangaId
|
||||
|
||||
suspend fun doLoad() {
|
||||
var manga = mangaDataRepository.resolveIntent(intent)
|
||||
?: throw MangaNotFoundException("Cannot find manga")
|
||||
mangaData.value = manga
|
||||
manga = MangaRepository(manga.source).getDetails(manga)
|
||||
// find default branch
|
||||
val hist = historyRepository.getOne(manga)
|
||||
selectedBranch.value = if (hist != null) {
|
||||
val currentChapter = manga.chapters?.find { it.id == hist.chapterId }
|
||||
if (currentChapter != null) currentChapter.branch else predictBranch(manga.chapters)
|
||||
} else {
|
||||
predictBranch(manga.chapters)
|
||||
}
|
||||
mangaData.value = manga
|
||||
relatedManga.value = runCatching {
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
val m = localMangaRepository.getRemoteManga(manga) ?: return@runCatching null
|
||||
MangaRepository(m.source).getDetails(m)
|
||||
} else {
|
||||
localMangaRepository.findSavedManga(manga)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
error.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun mapChapters(
|
||||
manga: Manga?,
|
||||
related: Manga?,
|
||||
history: MangaHistory?,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val chapters = manga?.chapters ?: return emptyList()
|
||||
val relatedChapters = related?.chapters
|
||||
return if (related?.source != MangaSource.LOCAL && !relatedChapters.isNullOrEmpty()) {
|
||||
mapChaptersWithSource(chapters, relatedChapters, history?.chapterId, newCount, branch)
|
||||
} else {
|
||||
mapChapters(chapters, relatedChapters, history?.chapterId, newCount, branch)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapChapters(
|
||||
chapters: List<MangaChapter>,
|
||||
downloadedChapters: List<MangaChapter>?,
|
||||
currentId: Long?,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val result = ArrayList<ChapterListItem>(chapters.size)
|
||||
val dateFormat = settings.getDateFormat()
|
||||
val currentIndex = chapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = chapters.size - newCount
|
||||
val downloadedIds = downloadedChapters?.mapToSet { it.id }
|
||||
for (i in chapters.indices) {
|
||||
val chapter = chapters[i]
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
result += chapter.toListItem(
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = false,
|
||||
isDownloaded = downloadedIds?.contains(chapter.id) == true,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun mapChaptersWithSource(
|
||||
chapters: List<MangaChapter>,
|
||||
sourceChapters: List<MangaChapter>,
|
||||
currentId: Long?,
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
): List<ChapterListItem> {
|
||||
val chaptersMap = chapters.associateByTo(HashMap(chapters.size)) { it.id }
|
||||
val result = ArrayList<ChapterListItem>(sourceChapters.size)
|
||||
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
|
||||
val firstNewIndex = sourceChapters.size - newCount
|
||||
val dateFormat = settings.getDateFormat()
|
||||
for (i in sourceChapters.indices) {
|
||||
val chapter = sourceChapters[i]
|
||||
val localChapter = chaptersMap.remove(chapter.id)
|
||||
if (chapter.branch != branch) {
|
||||
continue
|
||||
}
|
||||
result += localChapter?.toListItem(
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
) ?: chapter.toListItem(
|
||||
isCurrent = i == currentIndex,
|
||||
isUnread = i > currentIndex,
|
||||
isNew = i >= firstNewIndex,
|
||||
isMissing = true,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
}
|
||||
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
|
||||
result.ensureCapacity(result.size + chaptersMap.size)
|
||||
chaptersMap.values.mapNotNullTo(result) {
|
||||
if (it.branch == branch) {
|
||||
it.toListItem(
|
||||
isCurrent = false,
|
||||
isUnread = true,
|
||||
isNew = false,
|
||||
isMissing = false,
|
||||
isDownloaded = false,
|
||||
dateFormat = dateFormat,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
result.sortBy { it.chapter.number }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun predictBranch(chapters: List<MangaChapter>?): String? {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val groups = chapters.groupBy { it.branch }
|
||||
for (locale in LocaleListCompat.getAdjustedDefault()) {
|
||||
var language = locale.getDisplayLanguage(locale).toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
language = locale.getDisplayName(locale).toTitleCase(locale)
|
||||
if (groups.containsKey(language)) {
|
||||
return language
|
||||
}
|
||||
}
|
||||
return groups.maxByOrNull { it.value.size }?.key
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.TextView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.ext.replaceWith
|
||||
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||
|
||||
class BranchesAdapter : BaseAdapter() {
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.koitharu.kotatsu.details.ui.adapter
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.databinding.ItemChapterBinding
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
@@ -21,11 +21,7 @@ fun chapterListItemAD(
|
||||
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) }
|
||||
) {
|
||||
|
||||
val eventListener = object : View.OnClickListener, View.OnLongClickListener {
|
||||
override fun onClick(v: View) = clickListener.onItemClick(item, v)
|
||||
override fun onLongClick(v: View) = clickListener.onItemLongClick(item, v)
|
||||
}
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
itemView.setOnClickListener(eventListener)
|
||||
itemView.setOnLongClickListener(eventListener)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user