Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2820ec11 | ||
|
|
312fb033e0 | ||
|
|
18bc4dc739 | ||
|
|
2b61b27271 | ||
|
|
58c9f75b91 | ||
|
|
790f1fb8a3 | ||
|
|
5c4f3f7fe4 | ||
|
|
86ead09080 | ||
|
|
0932507346 | ||
|
|
21f7b7120a | ||
|
|
473135bfc5 | ||
|
|
ce7960e5e9 | ||
|
|
17c440ee43 | ||
|
|
5d881ca154 | ||
|
|
e4b29b3ff9 | ||
|
|
046aaa0649 | ||
|
|
f653c74ce8 | ||
|
|
0c73c55b9d | ||
|
|
8dec54e96f | ||
|
|
859ae966c8 | ||
|
|
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
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,9 +6,12 @@
|
|||||||
/.idea/dictionaries
|
/.idea/dictionaries
|
||||||
/.idea/modules.xml
|
/.idea/modules.xml
|
||||||
/.idea/misc.xml
|
/.idea/misc.xml
|
||||||
|
/.idea/discord.xml
|
||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
|
/.idea/kotlinScripting.xml
|
||||||
|
/.idea/deploymentTargetDropDown.xml
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
|
|||||||
17
.idea/deploymentTargetDropDown.xml
generated
17
.idea/deploymentTargetDropDown.xml
generated
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="deploymentTargetDropDown">
|
|
||||||
<targetSelectedWithDropDown>
|
|
||||||
<Target>
|
|
||||||
<type value="QUICK_BOOT_TARGET" />
|
|
||||||
<deviceKey>
|
|
||||||
<Key>
|
|
||||||
<type value="VIRTUAL_DEVICE_PATH" />
|
|
||||||
<value value="$USER_HOME$/.android/avd/Pixel_API_S.avd" />
|
|
||||||
</Key>
|
|
||||||
</deviceKey>
|
|
||||||
</Target>
|
|
||||||
</targetSelectedWithDropDown>
|
|
||||||
<timeTargetWasSelectedWithDropDown value="2021-02-19T19:02:37.198775Z" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
2
.idea/gradle.xml
generated
2
.idea/gradle.xml
generated
@@ -7,7 +7,7 @@
|
|||||||
<option name="testRunner" value="GRADLE" />
|
<option name="testRunner" value="GRADLE" />
|
||||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="Embedded JDK" />
|
<option name="gradleJvm" value="Android Studio default JDK" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<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">
|
<component name="InspectionProjectProfileManager">
|
||||||
<profile version="1.0">
|
<profile version="1.0">
|
||||||
<option name="myName" value="Project Default" />
|
<option name="myName" value="Project Default" />
|
||||||
<inspection_tool class="BooleanLiteralArgument" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
|
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
|
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
|
||||||
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="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="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
|
||||||
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
</profile>
|
</profile>
|
||||||
|
|||||||
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.
|
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
|
### Download
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ Download APK from Github Releases:
|
|||||||
* Tablet-optimized material design UI
|
* Tablet-optimized material design UI
|
||||||
* Standard and Webtoon-optimized reader
|
* Standard and Webtoon-optimized reader
|
||||||
* Notifications about new chapters with updates feed
|
* Notifications about new chapters with updates feed
|
||||||
|
* Available in multiple languages
|
||||||
|
* Password protect access to the app
|
||||||
|
|
||||||
### Screenshots
|
### 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
|
### License
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 32
|
targetSdkVersion 32
|
||||||
versionCode 400
|
versionCode 409
|
||||||
versionName '3.0'
|
versionName '3.3'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -49,14 +49,15 @@ android {
|
|||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
jvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||||
freeCompilerArgs += [
|
freeCompilerArgs += [
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
|
'-opt-in=coil.annotation.ExperimentalCoilApi',
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
lint {
|
lint {
|
||||||
abortOnError false
|
abortOnError false
|
||||||
disable 'MissingTranslation', 'PrivateResource'
|
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged'
|
||||||
}
|
}
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.includeAndroidResources = true
|
unitTests.includeAndroidResources = true
|
||||||
@@ -65,12 +66,12 @@ android {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
|
||||||
implementation('com.github.nv95:kotatsu-parsers:3ea7e92e64') {
|
implementation('com.github.nv95:kotatsu-parsers:f46c5add46') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
|
||||||
|
|
||||||
implementation 'androidx.core:core-ktx:1.7.0'
|
implementation 'androidx.core:core-ktx:1.7.0'
|
||||||
implementation 'androidx.activity:activity-ktx:1.4.0'
|
implementation 'androidx.activity:activity-ktx:1.4.0'
|
||||||
@@ -86,7 +87,7 @@ dependencies {
|
|||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||||
implementation 'androidx.preference:preference-ktx:1.2.0'
|
implementation 'androidx.preference:preference-ktx:1.2.0'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
implementation 'androidx.work:work-runtime-ktx:2.7.1'
|
||||||
implementation 'com.google.android.material:material:1.6.0-beta01'
|
implementation 'com.google.android.material:material:1.7.0-alpha01'
|
||||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
kapt 'androidx.lifecycle:lifecycle-compiler:2.4.1'
|
||||||
|
|
||||||
@@ -95,21 +96,22 @@ dependencies {
|
|||||||
kapt 'androidx.room:room-compiler:2.4.2'
|
kapt 'androidx.room:room-compiler:2.4.2'
|
||||||
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
|
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:4.3.2'
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
|
|
||||||
implementation 'io.insert-koin:koin-android:3.1.5'
|
implementation 'io.insert-koin:koin-android:3.2.0'
|
||||||
implementation 'io.coil-kt:coil-base:1.4.0'
|
implementation 'io.coil-kt:coil-base:2.1.0'
|
||||||
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
implementation 'com.davemorrissey.labs:subsampling-scale-image-view-androidx:3.10.0'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
|
|
||||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
|
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'
|
||||||
testImplementation 'io.insert-koin:koin-test-junit4:3.1.5'
|
testImplementation 'io.insert-koin:koin-test-junit4:3.2.0'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||||
androidTestImplementation 'androidx.test:rules: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.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<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" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||||
android:allowBackup="true"
|
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:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Kotatsu"
|
android:theme="@style/Theme.Kotatsu"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
tools:ignore="UnusedAttribute">
|
tools:ignore="UnusedAttribute">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
|
android:name="org.koitharu.kotatsu.main.ui.MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -53,20 +53,12 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
|
||||||
android:label="@string/search" />
|
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" />
|
android:label="@string/search_manga" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
|
android:name="org.koitharu.kotatsu.settings.SettingsActivity"
|
||||||
android:label="@string/settings" />
|
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
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
android:name="org.koitharu.kotatsu.browser.BrowserActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
@@ -87,7 +79,8 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/manga_shelf">
|
android:label="@string/manga_shelf"
|
||||||
|
android:theme="@style/Theme.Kotatsu.DialogWhenLarge">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@@ -95,6 +88,9 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity"
|
android:name="org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity"
|
||||||
android:label="@string/search" />
|
android:label="@string/search" />
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity"
|
||||||
|
android:label="@string/search" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity"
|
android:name="org.koitharu.kotatsu.main.ui.protect.ProtectActivity"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
@@ -104,12 +100,18 @@
|
|||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
|
||||||
android:label="@string/downloads" />
|
android:label="@string/downloads"
|
||||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/>
|
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
|
<service
|
||||||
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
|
||||||
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
android:permission="android.permission.BIND_REMOTEVIEWS" />
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import androidx.fragment.app.strictmode.FragmentStrictMode
|
|||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
|
||||||
|
import org.koitharu.kotatsu.bookmarks.bookmarksModule
|
||||||
import org.koitharu.kotatsu.core.db.databaseModule
|
import org.koitharu.kotatsu.core.db.databaseModule
|
||||||
import org.koitharu.kotatsu.core.github.githubModule
|
import org.koitharu.kotatsu.core.github.githubModule
|
||||||
import org.koitharu.kotatsu.core.network.networkModule
|
import org.koitharu.kotatsu.core.network.networkModule
|
||||||
@@ -42,6 +44,7 @@ class KotatsuApp : Application() {
|
|||||||
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
|
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
|
||||||
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
|
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
|
||||||
registerActivityLifecycleCallbacks(get<AppProtectHelper>())
|
registerActivityLifecycleCallbacks(get<AppProtectHelper>())
|
||||||
|
registerActivityLifecycleCallbacks(get<ActivityRecreationHandle>())
|
||||||
val widgetUpdater = WidgetUpdater(applicationContext)
|
val widgetUpdater = WidgetUpdater(applicationContext)
|
||||||
widgetUpdater.subscribeToFavourites(get())
|
widgetUpdater.subscribeToFavourites(get())
|
||||||
widgetUpdater.subscribeToHistory(get())
|
widgetUpdater.subscribeToHistory(get())
|
||||||
@@ -67,6 +70,7 @@ class KotatsuApp : Application() {
|
|||||||
readerModule,
|
readerModule,
|
||||||
appWidgetModule,
|
appWidgetModule,
|
||||||
suggestionsModule,
|
suggestionsModule,
|
||||||
|
bookmarksModule,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,19 @@ package org.koitharu.kotatsu.base.domain
|
|||||||
|
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
import org.koitharu.kotatsu.core.db.entity.*
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
|
|
||||||
class MangaDataRepository(private val db: MangaDatabase) {
|
class MangaDataRepository(private val db: MangaDatabase) {
|
||||||
|
|
||||||
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
|
suspend fun savePreferences(manga: Manga, mode: ReaderMode) {
|
||||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
val tags = manga.tags.toEntities()
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.tagsDao.upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||||
db.preferencesDao.upsert(
|
db.preferencesDao.upsert(
|
||||||
MangaPrefsEntity(
|
MangaPrefsEntity(
|
||||||
mangaId = manga.id,
|
mangaId = manga.id,
|
||||||
@@ -37,21 +34,19 @@ class MangaDataRepository(private val db: MangaDatabase) {
|
|||||||
|
|
||||||
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
suspend fun resolveIntent(intent: MangaIntent): Manga? = when {
|
||||||
intent.manga != null -> intent.manga
|
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
|
else -> null // TODO resolve uri
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun storeManga(manga: Manga) {
|
suspend fun storeManga(manga: Manga) {
|
||||||
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
|
val tags = manga.tags.toEntities()
|
||||||
db.withTransaction {
|
db.withTransaction {
|
||||||
db.tagsDao.upsert(tags)
|
db.tagsDao.upsert(tags)
|
||||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
db.mangaDao.upsert(manga.toEntity(), tags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findTags(source: MangaSource): Set<MangaTag> {
|
suspend fun findTags(source: MangaSource): Set<MangaTag> {
|
||||||
return db.tagsDao.findTags(source.name).mapToSet {
|
return db.tagsDao.findTags(source.name).toMangaTags()
|
||||||
it.toMangaTag()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,54 +9,58 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import org.koitharu.kotatsu.parsers.util.await
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
import org.koitharu.kotatsu.utils.ext.medianOrNull
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.zip.ZipFile
|
import java.util.zip.ZipFile
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
object MangaUtils : KoinComponent {
|
object MangaUtils : KoinComponent {
|
||||||
|
|
||||||
|
private const val MIN_WEBTOON_RATIO = 2
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatic determine type of manga by page size
|
* Automatic determine type of manga by page size
|
||||||
* @return ReaderMode.WEBTOON if page is wide
|
* @return ReaderMode.WEBTOON if page is wide
|
||||||
*/
|
*/
|
||||||
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean? {
|
suspend fun determineMangaIsWebtoon(pages: List<MangaPage>): Boolean {
|
||||||
try {
|
val pageIndex = (pages.size * 0.3).roundToInt()
|
||||||
val page = pages.medianOrNull() ?: return null
|
val page = requireNotNull(pages.getOrNull(pageIndex)) { "No pages" }
|
||||||
val url = MangaRepository(page.source).getPageUrl(page)
|
val url = MangaRepository(page.source).getPageUrl(page)
|
||||||
val uri = Uri.parse(url)
|
val uri = Uri.parse(url)
|
||||||
val size = if (uri.scheme == "cbz") {
|
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) {
|
runInterruptible(Dispatchers.IO) {
|
||||||
val zip = ZipFile(uri.schemeSpecificPart)
|
getBitmapSize(it.body?.byteStream())
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
private fun getBitmapSize(input: InputStream?): Size {
|
||||||
@@ -69,4 +73,4 @@ object MangaUtils : KoinComponent {
|
|||||||
check(imageHeight > 0 && imageWidth > 0)
|
check(imageHeight > 0 && imageWidth > 0)
|
||||||
return Size(imageWidth, imageHeight)
|
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.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.view.ActionMode
|
import androidx.appcompat.view.ActionMode
|
||||||
import androidx.appcompat.widget.ActionBarContextView
|
import androidx.appcompat.widget.ActionBarContextView
|
||||||
@@ -20,11 +21,13 @@ import androidx.viewbinding.ViewBinding
|
|||||||
import org.koin.android.ext.android.get
|
import org.koin.android.ext.android.get
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
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.base.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
|
||||||
abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
abstract class BaseActivity<B : ViewBinding> :
|
||||||
|
AppCompatActivity(),
|
||||||
WindowInsetsDelegate.WindowInsetsListener {
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
protected lateinit var binding: B
|
protected lateinit var binding: B
|
||||||
@@ -36,11 +39,17 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
|||||||
@Suppress("LeakingThis")
|
@Suppress("LeakingThis")
|
||||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||||
|
|
||||||
|
val actionModeDelegate = ActionModeDelegate()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
val settings = get<AppSettings>()
|
val settings = get<AppSettings>()
|
||||||
|
val isAmoled = settings.isAmoledTheme
|
||||||
|
val isDynamic = settings.isDynamicTheme
|
||||||
|
// TODO support DialogWhenLarge theme
|
||||||
when {
|
when {
|
||||||
settings.isAmoledTheme -> setTheme(R.style.ThemeOverlay_Kotatsu_AMOLED)
|
isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled)
|
||||||
settings.isDynamicTheme -> setTheme(R.style.Theme_Kotatsu_Monet)
|
isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled)
|
||||||
|
isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet)
|
||||||
}
|
}
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
@@ -90,8 +99,10 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
|||||||
return isNight && get<AppSettings>().isAmoledTheme
|
return isNight && get<AppSettings>().isAmoledTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
override fun onSupportActionModeStarted(mode: ActionMode) {
|
override fun onSupportActionModeStarted(mode: ActionMode) {
|
||||||
super.onSupportActionModeStarted(mode)
|
super.onSupportActionModeStarted(mode)
|
||||||
|
actionModeDelegate.onSupportActionModeStarted(mode)
|
||||||
val insets = ViewCompat.getRootWindowInsets(binding.root)
|
val insets = ViewCompat.getRootWindowInsets(binding.root)
|
||||||
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
||||||
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
|
val view = findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)
|
||||||
@@ -100,6 +111,12 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onSupportActionModeFinished(mode: ActionMode) {
|
||||||
|
super.onSupportActionModeFinished(mode)
|
||||||
|
actionModeDelegate.onSupportActionModeFinished(mode)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
if ( // https://issuetracker.google.com/issues/139738913
|
if ( // https://issuetracker.google.com/issues/139738913
|
||||||
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
|
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&
|
||||||
@@ -111,4 +128,4 @@ abstract class BaseActivity<B : ViewBinding> : AppCompatActivity(),
|
|||||||
super.onBackPressed()
|
super.onBackPressed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,18 +2,20 @@ package org.koitharu.kotatsu.base.ui
|
|||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.DisplayMetrics
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewGroup.LayoutParams
|
import android.view.ViewGroup.LayoutParams
|
||||||
import androidx.appcompat.app.AppCompatDialog
|
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
|
||||||
|
import org.koitharu.kotatsu.utils.ext.displayCompat
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
@@ -32,6 +34,20 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
|||||||
): View {
|
): View {
|
||||||
val binding = onInflateView(inflater, container)
|
val binding = onInflateView(inflater, container)
|
||||||
viewBinding = binding
|
viewBinding = binding
|
||||||
|
|
||||||
|
// Enforce max width for tablets
|
||||||
|
val width = resources.getDimensionPixelSize(R.dimen.bottom_sheet_width)
|
||||||
|
if (width > 0) {
|
||||||
|
behavior?.maxWidth = width
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set peek height to 50% display height
|
||||||
|
requireContext().displayCompat?.let {
|
||||||
|
val metrics = DisplayMetrics()
|
||||||
|
it.getRealMetrics(metrics)
|
||||||
|
behavior?.peekHeight = metrics.heightPixels / 2
|
||||||
|
}
|
||||||
|
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,9 +57,7 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
return if (resources.getBoolean(R.bool.is_tablet)) {
|
return AppBottomSheetDialog(requireContext(), theme)
|
||||||
AppCompatDialog(context, R.style.Theme_Kotatsu_Dialog)
|
|
||||||
} else super.onCreateDialog(savedInstanceState)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import org.koitharu.kotatsu.base.ui.util.ActionModeDelegate
|
||||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
|
|
||||||
abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
abstract class BaseFragment<B : ViewBinding> :
|
||||||
|
Fragment(),
|
||||||
WindowInsetsDelegate.WindowInsetsListener {
|
WindowInsetsDelegate.WindowInsetsListener {
|
||||||
|
|
||||||
private var viewBinding: B? = null
|
private var viewBinding: B? = null
|
||||||
@@ -23,6 +25,9 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
|||||||
@Suppress("LeakingThis")
|
@Suppress("LeakingThis")
|
||||||
protected val insetsDelegate = WindowInsetsDelegate(this)
|
protected val insetsDelegate = WindowInsetsDelegate(this)
|
||||||
|
|
||||||
|
protected val actionModeDelegate: ActionModeDelegate
|
||||||
|
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -47,4 +52,4 @@ abstract class BaseFragment<B : ViewBinding> : Fragment(),
|
|||||||
protected fun bindingOrNull() = viewBinding
|
protected fun bindingOrNull() = viewBinding
|
||||||
|
|
||||||
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,12 @@ import android.view.View
|
|||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private const val SYSTEM_UI_FLAGS_SHOWN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
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_HIDE_NAVIGATION or
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private const val SYSTEM_UI_FLAGS_HIDDEN = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
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_HIDE_NAVIGATION or
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 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_FULLSCREEN or
|
||||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
|
||||||
abstract class BaseFullscreenActivity<B : ViewBinding> : BaseActivity<B>(),
|
abstract class BaseFullscreenActivity<B : ViewBinding> :
|
||||||
|
BaseActivity<B>(),
|
||||||
View.OnSystemUiVisibilityChangeListener {
|
View.OnSystemUiVisibilityChangeListener {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -35,16 +38,19 @@ abstract class BaseFullscreenActivity<B : ViewBinding> : BaseActivity<B>(),
|
|||||||
showSystemUI()
|
showSystemUI()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
|
||||||
@Deprecated("Deprecated in Java")
|
@Deprecated("Deprecated in Java")
|
||||||
final override fun onSystemUiVisibilityChange(visibility: Int) {
|
final override fun onSystemUiVisibilityChange(visibility: Int) {
|
||||||
onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0)
|
onSystemUiVisibilityChanged(visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO WindowInsetsControllerCompat works incorrect
|
// TODO WindowInsetsControllerCompat works incorrect
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
protected fun hideSystemUI() {
|
protected fun hideSystemUI() {
|
||||||
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN
|
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_HIDDEN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
protected fun showSystemUI() {
|
protected fun showSystemUI() {
|
||||||
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN
|
window.decorView.systemUiVisibility = SYSTEM_UI_FLAGS_SHOWN
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import androidx.annotation.CallSuper
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
|
||||||
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
import org.koitharu.kotatsu.base.ui.util.WindowInsetsDelegate
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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,
|
WindowInsetsDelegate.WindowInsetsListener,
|
||||||
RecyclerViewOwner {
|
RecyclerViewOwner {
|
||||||
|
|
||||||
@@ -39,16 +43,20 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : Pre
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (titleId != 0) {
|
if (titleId != 0) {
|
||||||
activity?.setTitle(titleId)
|
setTitle(getString(titleId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
override fun onWindowInsetsChanged(insets: Insets) {
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
listView.updatePadding(
|
listView.updatePadding(
|
||||||
left = insets.left,
|
|
||||||
right = insets.right,
|
|
||||||
bottom = insets.bottom
|
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
|
package org.koitharu.kotatsu.base.ui
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
|
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||||
|
|
||||||
abstract class BaseViewModel : ViewModel() {
|
abstract class BaseViewModel : ViewModel() {
|
||||||
|
|
||||||
val onError = SingleLiveEvent<Throwable>()
|
protected val loadingCounter = CountedBooleanLiveData()
|
||||||
val isLoading = MutableLiveData(false)
|
protected val errorEvent = SingleLiveEvent<Throwable>()
|
||||||
|
|
||||||
|
val onError: LiveData<Throwable>
|
||||||
|
get() = errorEvent
|
||||||
|
|
||||||
|
val isLoading: LiveData<Boolean>
|
||||||
|
get() = loadingCounter
|
||||||
|
|
||||||
protected fun launchJob(
|
protected fun launchJob(
|
||||||
context: CoroutineContext = EmptyCoroutineContext,
|
context: CoroutineContext = EmptyCoroutineContext,
|
||||||
@@ -25,20 +32,18 @@ abstract class BaseViewModel : ViewModel() {
|
|||||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||||
block: suspend CoroutineScope.() -> Unit
|
block: suspend CoroutineScope.() -> Unit
|
||||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||||
isLoading.postValue(true)
|
loadingCounter.increment()
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.postValue(false)
|
loadingCounter.decrement()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||||
if (BuildConfig.DEBUG) {
|
throwable.printStackTraceDebug()
|
||||||
throwable.printStackTrace()
|
|
||||||
}
|
|
||||||
if (throwable !is CancellationException) {
|
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 initial 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.Context
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.BaseAdapter
|
import android.widget.BaseAdapter
|
||||||
@@ -12,7 +13,6 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
import org.koitharu.kotatsu.databinding.ItemStorageBinding
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.utils.ext.inflate
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
class StorageSelectDialog private constructor(private val delegate: AlertDialog) :
|
||||||
@@ -66,7 +66,7 @@ class StorageSelectDialog private constructor(private val delegate: AlertDialog)
|
|||||||
val volumes = getAvailableVolumes(storageManager)
|
val volumes = getAvailableVolumes(storageManager)
|
||||||
|
|
||||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
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 {
|
val binding = (view.tag as? ItemStorageBinding) ?: ItemStorageBinding.bind(view).also {
|
||||||
view.tag = it
|
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,34 @@
|
|||||||
|
package org.koitharu.kotatsu.base.ui.util
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application.ActivityLifecycleCallbacks
|
||||||
|
import android.os.Bundle
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class ActivityRecreationHandle : ActivityLifecycleCallbacks {
|
||||||
|
|
||||||
|
private val activities = WeakHashMap<Activity, Unit>()
|
||||||
|
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||||
|
activities[activity] = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStarted(activity: Activity) = Unit
|
||||||
|
|
||||||
|
override fun onActivityResumed(activity: Activity) = Unit
|
||||||
|
|
||||||
|
override fun onActivityPaused(activity: Activity) = Unit
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) = Unit
|
||||||
|
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||||
|
|
||||||
|
override fun onActivityDestroyed(activity: Activity) {
|
||||||
|
activities.remove(activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recreateAll() {
|
||||||
|
val snapshot = activities.keys.toList()
|
||||||
|
snapshot.forEach { it.recreate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
import android.os.Parcelable.Creator
|
import android.os.Parcelable.Creator
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
import android.widget.Checkable
|
import android.widget.Checkable
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.appcompat.widget.AppCompatImageView
|
import androidx.appcompat.widget.AppCompatImageView
|
||||||
@@ -61,6 +62,12 @@ class CheckableImageView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ToggleOnClickListener : OnClickListener {
|
||||||
|
override fun onClick(view: View) {
|
||||||
|
(view as? Checkable)?.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun interface OnCheckedChangeListener {
|
fun interface OnCheckedChangeListener {
|
||||||
|
|
||||||
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
|
fun onCheckedChanged(view: CheckableImageView, isChecked: Boolean)
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import android.graphics.drawable.shapes.RectShape
|
|||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import androidx.annotation.AttrRes
|
import androidx.annotation.AttrRes
|
||||||
import androidx.appcompat.widget.AppCompatCheckedTextView
|
import androidx.appcompat.widget.AppCompatCheckedTextView
|
||||||
import androidx.core.content.res.use
|
|
||||||
import androidx.core.content.withStyledAttributes
|
import androidx.core.content.withStyledAttributes
|
||||||
import com.google.android.material.ripple.RippleUtils
|
import com.google.android.material.ripple.RippleUtils
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import com.google.android.material.shape.ShapeAppearanceModel
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getThemeColorStateList
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
class ListItemTextView @JvmOverloads constructor(
|
class ListItemTextView @JvmOverloads constructor(
|
||||||
@@ -36,8 +36,7 @@ class ListItemTextView @JvmOverloads constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
|
context.withStyledAttributes(attrs, R.styleable.ListItemTextView, defStyleAttr) {
|
||||||
val itemRippleColor = getColorStateList(R.styleable.ListItemTextView_rippleColor)
|
val itemRippleColor = getRippleColor(context)
|
||||||
?: getRippleColorFallback(context)
|
|
||||||
val shape = createShapeDrawable(this)
|
val shape = createShapeDrawable(this)
|
||||||
background = RippleDrawable(
|
background = RippleDrawable(
|
||||||
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
|
RippleUtils.sanitizeRippleDrawableColor(itemRippleColor),
|
||||||
@@ -108,7 +107,7 @@ class ListItemTextView @JvmOverloads constructor(
|
|||||||
ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0),
|
ta.getResourceId(R.styleable.ListItemTextView_shapeAppearanceOverlay, 0),
|
||||||
).build()
|
).build()
|
||||||
val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
|
val shapeDrawable = MaterialShapeDrawable(shapeAppearance)
|
||||||
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundTint)
|
shapeDrawable.fillColor = ta.getColorStateList(R.styleable.ListItemTextView_backgroundFillColor)
|
||||||
return InsetDrawable(
|
return InsetDrawable(
|
||||||
shapeDrawable,
|
shapeDrawable,
|
||||||
ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0),
|
ta.getDimensionPixelOffset(R.styleable.ListItemTextView_android_insetLeft, 0),
|
||||||
@@ -118,9 +117,8 @@ class ListItemTextView @JvmOverloads constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRippleColorFallback(context: Context): ColorStateList {
|
private fun getRippleColor(context: Context): ColorStateList {
|
||||||
return context.obtainStyledAttributes(intArrayOf(android.R.attr.colorControlHighlight)).use {
|
return context.getThemeColorStateList(android.R.attr.colorControlHighlight)
|
||||||
it.getColorStateList(0)
|
?: ColorStateList.valueOf(Color.TRANSPARENT)
|
||||||
} ?: 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.graphics.Insets
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseActivity
|
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.core.network.UserAgentInterceptor
|
||||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||||
@@ -28,6 +29,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
|||||||
}
|
}
|
||||||
with(binding.webView.settings) {
|
with(binding.webView.settings) {
|
||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
|
userAgentString = UserAgentInterceptor.userAgent
|
||||||
}
|
}
|
||||||
binding.webView.webViewClient = BrowserClient(this)
|
binding.webView.webViewClient = BrowserClient(this)
|
||||||
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
|
||||||
|
|||||||
@@ -2,15 +2,11 @@ package org.koitharu.kotatsu.browser
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
|
||||||
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
import org.koitharu.kotatsu.core.network.WebViewClientCompat
|
||||||
|
|
||||||
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
|
class BrowserClient(private val callback: BrowserCallback) : WebViewClientCompat(), KoinComponent {
|
||||||
|
|
||||||
private val okHttp by inject<OkHttpClient>(mode = LazyThreadSafetyMode.SYNCHRONIZED)
|
|
||||||
|
|
||||||
override fun onPageFinished(webView: WebView, url: String) {
|
override fun onPageFinished(webView: WebView, url: String) {
|
||||||
super.onPageFinished(webView, url)
|
super.onPageFinished(webView, url)
|
||||||
callback.onLoadingStateChanged(isLoading = false)
|
callback.onLoadingStateChanged(isLoading = false)
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import java.io.File
|
|
||||||
import java.util.*
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.utils.MutableZipFile
|
|
||||||
import org.koitharu.kotatsu.utils.ext.format
|
|
||||||
|
|
||||||
class BackupArchive(file: File) : MutableZipFile(file) {
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!dir.exists()) {
|
|
||||||
dir.mkdirs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun put(entry: BackupEntry) {
|
|
||||||
put(entry.name, entry.data.toString(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getEntry(name: String): BackupEntry {
|
|
||||||
val json = withContext(Dispatchers.Default) {
|
|
||||||
JSONArray(getContent(name))
|
|
||||||
}
|
|
||||||
return BackupEntry(name, json)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val DIR_BACKUPS = "backups"
|
|
||||||
|
|
||||||
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
|
||||||
val filename = buildString {
|
|
||||||
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
|
|
||||||
append('_')
|
|
||||||
append(Date().format("ddMMyyyy"))
|
|
||||||
append(".bak")
|
|
||||||
}
|
|
||||||
BackupArchive(File(dir, filename))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -121,6 +121,7 @@ class BackupRepository(private val db: MangaDatabase) {
|
|||||||
jo.put("sort_key", sortKey)
|
jo.put("sort_key", sortKey)
|
||||||
jo.put("title", title)
|
jo.put("title", title)
|
||||||
jo.put("order", order)
|
jo.put("order", order)
|
||||||
|
jo.put("track", track)
|
||||||
return jo
|
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"),
|
sortKey = json.getInt("sort_key"),
|
||||||
title = json.getString("title"),
|
title = json.getString("title"),
|
||||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
||||||
|
track = json.getBooleanOrDefault("track", true),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun parseFavourite(json: JSONObject) = FavouriteEntity(
|
private fun parseFavourite(json: JSONObject) = FavouriteEntity(
|
||||||
|
|||||||
@@ -1,28 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.db
|
package org.koitharu.kotatsu.core.db
|
||||||
|
|
||||||
import androidx.room.Room
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.core.db.migrations.*
|
|
||||||
|
|
||||||
val databaseModule
|
val databaseModule
|
||||||
get() = module {
|
get() = module {
|
||||||
single {
|
single { MangaDatabase(androidContext()) }
|
||||||
Room.databaseBuilder(
|
|
||||||
androidContext(),
|
|
||||||
MangaDatabase::class.java,
|
|
||||||
"kotatsu-db"
|
|
||||||
).addMigrations(
|
|
||||||
Migration1To2(),
|
|
||||||
Migration2To3(),
|
|
||||||
Migration3To4(),
|
|
||||||
Migration4To5(),
|
|
||||||
Migration5To6(),
|
|
||||||
Migration6To7(),
|
|
||||||
Migration7To8(),
|
|
||||||
Migration8To9(),
|
|
||||||
).addCallback(
|
|
||||||
DatabasePrePopulateCallback(androidContext().resources)
|
|
||||||
).build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,8 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
|
|||||||
|
|
||||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`) VALUES (?,?,?,?)",
|
"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)
|
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.core.db
|
package org.koitharu.kotatsu.core.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
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.dao.*
|
||||||
import org.koitharu.kotatsu.core.db.entity.*
|
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.FavouriteCategoriesDao
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
@@ -17,9 +22,9 @@ import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
|||||||
entities = [
|
entities = [
|
||||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::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() {
|
abstract class MangaDatabase : RoomDatabase() {
|
||||||
|
|
||||||
@@ -40,4 +45,25 @@ abstract class MangaDatabase : RoomDatabase() {
|
|||||||
abstract val trackLogsDao: TrackLogsDao
|
abstract val trackLogsDao: TrackLogsDao
|
||||||
|
|
||||||
abstract val suggestionDao: SuggestionDao
|
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(
|
@Query(
|
||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
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
|
ORDER BY COUNT(manga_id) DESC
|
||||||
LIMIT :limit"""
|
LIMIT :limit"""
|
||||||
)
|
)
|
||||||
@@ -22,7 +22,7 @@ abstract class TagsDao {
|
|||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
WHERE tags.source = :source
|
WHERE tags.source = :source
|
||||||
GROUP BY manga_tags.tag_id
|
GROUP BY tags.title
|
||||||
ORDER BY COUNT(manga_id) DESC
|
ORDER BY COUNT(manga_id) DESC
|
||||||
LIMIT :limit"""
|
LIMIT :limit"""
|
||||||
)
|
)
|
||||||
@@ -32,7 +32,7 @@ abstract class TagsDao {
|
|||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
WHERE tags.source = :source AND title LIKE :query
|
WHERE tags.source = :source AND title LIKE :query
|
||||||
GROUP BY manga_tags.tag_id
|
GROUP BY tags.title
|
||||||
ORDER BY COUNT(manga_id) DESC
|
ORDER BY COUNT(manga_id) DESC
|
||||||
LIMIT :limit"""
|
LIMIT :limit"""
|
||||||
)
|
)
|
||||||
@@ -42,7 +42,7 @@ abstract class TagsDao {
|
|||||||
"""SELECT tags.* FROM tags
|
"""SELECT tags.* FROM tags
|
||||||
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
|
||||||
WHERE title LIKE :query
|
WHERE title LIKE :query
|
||||||
GROUP BY manga_tags.tag_id
|
GROUP BY tags.title
|
||||||
ORDER BY COUNT(manga_id) DESC
|
ORDER BY COUNT(manga_id) DESC
|
||||||
LIMIT :limit"""
|
LIMIT :limit"""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ abstract class TracksDao {
|
|||||||
@Query("SELECT * FROM tracks")
|
@Query("SELECT * FROM tracks")
|
||||||
abstract suspend fun findAll(): List<TrackEntity>
|
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")
|
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
|
||||||
abstract suspend fun find(mangaId: Long): TrackEntity?
|
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.mapToSet
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||||
|
|
||||||
|
// 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.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
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")
|
@Entity(tableName = "manga")
|
||||||
class MangaEntity(
|
class MangaEntity(
|
||||||
@@ -16,46 +12,11 @@ class MangaEntity(
|
|||||||
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
@ColumnInfo(name = "alt_title") val altTitle: String?,
|
||||||
@ColumnInfo(name = "url") val url: String,
|
@ColumnInfo(name = "url") val url: String,
|
||||||
@ColumnInfo(name = "public_url") val publicUrl: 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 = "nsfw") val isNsfw: Boolean,
|
||||||
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
@ColumnInfo(name = "cover_url") val coverUrl: String,
|
||||||
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
|
||||||
@ColumnInfo(name = "state") val state: String?,
|
@ColumnInfo(name = "state") val state: String?,
|
||||||
@ColumnInfo(name = "author") val author: String?,
|
@ColumnInfo(name = "author") val author: String?,
|
||||||
@ColumnInfo(name = "source") val source: 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
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "preferences", foreignKeys = [
|
tableName = "preferences",
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
childColumns = ["manga_id"],
|
childColumns = ["manga_id"],
|
||||||
onDelete = ForeignKey.CASCADE
|
onDelete = ForeignKey.CASCADE
|
||||||
)]
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
class MangaPrefsEntity(
|
class MangaPrefsEntity(
|
||||||
@PrimaryKey(autoGenerate = false)
|
@PrimaryKey(autoGenerate = false)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import androidx.room.Entity
|
|||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], foreignKeys = [
|
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"],
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
|||||||
import androidx.room.Embedded
|
import androidx.room.Embedded
|
||||||
import androidx.room.Junction
|
import androidx.room.Junction
|
||||||
import androidx.room.Relation
|
import androidx.room.Relation
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
|
|
||||||
class MangaWithTags(
|
class MangaWithTags(
|
||||||
@Embedded val manga: MangaEntity,
|
@Embedded val manga: MangaEntity,
|
||||||
@@ -12,10 +11,5 @@ class MangaWithTags(
|
|||||||
entityColumn = "tag_id",
|
entityColumn = "tag_id",
|
||||||
associateBy = Junction(MangaTagsEntity::class)
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
)
|
)
|
||||||
val tags: List<TagEntity>
|
val tags: List<TagEntity>,
|
||||||
) {
|
)
|
||||||
|
|
||||||
fun toManga() = manga.toManga(tags.mapToSet {
|
|
||||||
it.toMangaTag()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,6 @@ package org.koitharu.kotatsu.core.db.entity
|
|||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.PrimaryKey
|
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")
|
@Entity(tableName = "tags")
|
||||||
class TagEntity(
|
class TagEntity(
|
||||||
@@ -15,21 +11,4 @@ class TagEntity(
|
|||||||
@ColumnInfo(name = "title") val title: String,
|
@ColumnInfo(name = "title") val title: String,
|
||||||
@ColumnInfo(name = "key") val key: String,
|
@ColumnInfo(name = "key") val key: String,
|
||||||
@ColumnInfo(name = "source") val source: 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
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "tracks", foreignKeys = [
|
tableName = "tracks",
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import androidx.room.ForeignKey
|
|||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "track_logs", foreignKeys = [
|
tableName = "track_logs",
|
||||||
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
parentColumns = ["manga_id"],
|
parentColumns = ["manga_id"],
|
||||||
@@ -20,5 +21,5 @@ class TrackLogEntity(
|
|||||||
@ColumnInfo(name = "id") val id: Long = 0L,
|
@ColumnInfo(name = "id") val id: Long = 0L,
|
||||||
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
|
||||||
@ColumnInfo(name = "chapters") val chapters: String,
|
@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.Embedded
|
||||||
import androidx.room.Junction
|
import androidx.room.Junction
|
||||||
import androidx.room.Relation
|
import androidx.room.Relation
|
||||||
import org.koitharu.kotatsu.core.model.TrackingLogItem
|
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class TrackLogWithManga(
|
class TrackLogWithManga(
|
||||||
@Embedded val trackLog: TrackLogEntity,
|
@Embedded val trackLog: TrackLogEntity,
|
||||||
@@ -19,13 +16,5 @@ class TrackLogWithManga(
|
|||||||
entityColumn = "tag_id",
|
entityColumn = "tag_id",
|
||||||
associateBy = Junction(MangaTagsEntity::class)
|
associateBy = Junction(MangaTagsEntity::class)
|
||||||
)
|
)
|
||||||
val tags: List<TagEntity>
|
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
val githubModule
|
||||||
get() = module {
|
get() = module {
|
||||||
single {
|
factory { GithubRepository(get()) }
|
||||||
GithubRepository(get())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -54,27 +54,23 @@ class VersionId(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private fun variantWeight(variantType: String) = when (variantType.lowercase(Locale.ROOT)) {
|
||||||
|
"a", "alpha" -> 1
|
||||||
private fun variantWeight(variantType: String) =
|
"b", "beta" -> 2
|
||||||
when (variantType.lowercase(Locale.ROOT)) {
|
"rc" -> 4
|
||||||
"a", "alpha" -> 1
|
"" -> 8
|
||||||
"b", "beta" -> 2
|
else -> 0
|
||||||
"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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
import java.util.*
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class FavouriteCategory(
|
data class FavouriteCategory(
|
||||||
@@ -12,4 +12,5 @@ data class FavouriteCategory(
|
|||||||
val sortKey: Int,
|
val sortKey: Int,
|
||||||
val order: SortOrder,
|
val order: SortOrder,
|
||||||
val createdAt: Date,
|
val createdAt: Date,
|
||||||
|
val isTrackingEnabled: Boolean,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
@@ -1,25 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
fun Manga.withoutChapters() = if (chapters.isNullOrEmpty()) {
|
fun Collection<Manga>.ids() = mapToSet { it.id }
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import android.os.Parcel
|
|||||||
import androidx.core.os.ParcelCompat
|
import androidx.core.os.ParcelCompat
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
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.writeLong(id)
|
||||||
out.writeString(title)
|
out.writeString(title)
|
||||||
out.writeString(altTitle)
|
out.writeString(altTitle)
|
||||||
@@ -18,7 +18,11 @@ fun Manga.writeToParcel(out: Parcel, flags: Int) {
|
|||||||
out.writeParcelable(ParcelableMangaTags(tags), flags)
|
out.writeParcelable(ParcelableMangaTags(tags), flags)
|
||||||
out.writeSerializable(state)
|
out.writeSerializable(state)
|
||||||
out.writeString(author)
|
out.writeString(author)
|
||||||
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
if (withChapters) {
|
||||||
|
out.writeParcelable(chapters?.let(::ParcelableMangaChapters), flags)
|
||||||
|
} else {
|
||||||
|
out.writeString(null)
|
||||||
|
}
|
||||||
out.writeSerializable(source)
|
out.writeSerializable(source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,39 @@ package org.koitharu.kotatsu.core.model.parcelable
|
|||||||
|
|
||||||
import android.os.Parcel
|
import android.os.Parcel
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.util.Log
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
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(
|
class ParcelableManga(
|
||||||
val manga: Manga,
|
val manga: Manga,
|
||||||
|
private val withChapters: Boolean,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
constructor(parcel: Parcel) : this(parcel.readManga())
|
constructor(parcel: Parcel) : this(parcel.readManga(), true)
|
||||||
|
|
||||||
init {
|
|
||||||
if (BuildConfig.DEBUG && manga.chapters != null) {
|
|
||||||
Log.w("ParcelableManga", "Passing manga with chapters as Parcelable is dangerous!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
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 {
|
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
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import okhttp3.CookieJar
|
import okhttp3.CookieJar
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.dsl.bind
|
import org.koin.dsl.bind
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
val networkModule
|
val networkModule
|
||||||
get() = module {
|
get() = module {
|
||||||
single { AndroidCookieJar() } bind CookieJar::class
|
single { AndroidCookieJar() } bind CookieJar::class
|
||||||
single {
|
single {
|
||||||
|
val cache = get<LocalStorageManager>().createHttpCache()
|
||||||
OkHttpClient.Builder().apply {
|
OkHttpClient.Builder().apply {
|
||||||
connectTimeout(20, TimeUnit.SECONDS)
|
connectTimeout(20, TimeUnit.SECONDS)
|
||||||
readTimeout(60, TimeUnit.SECONDS)
|
readTimeout(60, TimeUnit.SECONDS)
|
||||||
writeTimeout(20, TimeUnit.SECONDS)
|
writeTimeout(20, TimeUnit.SECONDS)
|
||||||
cookieJar(get())
|
cookieJar(get())
|
||||||
cache(get<LocalStorageManager>().createHttpCache())
|
dns(DoHManager(cache, get()))
|
||||||
|
cache(cache)
|
||||||
addInterceptor(UserAgentInterceptor())
|
addInterceptor(UserAgentInterceptor())
|
||||||
addInterceptor(CloudFlareInterceptor())
|
addInterceptor(CloudFlareInterceptor())
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
addNetworkInterceptor(CurlLoggingInterceptor())
|
|
||||||
}
|
|
||||||
}.build()
|
}.build()
|
||||||
}
|
}
|
||||||
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
|
single<MangaLoaderContext> { MangaLoaderContextImpl(get(), get(), get()) }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import java.util.*
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class UserAgentInterceptor : Interceptor {
|
class UserAgentInterceptor : Interceptor {
|
||||||
|
|
||||||
@@ -30,5 +30,14 @@ class UserAgentInterceptor : Interceptor {
|
|||||||
Build.DEVICE,
|
Build.DEVICE,
|
||||||
Locale.getDefault().language
|
Locale.getDefault().language
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val userAgentChrome
|
||||||
|
get() = (
|
||||||
|
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
|
"Chrome/100.0.4896.127 Mobile Safari/537.36"
|
||||||
|
).format(
|
||||||
|
Build.VERSION.RELEASE,
|
||||||
|
Build.MODEL,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,12 +5,12 @@ import android.content.Context
|
|||||||
import android.content.pm.ShortcutManager
|
import android.content.pm.ShortcutManager
|
||||||
import android.media.ThumbnailUtils
|
import android.media.ThumbnailUtils
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Size
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.PixelSize
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -24,7 +24,7 @@ class ShortcutsRepository(
|
|||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val coil: ImageLoader,
|
private val coil: ImageLoader,
|
||||||
private val historyRepository: HistoryRepository,
|
private val historyRepository: HistoryRepository,
|
||||||
private val mangaRepository: MangaDataRepository
|
private val mangaRepository: MangaDataRepository,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val iconSize by lazy {
|
private val iconSize by lazy {
|
||||||
@@ -54,7 +54,7 @@ class ShortcutsRepository(
|
|||||||
val bmp = coil.execute(
|
val bmp = coil.execute(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(manga.coverUrl)
|
.data(manga.coverUrl)
|
||||||
.size(iconSize)
|
.size(iconSize.width, iconSize.height)
|
||||||
.build()
|
.build()
|
||||||
).requireBitmap()
|
).requireBitmap()
|
||||||
ThumbnailUtils.extractThumbnail(bmp, iconSize.width, iconSize.height, 0)
|
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) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||||
(context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
|
(context.getSystemService(Context.SHORTCUT_SERVICE) as ShortcutManager).let {
|
||||||
PixelSize(it.iconMaxWidth, it.iconMaxHeight)
|
Size(it.iconMaxWidth, it.iconMaxHeight)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).launcherLargeIconSize.let {
|
(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 android.net.Uri
|
||||||
import coil.map.Mapper
|
import coil.map.Mapper
|
||||||
|
import coil.request.Options
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
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 mangaSource = MangaSource.valueOf(data.schemeSpecificPart)
|
||||||
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
|
val repo = MangaRepository(mangaSource) as RemoteMangaRepository
|
||||||
return repo.getFaviconUrl().toHttpUrl()
|
return repo.getFaviconUrl().toHttpUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handles(data: Uri) = data.scheme == "favicon"
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import java.util.*
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
@@ -28,11 +30,18 @@ interface MangaRepository {
|
|||||||
|
|
||||||
companion object : KoinComponent {
|
companion object : KoinComponent {
|
||||||
|
|
||||||
|
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
|
||||||
|
|
||||||
operator fun invoke(source: MangaSource): MangaRepository {
|
operator fun invoke(source: MangaSource): MangaRepository {
|
||||||
return if (source == MangaSource.LOCAL) {
|
if (source == MangaSource.LOCAL) {
|
||||||
get<LocalMangaRepository>()
|
return get<LocalMangaRepository>()
|
||||||
} else {
|
}
|
||||||
RemoteMangaRepository(source, get())
|
cache[source]?.get()?.let { return it }
|
||||||
|
return synchronized(cache) {
|
||||||
|
cache[source]?.get()?.let { return it }
|
||||||
|
val repository = RemoteMangaRepository(MangaParser(source, get()))
|
||||||
|
cache[source] = WeakReference(repository)
|
||||||
|
repository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaParser
|
import org.koitharu.kotatsu.parsers.MangaParser
|
||||||
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
|
||||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||||
import org.koitharu.kotatsu.parsers.model.*
|
import org.koitharu.kotatsu.parsers.model.*
|
||||||
import org.koitharu.kotatsu.parsers.newParser
|
|
||||||
|
|
||||||
class RemoteMangaRepository(
|
class RemoteMangaRepository(private val parser: MangaParser) : MangaRepository {
|
||||||
override val source: MangaSource,
|
|
||||||
loaderContext: MangaLoaderContext,
|
|
||||||
) : MangaRepository {
|
|
||||||
|
|
||||||
private val parser: MangaParser = source.newParser(loaderContext)
|
override val source: MangaSource
|
||||||
|
get() = parser.source
|
||||||
|
|
||||||
override val sortOrders: Set<SortOrder>
|
override val sortOrders: Set<SortOrder>
|
||||||
get() = parser.sortOrders
|
get() = parser.sortOrders
|
||||||
@@ -28,7 +24,7 @@ class RemoteMangaRepository(
|
|||||||
offset: Int,
|
offset: Int,
|
||||||
query: String?,
|
query: String?,
|
||||||
tags: Set<MangaTag>?,
|
tags: Set<MangaTag>?,
|
||||||
sortOrder: SortOrder?
|
sortOrder: SortOrder?,
|
||||||
): List<Manga> = parser.getList(offset, query, tags, sortOrder)
|
): List<Manga> = parser.getList(offset, query, tags, sortOrder)
|
||||||
|
|
||||||
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
|
override suspend fun getDetails(manga: Manga): Manga = parser.getDetails(manga)
|
||||||
@@ -48,4 +44,4 @@ class RemoteMangaRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getConfig() = parser.config as SourceSettings
|
private fun getConfig() = parser.config as SourceSettings
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,6 @@ import android.content.Context
|
|||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.collection.arraySetOf
|
import androidx.collection.arraySetOf
|
||||||
@@ -14,7 +13,9 @@ import com.google.android.material.color.DynamicColors
|
|||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||||
|
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
import org.koitharu.kotatsu.utils.ext.getEnumValue
|
||||||
import org.koitharu.kotatsu.utils.ext.putEnumValue
|
import org.koitharu.kotatsu.utils.ext.putEnumValue
|
||||||
@@ -28,6 +29,16 @@ class AppSettings(context: Context) {
|
|||||||
|
|
||||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
||||||
|
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply {
|
||||||
|
remove(MangaSource.LOCAL)
|
||||||
|
if (!BuildConfig.DEBUG) {
|
||||||
|
remove(MangaSource.DUMMY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val remoteMangaSources: Set<MangaSource>
|
||||||
|
get() = Collections.unmodifiableSet(remoteSources)
|
||||||
|
|
||||||
var listMode: ListMode
|
var listMode: ListMode
|
||||||
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
|
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.DETAILED_LIST)
|
||||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
|
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
|
||||||
@@ -40,7 +51,7 @@ class AppSettings(context: Context) {
|
|||||||
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
|
||||||
val isDynamicTheme: Boolean
|
val isDynamicTheme: Boolean
|
||||||
get() = prefs.getBoolean(KEY_DYNAMIC_THEME, false)
|
get() = DynamicColors.isDynamicColorAvailable() && prefs.getBoolean(KEY_DYNAMIC_THEME, false)
|
||||||
|
|
||||||
val isAmoledTheme: Boolean
|
val isAmoledTheme: Boolean
|
||||||
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
|
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
|
||||||
@@ -56,6 +67,10 @@ class AppSettings(context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
|
||||||
|
|
||||||
|
var isAllFavouritesVisible: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_ALL_FAVOURITES_VISIBLE, true)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_ALL_FAVOURITES_VISIBLE, value) }
|
||||||
|
|
||||||
val isUpdateCheckingEnabled: Boolean
|
val isUpdateCheckingEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
|
get() = prefs.getBoolean(KEY_APP_UPDATE_AUTO, true)
|
||||||
|
|
||||||
@@ -63,7 +78,10 @@ class AppSettings(context: Context) {
|
|||||||
get() = prefs.getLong(KEY_APP_UPDATE, 0L)
|
get() = prefs.getLong(KEY_APP_UPDATE, 0L)
|
||||||
set(value) = prefs.edit { putLong(KEY_APP_UPDATE, value) }
|
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)
|
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
|
||||||
|
|
||||||
var notificationSound: Uri
|
var notificationSound: Uri
|
||||||
@@ -80,8 +98,11 @@ class AppSettings(context: Context) {
|
|||||||
val readerAnimation: Boolean
|
val readerAnimation: Boolean
|
||||||
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
|
get() = prefs.getBoolean(KEY_READER_ANIMATION, false)
|
||||||
|
|
||||||
val isPreferRtlReader: Boolean
|
val defaultReaderMode: ReaderMode
|
||||||
get() = prefs.getBoolean(KEY_READER_PREFER_RTL, false)
|
get() = prefs.getEnumValue(KEY_READER_MODE, ReaderMode.STANDARD)
|
||||||
|
|
||||||
|
val isReaderModeDetectionEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_READER_MODE_DETECT, true)
|
||||||
|
|
||||||
var historyGrouping: Boolean
|
var historyGrouping: Boolean
|
||||||
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
|
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
|
||||||
@@ -104,10 +125,9 @@ class AppSettings(context: Context) {
|
|||||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||||
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
|
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
|
||||||
|
|
||||||
var sourcesOrder: List<Int>
|
var sourcesOrder: List<String>
|
||||||
get() = prefs.getString(KEY_SOURCES_ORDER, null)
|
get() = prefs.getString(KEY_SOURCES_ORDER, null)
|
||||||
?.split('|')
|
?.split('|')
|
||||||
?.mapNotNull(String::toIntOrNull)
|
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
set(value) = prefs.edit {
|
set(value) = prefs.edit {
|
||||||
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
|
putString(KEY_SOURCES_ORDER, value.joinToString("|"))
|
||||||
@@ -120,6 +140,20 @@ class AppSettings(context: Context) {
|
|||||||
val isSourcesSelected: Boolean
|
val isSourcesSelected: Boolean
|
||||||
get() = KEY_SOURCES_HIDDEN in prefs
|
get() = KEY_SOURCES_HIDDEN in prefs
|
||||||
|
|
||||||
|
val newSources: Set<MangaSource>
|
||||||
|
get() {
|
||||||
|
val known = sourcesOrder.toSet()
|
||||||
|
val hidden = hiddenSources
|
||||||
|
return remoteMangaSources
|
||||||
|
.filterNotTo(EnumSet.noneOf(MangaSource::class.java)) { x ->
|
||||||
|
x.name in known || x.name in hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markKnownSources(sources: Collection<MangaSource>) {
|
||||||
|
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
|
||||||
|
}
|
||||||
|
|
||||||
val isPagesNumbersEnabled: Boolean
|
val isPagesNumbersEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
|
||||||
|
|
||||||
@@ -141,6 +175,12 @@ class AppSettings(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isDownloadsSlowdownEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
|
||||||
|
|
||||||
|
val downloadsParallelism: Int
|
||||||
|
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
|
||||||
|
|
||||||
val isSuggestionsEnabled: Boolean
|
val isSuggestionsEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
||||||
|
|
||||||
@@ -151,6 +191,9 @@ class AppSettings(context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false)
|
get() = prefs.getBoolean(KEY_SEARCH_SINGLE_SOURCE, false)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_SEARCH_SINGLE_SOURCE, value) }
|
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 {
|
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
|
||||||
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
|
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
|
||||||
NETWORK_ALWAYS -> true
|
NETWORK_ALWAYS -> true
|
||||||
@@ -165,12 +208,23 @@ class AppSettings(context: Context) {
|
|||||||
else -> SimpleDateFormat(format, Locale.getDefault())
|
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> {
|
fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
|
||||||
val list = MangaSource.values().toMutableList()
|
val list = remoteSources.toMutableList()
|
||||||
list.remove(MangaSource.LOCAL)
|
|
||||||
val order = sourcesOrder
|
val order = sourcesOrder
|
||||||
list.sortBy { x ->
|
list.sortBy { x ->
|
||||||
val e = order.indexOf(x.ordinal)
|
val e = order.indexOf(x.name)
|
||||||
if (e == -1) order.size + x.ordinal else e
|
if (e == -1) order.size + x.ordinal else e
|
||||||
}
|
}
|
||||||
if (!includeHidden) {
|
if (!includeHidden) {
|
||||||
@@ -212,7 +266,7 @@ class AppSettings(context: Context) {
|
|||||||
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
const val KEY_DYNAMIC_THEME = "dynamic_theme"
|
||||||
const val KEY_THEME_AMOLED = "amoled_theme"
|
const val KEY_THEME_AMOLED = "amoled_theme"
|
||||||
const val KEY_DATE_FORMAT = "date_format"
|
const val KEY_DATE_FORMAT = "date_format"
|
||||||
const val KEY_SOURCES_ORDER = "sources_order"
|
const val KEY_SOURCES_ORDER = "sources_order_2"
|
||||||
const val KEY_SOURCES_HIDDEN = "sources_hidden"
|
const val KEY_SOURCES_HIDDEN = "sources_hidden"
|
||||||
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
const val KEY_TRAFFIC_WARNING = "traffic_warning"
|
||||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||||
@@ -224,15 +278,19 @@ class AppSettings(context: Context) {
|
|||||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||||
const val KEY_READER_SWITCHERS = "reader_switchers"
|
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_SOURCES = "track_sources"
|
||||||
|
const val KEY_TRACK_CATEGORIES = "track_categories"
|
||||||
const val KEY_TRACK_WARNING = "track_warning"
|
const val KEY_TRACK_WARNING = "track_warning"
|
||||||
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
|
||||||
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
|
||||||
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
|
||||||
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
|
||||||
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
|
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_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_APP_PASSWORD = "app_password"
|
||||||
const val KEY_PROTECT_APP = "protect_app"
|
const val KEY_PROTECT_APP = "protect_app"
|
||||||
const val KEY_APP_VERSION = "app_version"
|
const val KEY_APP_VERSION = "app_version"
|
||||||
@@ -247,27 +305,23 @@ class AppSettings(context: Context) {
|
|||||||
const val KEY_PAGES_PRELOAD = "pages_preload"
|
const val KEY_PAGES_PRELOAD = "pages_preload"
|
||||||
const val KEY_SUGGESTIONS = "suggestions"
|
const val KEY_SUGGESTIONS = "suggestions"
|
||||||
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
|
||||||
|
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
|
||||||
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
|
||||||
|
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
|
||||||
|
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
|
||||||
|
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
||||||
|
const val KEY_DOH = "doh"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
|
const val KEY_APP_UPDATE_AUTO = "app_update_auto"
|
||||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
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_4PDA = "about_feedback_4pda"
|
||||||
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
|
const val KEY_FEEDBACK_DISCORD = "about_feedback_discord"
|
||||||
const val KEY_FEEDBACK_GITHUB = "about_feedback_github"
|
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_NEVER = 0
|
||||||
private const val NETWORK_ALWAYS = 1
|
private const val NETWORK_ALWAYS = 1
|
||||||
private const val NETWORK_NON_METERED = 2
|
private const val NETWORK_NON_METERED = 2
|
||||||
|
|
||||||
val isDynamicColorAvailable: Boolean
|
|
||||||
get() = DynamicColors.isDynamicColorAvailable() ||
|
|
||||||
(isSamsung && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
|
||||||
|
|
||||||
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 }
|
fun valueOf(id: Int) = values().firstOrNull { it.id == id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||||
|
|
||||||
class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
|
class AppCrashHandler(private val applicationContext: Context) : Thread.UncaughtExceptionHandler {
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ class AppCrashHandler(private val applicationContext: Context) : Thread.Uncaught
|
|||||||
try {
|
try {
|
||||||
applicationContext.startActivity(intent)
|
applicationContext.startActivity(intent)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
t.printStackTrace()
|
t.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
Log.e("CRASH", e.message, e)
|
Log.e("CRASH", e.message, e)
|
||||||
exitProcess(1)
|
exitProcess(1)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.core.ui
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.utils.ext.daysDiff
|
||||||
|
import org.koitharu.kotatsu.utils.ext.format
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
sealed class DateTimeAgo : ListModel {
|
sealed class DateTimeAgo : ListModel {
|
||||||
|
|
||||||
@@ -72,9 +75,33 @@ sealed class DateTimeAgo : ListModel {
|
|||||||
override fun hashCode(): Int = days
|
override fun hashCode(): Int = days
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Absolute(private val date: Date) : DateTimeAgo() {
|
||||||
|
|
||||||
|
private val day = date.daysDiff(0)
|
||||||
|
|
||||||
|
override fun format(resources: Resources): String {
|
||||||
|
return date.format("d MMMM")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as Absolute
|
||||||
|
|
||||||
|
if (day != other.day) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
object LongAgo : DateTimeAgo() {
|
object LongAgo : DateTimeAgo() {
|
||||||
override fun format(resources: Resources): String {
|
override fun format(resources: Resources): String {
|
||||||
return resources.getString(R.string.long_ago)
|
return resources.getString(R.string.long_ago)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.ui
|
|||||||
|
|
||||||
import coil.ComponentRegistry
|
import coil.ComponentRegistry
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.util.CoilUtils
|
import coil.disk.DiskCache
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import org.koitharu.kotatsu.core.parser.FaviconMapper
|
import org.koitharu.kotatsu.core.parser.FaviconMapper
|
||||||
|
import org.koitharu.kotatsu.local.data.CacheDir
|
||||||
import org.koitharu.kotatsu.local.data.CbzFetcher
|
import org.koitharu.kotatsu.local.data.CbzFetcher
|
||||||
|
|
||||||
val uiModule
|
val uiModule
|
||||||
@@ -14,15 +16,23 @@ val uiModule
|
|||||||
single {
|
single {
|
||||||
val httpClientFactory = {
|
val httpClientFactory = {
|
||||||
get<OkHttpClient>().newBuilder()
|
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()
|
.build()
|
||||||
}
|
}
|
||||||
ImageLoader.Builder(androidContext())
|
ImageLoader.Builder(androidContext())
|
||||||
.okHttpClient(httpClientFactory)
|
.okHttpClient(httpClientFactory)
|
||||||
.launchInterceptorChainOnMainThread(false)
|
.interceptorDispatcher(Dispatchers.Default)
|
||||||
.componentRegistry(
|
.diskCache(diskCacheFactory)
|
||||||
|
.components(
|
||||||
ComponentRegistry.Builder()
|
ComponentRegistry.Builder()
|
||||||
.add(CbzFetcher())
|
.add(CbzFetcher.Factory())
|
||||||
.add(FaviconMapper())
|
.add(FaviconMapper())
|
||||||
.build()
|
.build()
|
||||||
).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 {
|
get() = module {
|
||||||
|
|
||||||
viewModel { intent ->
|
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.view.ActionMode
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.view.isGone
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
|||||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
import org.koitharu.kotatsu.download.ui.service.DownloadService
|
||||||
|
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
@@ -67,8 +68,8 @@ class ChaptersFragment :
|
|||||||
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
|
||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
viewModel.hasChapters.observe(viewLifecycleOwner) {
|
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
|
||||||
binding.textViewHolder.isGone = it
|
binding.textViewHolder.isVisible = it
|
||||||
activity?.invalidateOptionsMenu()
|
activity?.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ class ChaptersFragment :
|
|||||||
override fun onPrepareOptionsMenu(menu: Menu) {
|
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||||
super.onPrepareOptionsMenu(menu)
|
super.onPrepareOptionsMenu(menu)
|
||||||
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
|
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
|
||||||
menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true
|
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||||
@@ -120,13 +121,7 @@ class ChaptersFragment :
|
|||||||
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
|
(activity as? DetailsActivity)?.showChapterMissingDialog(item.chapter.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val options = ActivityOptions.makeScaleUpAnimation(
|
val options = ActivityOptions.makeScaleUpAnimation(view, 0, 0, view.width, view.height)
|
||||||
view,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
view.measuredWidth,
|
|
||||||
view.measuredHeight
|
|
||||||
)
|
|
||||||
startActivity(
|
startActivity(
|
||||||
ReaderActivity.newIntent(
|
ReaderActivity.newIntent(
|
||||||
context = view.context,
|
context = view.context,
|
||||||
@@ -154,11 +149,29 @@ class ChaptersFragment :
|
|||||||
DownloadService.start(
|
DownloadService.start(
|
||||||
context ?: return false,
|
context ?: return false,
|
||||||
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
|
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
|
||||||
selectionDecoration?.checkedItemsIds
|
selectionDecoration?.checkedItemsIds?.toSet()
|
||||||
)
|
)
|
||||||
mode.finish()
|
mode.finish()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
R.id.action_delete -> {
|
||||||
|
val ids = selectionDecoration?.checkedItemsIds
|
||||||
|
val manga = viewModel.manga.value
|
||||||
|
when {
|
||||||
|
ids.isNullOrEmpty() || manga == null -> Unit
|
||||||
|
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
|
||||||
|
else -> {
|
||||||
|
LocalChaptersRemoveService.start(requireContext(), manga, ids)
|
||||||
|
Snackbar.make(
|
||||||
|
binding.recyclerViewChapters,
|
||||||
|
R.string.chapters_will_removed_background,
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
R.id.action_select_all -> {
|
R.id.action_select_all -> {
|
||||||
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
|
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
|
||||||
selectionDecoration?.checkAll(ids)
|
selectionDecoration?.checkAll(ids)
|
||||||
@@ -178,9 +191,7 @@ class ChaptersFragment :
|
|||||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
val manga = viewModel.manga.value
|
|
||||||
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
|
||||||
mode.title = manga?.title
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,12 +201,10 @@ class ChaptersFragment :
|
|||||||
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
menu.findItem(R.id.action_save).isVisible = items.none { x ->
|
||||||
x.chapter.source == MangaSource.LOCAL
|
x.chapter.source == MangaSource.LOCAL
|
||||||
}
|
}
|
||||||
mode.subtitle = resources.getQuantityString(
|
menu.findItem(R.id.action_delete).isVisible = items.all { x ->
|
||||||
R.plurals.chapters_from_x,
|
x.chapter.source == MangaSource.LOCAL
|
||||||
items.size,
|
}
|
||||||
items.size,
|
mode.title = items.size.toString()
|
||||||
chaptersAdapter?.itemCount ?: 0
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
@@ -17,6 +16,7 @@ import androidx.appcompat.view.ActionMode
|
|||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.net.toFile
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updateLayoutParams
|
import androidx.core.view.updateLayoutParams
|
||||||
import androidx.core.view.updatePadding
|
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.download.ui.service.DownloadService
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||||
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
|
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||||
import org.koitharu.kotatsu.utils.ShareHelper
|
import org.koitharu.kotatsu.utils.ShareHelper
|
||||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
|
|
||||||
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy,
|
class DetailsActivity :
|
||||||
|
BaseActivity<ActivityDetailsBinding>(),
|
||||||
|
TabLayoutMediator.TabConfigurationStrategy,
|
||||||
AdapterView.OnItemSelectedListener {
|
AdapterView.OnItemSelectedListener {
|
||||||
|
|
||||||
private val viewModel by viewModel<DetailsViewModel> {
|
private val viewModel by viewModel<DetailsViewModel> {
|
||||||
@@ -80,6 +83,9 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
|
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
|
||||||
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
|
viewModel.onMangaRemoved.observe(this, ::onMangaRemoved)
|
||||||
viewModel.onError.observe(this, ::onError)
|
viewModel.onError.observe(this, ::onError)
|
||||||
|
viewModel.onShowToast.observe(this) {
|
||||||
|
binding.snackbar.show(messageText = getString(it), longDuration = false)
|
||||||
|
}
|
||||||
|
|
||||||
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
|
registerReceiver(downloadReceiver, IntentFilter(DownloadService.ACTION_DOWNLOAD_COMPLETE))
|
||||||
}
|
}
|
||||||
@@ -163,7 +169,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
R.id.action_share -> {
|
R.id.action_share -> {
|
||||||
viewModel.manga.value?.let {
|
viewModel.manga.value?.let {
|
||||||
if (it.source == MangaSource.LOCAL) {
|
if (it.source == MangaSource.LOCAL) {
|
||||||
ShareHelper(this).shareCbz(Uri.parse(it.url).toFile())
|
ShareHelper(this).shareCbz(listOf(it.url.toUri().toFile()))
|
||||||
} else {
|
} else {
|
||||||
ShareHelper(this).shareMangaLink(it)
|
ShareHelper(this).shareMangaLink(it)
|
||||||
}
|
}
|
||||||
@@ -171,38 +177,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_delete -> {
|
R.id.action_delete -> {
|
||||||
viewModel.manga.value?.let { m ->
|
val title = viewModel.manga.value?.title.orEmpty()
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(R.string.delete_manga)
|
.setTitle(R.string.delete_manga)
|
||||||
.setMessage(getString(R.string.text_delete_local_manga, m.title))
|
.setMessage(getString(R.string.text_delete_local_manga, title))
|
||||||
.setPositiveButton(R.string.delete) { _, _ ->
|
.setPositiveButton(R.string.delete) { _, _ ->
|
||||||
viewModel.deleteLocal(m)
|
viewModel.deleteLocal()
|
||||||
}
|
}
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.action_save -> {
|
R.id.action_save -> {
|
||||||
viewModel.manga.value?.let {
|
viewModel.manga.value?.let {
|
||||||
val chaptersCount = it.chapters?.size ?: 0
|
val chaptersCount = it.chapters?.size ?: 0
|
||||||
if (chaptersCount > 5) {
|
val branches = viewModel.branches.value.orEmpty()
|
||||||
MaterialAlertDialogBuilder(this)
|
if (chaptersCount > 5 || branches.size > 1) {
|
||||||
.setTitle(R.string.save_manga)
|
showSaveConfirmation(it, chaptersCount, branches)
|
||||||
.setMessage(
|
|
||||||
getString(
|
|
||||||
R.string.large_manga_save_confirm,
|
|
||||||
resources.getQuantityString(
|
|
||||||
R.plurals.chapters,
|
|
||||||
chaptersCount,
|
|
||||||
chaptersCount
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
|
||||||
.setPositiveButton(R.string.save) { _, _ ->
|
|
||||||
DownloadService.start(this, it)
|
|
||||||
}.show()
|
|
||||||
} else {
|
} else {
|
||||||
DownloadService.start(this, it)
|
DownloadService.start(this, it)
|
||||||
}
|
}
|
||||||
@@ -217,7 +208,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
}
|
}
|
||||||
R.id.action_related -> {
|
R.id.action_related -> {
|
||||||
viewModel.manga.value?.let {
|
viewModel.manga.value?.let {
|
||||||
startActivity(GlobalSearchActivity.newIntent(this, it.title))
|
startActivity(MultiSearchActivity.newIntent(this, it.title))
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -262,7 +253,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
fun showChapterMissingDialog(chapterId: Long) {
|
fun showChapterMissingDialog(chapterId: Long) {
|
||||||
val remoteManga = viewModel.getRemoteManga()
|
val remoteManga = viewModel.getRemoteManga()
|
||||||
if (remoteManga == null) {
|
if (remoteManga == null) {
|
||||||
binding.snackbar.show(getString( R.string.chapter_is_missing))
|
binding.snackbar.show(getString(R.string.chapter_is_missing))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
MaterialAlertDialogBuilder(this).apply {
|
MaterialAlertDialogBuilder(this).apply {
|
||||||
@@ -328,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 {
|
companion object {
|
||||||
|
|
||||||
fun newIntent(context: Context, manga: Manga): Intent {
|
fun newIntent(context: Context, manga: Manga): Intent {
|
||||||
return Intent(context, DetailsActivity::class.java)
|
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 {
|
fun newIntent(context: Context, mangaId: Long): Intent {
|
||||||
@@ -340,4 +361,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
|
|||||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,12 @@ import android.os.Bundle
|
|||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import android.text.method.LinkMovementMethod
|
import android.text.method.LinkMovementMethod
|
||||||
import android.view.*
|
import android.view.*
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.graphics.Insets
|
import androidx.core.graphics.Insets
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.text.parseAsHtml
|
import androidx.core.text.parseAsHtml
|
||||||
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
@@ -20,10 +22,14 @@ import org.koin.android.ext.android.inject
|
|||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.base.ui.BaseFragment
|
import org.koitharu.kotatsu.base.ui.BaseFragment
|
||||||
|
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.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.core.model.MangaHistory
|
||||||
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
|
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.image.ui.ImageActivity
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
@@ -40,7 +46,8 @@ class DetailsFragment :
|
|||||||
BaseFragment<FragmentDetailsBinding>(),
|
BaseFragment<FragmentDetailsBinding>(),
|
||||||
View.OnClickListener,
|
View.OnClickListener,
|
||||||
View.OnLongClickListener,
|
View.OnLongClickListener,
|
||||||
ChipsView.OnChipClickListener {
|
ChipsView.OnChipClickListener,
|
||||||
|
OnListItemClickListener<Bookmark> {
|
||||||
|
|
||||||
private val viewModel by sharedViewModel<DetailsViewModel>()
|
private val viewModel by sharedViewModel<DetailsViewModel>()
|
||||||
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
|
private val coil by inject<ImageLoader>(mode = LazyThreadSafetyMode.NONE)
|
||||||
@@ -68,6 +75,7 @@ class DetailsFragment :
|
|||||||
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
|
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
|
||||||
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
|
viewModel.favouriteCategories.observe(viewLifecycleOwner, ::onFavouriteChanged)
|
||||||
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
|
viewModel.readingHistory.observe(viewLifecycleOwner, ::onHistoryChanged)
|
||||||
|
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
@@ -75,6 +83,24 @@ class DetailsFragment :
|
|||||||
inflater.inflate(R.menu.opt_details_info, menu)
|
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) {
|
private fun onMangaUpdated(manga: Manga) {
|
||||||
with(binding) {
|
with(binding) {
|
||||||
// Main
|
// 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) {
|
override fun onClick(v: View) {
|
||||||
val manga = viewModel.manga.value ?: return
|
val manga = viewModel.manga.value ?: return
|
||||||
when (v.id) {
|
when (v.id) {
|
||||||
R.id.button_favorite -> {
|
R.id.button_favorite -> {
|
||||||
FavouriteCategoriesDialog.show(childFragmentManager, manga)
|
FavouriteCategoriesBottomSheet.show(childFragmentManager, manga)
|
||||||
}
|
}
|
||||||
R.id.button_read -> {
|
R.id.button_read -> {
|
||||||
val chapterId = viewModel.readingHistory.value?.chapterId
|
val chapterId = viewModel.readingHistory.value?.chapterId
|
||||||
@@ -205,13 +245,9 @@ class DetailsFragment :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
R.id.imageView_cover -> {
|
R.id.imageView_cover -> {
|
||||||
val options = ActivityOptions.makeSceneTransitionAnimation(
|
val options = ActivityOptions.makeScaleUpAnimation(v, 0, 0, v.width, v.height)
|
||||||
requireActivity(),
|
|
||||||
binding.imageViewCover,
|
|
||||||
binding.imageViewCover.transitionName,
|
|
||||||
)
|
|
||||||
startActivity(
|
startActivity(
|
||||||
ImageActivity.newIntent(v.context, manga.largeCoverUrl ?: manga.coverUrl),
|
ImageActivity.newIntent(v.context, manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }),
|
||||||
options.toBundle()
|
options.toBundle()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -224,14 +260,16 @@ class DetailsFragment :
|
|||||||
if (viewModel.readingHistory.value == null) {
|
if (viewModel.readingHistory.value == null) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
v.showPopupMenu(R.menu.popup_read) {
|
val menu = PopupMenu(v.context, v)
|
||||||
when (it.itemId) {
|
menu.inflate(R.menu.popup_read)
|
||||||
|
menu.setOnMenuItemClickListener { menuItem ->
|
||||||
|
when (menuItem.itemId) {
|
||||||
R.id.action_read -> {
|
R.id.action_read -> {
|
||||||
val branch = viewModel.selectedBranchValue
|
val branch = viewModel.selectedBranchValue
|
||||||
startActivity(
|
startActivity(
|
||||||
ReaderActivity.newIntent(
|
ReaderActivity.newIntent(
|
||||||
context = context ?: return@showPopupMenu false,
|
context = context ?: return@setOnMenuItemClickListener false,
|
||||||
manga = viewModel.manga.value ?: return@showPopupMenu false,
|
manga = viewModel.manga.value ?: return@setOnMenuItemClickListener false,
|
||||||
state = viewModel.chapters.value?.firstOrNull { c ->
|
state = viewModel.chapters.value?.firstOrNull { c ->
|
||||||
c.chapter.branch == branch
|
c.chapter.branch == branch
|
||||||
}?.let { c ->
|
}?.let { c ->
|
||||||
@@ -244,6 +282,7 @@ class DetailsFragment :
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
menu.show()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
else -> return false
|
else -> return false
|
||||||
@@ -274,20 +313,20 @@ class DetailsFragment :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadCover(manga: Manga) {
|
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)
|
val request = ImageRequest.Builder(context ?: return)
|
||||||
.target(binding.imageViewCover)
|
.target(binding.imageViewCover)
|
||||||
if (currentCover != null) {
|
.data(imageUrl)
|
||||||
request.data(manga.largeCoverUrl ?: return)
|
.crossfade(true)
|
||||||
.placeholderMemoryCacheKey(CoilUtils.metadata(binding.imageViewCover)?.memoryCacheKey)
|
.referer(manga.publicUrl)
|
||||||
.fallback(currentCover)
|
|
||||||
} else {
|
|
||||||
request.crossfade(true)
|
|
||||||
.data(manga.coverUrl)
|
|
||||||
.fallback(R.drawable.ic_placeholder)
|
|
||||||
}
|
|
||||||
request.referer(manga.publicUrl)
|
|
||||||
.lifecycle(viewLifecycleOwner)
|
.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
|
package org.koitharu.kotatsu.details.ui
|
||||||
|
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.lifecycle.*
|
||||||
import androidx.lifecycle.asFlow
|
import kotlinx.coroutines.*
|
||||||
import androidx.lifecycle.asLiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import org.koitharu.kotatsu.R
|
||||||
import kotlinx.coroutines.plus
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
import org.koitharu.kotatsu.base.domain.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.base.domain.MangaIntent
|
import org.koitharu.kotatsu.base.domain.MangaIntent
|
||||||
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.exceptions.MangaNotFoundException
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
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.ChapterListItem
|
||||||
import org.koitharu.kotatsu.details.ui.model.toListItem
|
|
||||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
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.tracker.domain.TrackingRepository
|
||||||
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
import org.koitharu.kotatsu.utils.ext.iterator
|
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
|
||||||
import org.koitharu.kotatsu.utils.ext.mapToSet
|
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class DetailsViewModel(
|
class DetailsViewModel(
|
||||||
private val intent: MangaIntent,
|
intent: MangaIntent,
|
||||||
private val historyRepository: HistoryRepository,
|
private val historyRepository: HistoryRepository,
|
||||||
private val favouritesRepository: FavouritesRepository,
|
favouritesRepository: FavouritesRepository,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
private val trackingRepository: TrackingRepository,
|
private val trackingRepository: TrackingRepository,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
mangaDataRepository: MangaDataRepository,
|
||||||
|
private val bookmarksRepository: BookmarksRepository,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
private val delegate = MangaDetailsDelegate(
|
||||||
|
intent = intent,
|
||||||
|
settings = settings,
|
||||||
|
mangaDataRepository = mangaDataRepository,
|
||||||
|
historyRepository = historyRepository,
|
||||||
|
localMangaRepository = localMangaRepository,
|
||||||
|
)
|
||||||
|
|
||||||
private var loadingJob: Job
|
private var loadingJob: Job
|
||||||
private val mangaData = MutableStateFlow(intent.manga)
|
|
||||||
private val selectedBranch = MutableStateFlow<String?>(null)
|
|
||||||
|
|
||||||
private val history = mangaData.mapNotNull { it?.id }
|
val onShowToast = SingleLiveEvent<Int>()
|
||||||
.distinctUntilChanged()
|
|
||||||
.flatMapLatest { mangaId ->
|
|
||||||
historyRepository.observeOne(mangaId)
|
|
||||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
|
||||||
|
|
||||||
private val favourite = mangaData.mapNotNull { it?.id }
|
private val history = historyRepository.observeOne(delegate.mangaId)
|
||||||
.distinctUntilChanged()
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||||
.flatMapLatest { mangaId ->
|
|
||||||
favouritesRepository.observeCategoriesIds(mangaId).map { it.isNotEmpty() }
|
|
||||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
|
||||||
|
|
||||||
private val newChapters = mangaData.mapNotNull { it?.id }
|
private val favourite = favouritesRepository.observeCategoriesIds(delegate.mangaId).map { it.isNotEmpty() }
|
||||||
.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) }
|
|
||||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||||
|
|
||||||
val manga = mangaData.filterNotNull()
|
private val newChapters = viewModelScope.async(Dispatchers.Default) {
|
||||||
.asLiveData(viewModelScope.coroutineContext)
|
trackingRepository.getNewChaptersCount(delegate.mangaId)
|
||||||
val favouriteCategories = favourite
|
}
|
||||||
.asLiveData(viewModelScope.coroutineContext)
|
|
||||||
val newChaptersCount = newChapters
|
private val chaptersQuery = MutableStateFlow("")
|
||||||
.asLiveData(viewModelScope.coroutineContext)
|
|
||||||
val readingHistory = history
|
private val chaptersReversed = settings.observeAsFlow(AppSettings.KEY_REVERSE_CHAPTERS) { chaptersReverse }
|
||||||
.asLiveData(viewModelScope.coroutineContext)
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||||
val isChaptersReversed = chaptersReversed
|
|
||||||
.asLiveData(viewModelScope.coroutineContext)
|
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 onMangaRemoved = SingleLiveEvent<Manga>()
|
||||||
|
|
||||||
val branches = mangaData.map {
|
val branches: LiveData<List<String?>> = delegate.manga.map {
|
||||||
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
|
val chapters = it?.chapters ?: return@map emptyList()
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
chapters.mapToSet { x -> x.branch }.sortedWith(BranchComparator())
|
||||||
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
val selectedBranchIndex = combine(
|
val selectedBranchIndex = combine(
|
||||||
branches.asFlow(),
|
branches.asFlow(),
|
||||||
selectedBranch
|
delegate.selectedBranch
|
||||||
) { branches, selected ->
|
) { branches, selected ->
|
||||||
branches.indexOf(selected)
|
branches.indexOf(selected)
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
val hasChapters = mangaData.map {
|
val isChaptersEmpty: LiveData<Boolean> = delegate.manga.map { m ->
|
||||||
!(it?.chapters.isNullOrEmpty())
|
m != null && m.chapters.isNullOrEmpty()
|
||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
|
||||||
|
|
||||||
val chapters = combine(
|
val chapters = combine(
|
||||||
combine(
|
combine(
|
||||||
mangaData.map { it?.chapters.orEmpty() },
|
delegate.manga,
|
||||||
relatedManga,
|
delegate.relatedManga,
|
||||||
history.map { it?.chapterId },
|
history,
|
||||||
newChapters,
|
delegate.selectedBranch,
|
||||||
selectedBranch
|
) { manga, related, history, branch ->
|
||||||
) { chapters, related, currentId, newCount, branch ->
|
delegate.mapChapters(manga, related, history, newChapters.await(), 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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
chaptersReversed,
|
chaptersReversed,
|
||||||
chaptersQuery,
|
chaptersQuery,
|
||||||
@@ -123,7 +107,7 @@ class DetailsViewModel(
|
|||||||
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
|
||||||
|
|
||||||
val selectedBranchValue: String?
|
val selectedBranchValue: String?
|
||||||
get() = selectedBranch.value
|
get() = delegate.selectedBranch.value
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadingJob = doLoad()
|
loadingJob = doLoad()
|
||||||
@@ -134,8 +118,15 @@ class DetailsViewModel(
|
|||||||
loadingJob = doLoad()
|
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) {
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
|
||||||
|
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
|
||||||
val original = localMangaRepository.getRemoteManga(manga)
|
val original = localMangaRepository.getRemoteManga(manga)
|
||||||
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -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) {
|
fun setChaptersReversed(newValue: Boolean) {
|
||||||
settings.chaptersReverse = newValue
|
settings.chaptersReverse = newValue
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSelectedBranch(branch: String?) {
|
fun setSelectedBranch(branch: String?) {
|
||||||
selectedBranch.value = branch
|
delegate.selectedBranch.value = branch
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRemoteManga(): Manga? {
|
fun getRemoteManga(): Manga? {
|
||||||
return relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
|
return delegate.relatedManga.value?.takeUnless { it.source == MangaSource.LOCAL }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun performChapterSearch(query: String?) {
|
fun performChapterSearch(query: String?) {
|
||||||
@@ -162,7 +160,7 @@ class DetailsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onDownloadComplete(downloadedManga: Manga) {
|
fun onDownloadComplete(downloadedManga: Manga) {
|
||||||
val currentManga = mangaData.value ?: return
|
val currentManga = delegate.manga.value ?: return
|
||||||
if (currentManga.id != downloadedManga.id) {
|
if (currentManga.id != downloadedManga.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,135 +171,16 @@ class DetailsViewModel(
|
|||||||
runCatching {
|
runCatching {
|
||||||
localMangaRepository.getDetails(downloadedManga)
|
localMangaRepository.getDetails(downloadedManga)
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
relatedManga.value = it
|
delegate.relatedManga.value = it
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
if (BuildConfig.DEBUG) {
|
it.printStackTraceDebug()
|
||||||
it.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
|
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
|
||||||
var manga = mangaDataRepository.resolveIntent(intent)
|
delegate.doLoad()
|
||||||
?: 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {
|
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.BaseAdapter
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.utils.ext.replaceWith
|
import org.koitharu.kotatsu.parsers.util.replaceWith
|
||||||
|
|
||||||
class BranchesAdapter : BaseAdapter() {
|
class BranchesAdapter : BaseAdapter() {
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user