Compare commits
258 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06a0b5829b | ||
|
|
0ce2870c8b | ||
|
|
f59027666b | ||
|
|
8513bc6daf | ||
|
|
cceaefc896 | ||
|
|
881f154b5e | ||
|
|
34be5d16f2 | ||
|
|
e7e554648d | ||
|
|
89a4180b46 | ||
|
|
4e2e190547 | ||
|
|
3c557aae6c | ||
|
|
0b00a3675d | ||
|
|
8f20be6953 | ||
|
|
26875c01c6 | ||
|
|
4beb34c1a5 | ||
|
|
1d50ab00c4 | ||
|
|
299cd229ec | ||
|
|
b02f394cd4 | ||
|
|
7352f06564 | ||
|
|
1e4861367e | ||
|
|
bc3208946b | ||
|
|
d5fbb00676 | ||
|
|
7514362ca4 | ||
|
|
e76a04bea0 | ||
|
|
732a6e7c26 | ||
|
|
f3111dc636 | ||
|
|
e0e0cf4ecd | ||
|
|
50f302a7f8 | ||
|
|
500995a9d8 | ||
|
|
beaf5cc0d5 | ||
|
|
6377de470d | ||
|
|
dec45f7851 | ||
|
|
dbada34a43 | ||
|
|
b62467964e | ||
|
|
3249e10931 | ||
|
|
0d5229b112 | ||
|
|
d0ed1fb85f | ||
|
|
9e5664da3a | ||
|
|
35c158d35a | ||
|
|
464f24e9f0 | ||
|
|
c8a8203c39 | ||
|
|
b414758f32 | ||
|
|
1181860e41 | ||
|
|
e35521f16f | ||
|
|
5fb8ff53f9 | ||
|
|
a66283d035 | ||
|
|
a1ba0b8c21 | ||
|
|
f3b42b9a42 | ||
|
|
aa2f2c17fc | ||
|
|
ebc17b645b | ||
|
|
cc14e1abcf | ||
|
|
b1b474e2e7 | ||
|
|
8ca3bece5d | ||
|
|
90bd9023d5 | ||
|
|
986627f24d | ||
|
|
cf2b8e2481 | ||
|
|
b9435de5cd | ||
|
|
861c21faea | ||
|
|
9b4d014b21 | ||
|
|
c6da7de699 | ||
|
|
ef3aa40acc | ||
|
|
07af3ea703 | ||
|
|
391c8ab649 | ||
|
|
6b1885c89d | ||
|
|
8423b48fb9 | ||
|
|
803c825d91 | ||
|
|
6a9682a077 | ||
|
|
9197b9cc3a | ||
|
|
02ea804874 | ||
|
|
c424466198 | ||
|
|
18b312dde6 | ||
|
|
f78262b1a0 | ||
|
|
c557a51c4d | ||
|
|
8995762935 | ||
|
|
ed2664db78 | ||
|
|
f5a5e53b5a | ||
|
|
9ef961590d | ||
|
|
9b569615ee | ||
|
|
f48cf2efe4 | ||
|
|
18094a310c | ||
|
|
320c49a831 | ||
|
|
2a971d5dae | ||
|
|
4467e79ae6 | ||
|
|
c68b180bf6 | ||
|
|
5f879f6c83 | ||
|
|
aeb3732d75 | ||
|
|
6292a0fd6b | ||
|
|
8985b4135d | ||
|
|
f8a5397542 | ||
|
|
5f51041220 | ||
|
|
5a14412b62 | ||
|
|
be012f631a | ||
|
|
0165f43603 | ||
|
|
55801a1488 | ||
|
|
77103f016f | ||
|
|
6b6719a259 | ||
|
|
822642abb0 | ||
|
|
260745fb95 | ||
|
|
024ec0388f | ||
|
|
5345998eec | ||
|
|
3d56190e71 | ||
|
|
954431d0a5 | ||
|
|
afec63b443 | ||
|
|
ac5b29c35a | ||
|
|
59f5578b66 | ||
|
|
391dbb4237 | ||
|
|
7d4505eb78 | ||
|
|
e6ceb20cf7 | ||
|
|
8004f8c093 | ||
|
|
61bf2abb6c | ||
|
|
d9612f3427 | ||
|
|
435c3824f7 | ||
|
|
c846693570 | ||
|
|
123937cd01 | ||
|
|
9f56554313 | ||
|
|
f8687bb697 | ||
|
|
43d3a2cc6a | ||
|
|
a95db6ed21 | ||
|
|
fd0bb57338 | ||
|
|
6b94bc2632 | ||
|
|
c8b91599c6 | ||
|
|
3a8b0f9e93 | ||
|
|
17a0725666 | ||
|
|
3be7848ad9 | ||
|
|
08202c11a3 | ||
|
|
5ef907d046 | ||
|
|
c3776ea3c6 | ||
|
|
a624bffea3 | ||
|
|
8f38b4fe30 | ||
|
|
71a2de5358 | ||
|
|
5478f8fb59 | ||
|
|
5155c9a33d | ||
|
|
1d1e49123a | ||
|
|
f7a461a9d8 | ||
|
|
3a02d22e02 | ||
|
|
2b8a29e2a6 | ||
|
|
bc68441585 | ||
|
|
1cc51b6a88 | ||
|
|
fd5aca7252 | ||
|
|
e447245fac | ||
|
|
5af0ee1c69 | ||
|
|
c02d1641ab | ||
|
|
f55c525c8a | ||
|
|
a42fc87a9a | ||
|
|
6b6905fd71 | ||
|
|
b7f57856db | ||
|
|
1d6d626b62 | ||
|
|
d93ff92cc9 | ||
|
|
8eda113f3b | ||
|
|
3916c2619e | ||
|
|
1d3e8e55ca | ||
|
|
2c3b4f29eb | ||
|
|
ee530002b6 | ||
|
|
59d530e0dc | ||
|
|
52a132caed | ||
|
|
379d2dd8d4 | ||
|
|
f8cefa3e8d | ||
|
|
5e1eda850c | ||
|
|
18cc0ad0fb | ||
|
|
11dd49c626 | ||
|
|
2ad8ab0258 | ||
|
|
4f8c5325a4 | ||
|
|
6e181a59a3 | ||
|
|
7a7d20dbf4 | ||
|
|
83d5f8e378 | ||
|
|
5ac9bad728 | ||
|
|
a090965a2d | ||
|
|
1e376754bc | ||
|
|
2cdbe52056 | ||
|
|
1e09ac3ecb | ||
|
|
acc76c931a | ||
|
|
59c12d35c1 | ||
|
|
0e3cad1af1 | ||
|
|
ba8766b32d | ||
|
|
35421cb71e | ||
|
|
8cecd9a0e2 | ||
|
|
523057f3e1 | ||
|
|
337d196bc3 | ||
|
|
c3b4c032bb | ||
|
|
4590c753ed | ||
|
|
9733101f0c | ||
|
|
8cd71cc98d | ||
|
|
42748d9c98 | ||
|
|
8043574314 | ||
|
|
44d1fdb9d3 | ||
|
|
bc7054de4a | ||
|
|
4971e8ab0f | ||
|
|
df038b1edb | ||
|
|
7e7aabc1d1 | ||
|
|
9605ff89fb | ||
|
|
4ed177d29f | ||
|
|
61cefefd10 | ||
|
|
9f965c5269 | ||
|
|
0c713cb799 | ||
|
|
6d3f8cbb3b | ||
|
|
05739bb5b3 | ||
|
|
47f0bbee17 | ||
|
|
dd77926dcb | ||
|
|
1b76f21507 | ||
|
|
fe21af5443 | ||
|
|
0b0373021e | ||
|
|
d641e7933d | ||
|
|
d8efe374a8 | ||
|
|
506a8b6e90 | ||
|
|
d81173bf76 | ||
|
|
896452a096 | ||
|
|
35aa4d5e8f | ||
|
|
4d4c9c7a48 | ||
|
|
b667e32598 | ||
|
|
c987fc234b | ||
|
|
8142a6811b | ||
|
|
3e36e1e11c | ||
|
|
30aaca6341 | ||
|
|
43b34a7bca | ||
|
|
b23008d0ae | ||
|
|
5a368b27bb | ||
|
|
fe3f95d160 | ||
|
|
de1a297338 | ||
|
|
d6350afe3a | ||
|
|
ec048c70f1 | ||
|
|
282c1b51f7 | ||
|
|
d6b6ce1bcd | ||
|
|
f48444dcf6 | ||
|
|
15ba766643 | ||
|
|
a0dbbcb350 | ||
|
|
f72bba9557 | ||
|
|
207791aa3e | ||
|
|
6319997716 | ||
|
|
b70c1da54b | ||
|
|
621cb19c5b | ||
|
|
b528b7b3c1 | ||
|
|
9a1bb6f6fc | ||
|
|
37f9c4b9f6 | ||
|
|
d0084e50e7 | ||
|
|
088576cc9d | ||
|
|
f0ba42b518 | ||
|
|
33366e63db | ||
|
|
0f39b313c0 | ||
|
|
abcbb940d3 | ||
|
|
f4fec709fc | ||
|
|
a8c6f6a1ce | ||
|
|
3a6794a50e | ||
|
|
aea3328f2d | ||
|
|
c225e90626 | ||
|
|
de8500d705 | ||
|
|
2d41cf14e2 | ||
|
|
1777528fcb | ||
|
|
43618ed224 | ||
|
|
2229439547 | ||
|
|
98abffa67b | ||
|
|
aafefffc27 | ||
|
|
add5c7dc17 | ||
|
|
40584cb6f5 | ||
|
|
e549d141a4 | ||
|
|
4199f54241 | ||
|
|
e511c3cc97 | ||
|
|
33dbca1bc9 | ||
|
|
40778a88dd |
@@ -4,7 +4,7 @@ root = true
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = tab
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
tab_width = 4
|
||||
|
||||
16
.github/workflows/trigger-site-deploy.yml
vendored
Normal file
16
.github/workflows/trigger-site-deploy.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Trigger Site Update
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
trigger-site:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send repository_dispatch to site-repo
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.SITE_REPO_TOKEN }}
|
||||
repository: KotatsuApp/website
|
||||
event-type: app-release
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
/.idea/dictionaries
|
||||
/.idea/modules.xml
|
||||
/.idea/misc.xml
|
||||
/.idea/markdown.xml
|
||||
/.idea/discord.xml
|
||||
/.idea/compiler.xml
|
||||
/.idea/workspace.xml
|
||||
@@ -26,4 +27,4 @@
|
||||
.cxx
|
||||
/.idea/deviceManager.xml
|
||||
/.kotlin/
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
/.idea/AndroidProjectSystem.xml
|
||||
|
||||
2
.idea/.gitignore
generated
vendored
2
.idea/.gitignore
generated
vendored
@@ -3,3 +3,5 @@
|
||||
/workspace.xml
|
||||
/migrations.xml
|
||||
/runConfigurations.xml
|
||||
/appInsightsSettings.xml
|
||||
/kotlinCodeInsightSettings.xml
|
||||
|
||||
26
.idea/appInsightsSettings.xml
generated
Normal file
26
.idea/appInsightsSettings.xml
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="PLACEHOLDER" />
|
||||
<option name="mobileSdkAppId" value="" />
|
||||
<option name="projectId" value="" />
|
||||
<option name="projectNumber" value="" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
74
.idea/codeStyles/Project.xml
generated
74
.idea/codeStyles/Project.xml
generated
@@ -1,9 +1,7 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="OTHER_INDENT_OPTIONS">
|
||||
<value>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</value>
|
||||
<value />
|
||||
</option>
|
||||
<AndroidXmlCodeStyleSettings>
|
||||
<option name="LAYOUT_SETTINGS">
|
||||
@@ -22,40 +20,46 @@
|
||||
</value>
|
||||
</option>
|
||||
</AndroidXmlCodeStyleSettings>
|
||||
<JavaCodeStyleSettings>
|
||||
<option name="IMPORT_LAYOUT_TABLE">
|
||||
<value>
|
||||
<package name="android" withSubpackages="true" static="true" />
|
||||
<package name="androidx" withSubpackages="true" static="true" />
|
||||
<package name="com" withSubpackages="true" static="true" />
|
||||
<package name="junit" withSubpackages="true" static="true" />
|
||||
<package name="net" withSubpackages="true" static="true" />
|
||||
<package name="org" withSubpackages="true" static="true" />
|
||||
<package name="java" withSubpackages="true" static="true" />
|
||||
<package name="javax" withSubpackages="true" static="true" />
|
||||
<package name="" withSubpackages="true" static="true" />
|
||||
<emptyLine />
|
||||
<package name="android" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="androidx" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="com" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="junit" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="net" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="org" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="java" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="javax" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
<package name="" withSubpackages="true" static="false" />
|
||||
<emptyLine />
|
||||
</value>
|
||||
</option>
|
||||
</JavaCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||
<option name="ALLOW_TRAILING_COMMA_COLLECTION_LITERAL_EXPRESSION" value="true" />
|
||||
<option name="ALLOW_TRAILING_COMMA_VALUE_ARGUMENT_LIST" value="true" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="CMake">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Groovy">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JAVA">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JSON">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="ObjectiveC">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Shell Script">
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
@@ -64,7 +68,6 @@
|
||||
<codeStyleSettings language="XML">
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
@@ -179,9 +182,6 @@
|
||||
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
<option name="BLOCK_COMMENT_AT_FIRST_COLUMN" value="false" />
|
||||
<option name="LINE_COMMENT_ADD_SPACE" value="true" />
|
||||
<indentOptions>
|
||||
<option name="USE_TAB_CHARACTER" value="true" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
4
.idea/gradle.xml
generated
4
.idea/gradle.xml
generated
@@ -6,7 +6,7 @@
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-21" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
@@ -16,4 +16,4 @@
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -10,6 +10,6 @@
|
||||
</option>
|
||||
</component>
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
||||
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||
|
||||
### Download
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||
* Password / fingerprint-protected access to the app
|
||||
* Automatically sync app data with other devices on the same account
|
||||
* Support for older devices running Android 5.0+
|
||||
* Support for older devices running Android 6.0+
|
||||
|
||||
</div>
|
||||
|
||||
@@ -112,6 +112,6 @@ You may copy, distribute and modify the software as long as you track changes/da
|
||||
|
||||
<div align="left">
|
||||
|
||||
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
|
||||
The developers of this application do not have any affiliation with the content available in the app and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -8,19 +8,21 @@ plugins {
|
||||
id 'dagger.hilt.android.plugin'
|
||||
id 'androidx.room'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||
// enable if needed
|
||||
// id 'dev.reformator.stacktracedecoroutinator'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 35
|
||||
compileSdk = 36
|
||||
buildToolsVersion = '35.0.0'
|
||||
namespace = 'org.koitharu.kotatsu'
|
||||
|
||||
defaultConfig {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 35
|
||||
versionCode = 1021
|
||||
versionName = '9.0-b1'
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 1033
|
||||
versionName = '9.4.1'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -30,6 +32,12 @@ android {
|
||||
// https://issuetracker.google.com/issues/408030127
|
||||
generateLocaleConfig false
|
||||
}
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localProperties.load(new FileInputStream(localPropertiesFile))
|
||||
}
|
||||
resValue 'string', 'tg_backup_bot_token', localProperties.getProperty('tg_backup_bot_token', '')
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
@@ -79,6 +87,7 @@ android {
|
||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
|
||||
'-Xjspecify-annotations=strict',
|
||||
'-Xannotation-default-target=first-only',
|
||||
'-Xtype-enhancement-improvements-strict-mode'
|
||||
]
|
||||
}
|
||||
@@ -172,6 +181,7 @@ dependencies {
|
||||
implementation libs.ssiv
|
||||
implementation libs.disk.lru.cache
|
||||
implementation libs.markwon
|
||||
implementation libs.kizzyrpc
|
||||
|
||||
implementation libs.acra.http
|
||||
implementation libs.acra.dialog
|
||||
@@ -179,6 +189,7 @@ dependencies {
|
||||
implementation libs.conscrypt.android
|
||||
|
||||
debugImplementation libs.leakcanary.android
|
||||
nightlyImplementation libs.leakcanary.android
|
||||
debugImplementation libs.workinspector
|
||||
|
||||
testImplementation libs.junit
|
||||
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@@ -8,8 +8,7 @@
|
||||
public static void checkParameterIsNotNull(...);
|
||||
public static void checkNotNullParameter(...);
|
||||
}
|
||||
-keep public class ** extends org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
-keep class org.koitharu.kotatsu.core.db.entity.* { *; }
|
||||
|
||||
-dontwarn okhttp3.internal.platform.**
|
||||
-dontwarn org.conscrypt.**
|
||||
-dontwarn org.bouncycastle.**
|
||||
@@ -17,8 +16,10 @@
|
||||
-dontwarn com.google.j2objc.annotations.**
|
||||
-dontwarn coil3.PlatformContext
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||
-keep class org.koitharu.kotatsu.settings.about.changelog.ChangelogFragment
|
||||
|
||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
|
||||
-keep class org.jsoup.parser.Tag
|
||||
|
||||
@@ -41,8 +41,8 @@ class KotatsuApp : BaseApp() {
|
||||
detectNetwork()
|
||||
detectDiskWrites()
|
||||
detectCustomSlowCalls()
|
||||
detectResourceMismatches()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
@@ -56,7 +56,9 @@ class KotatsuApp : BaseApp() {
|
||||
detectLeakedSqlLiteObjects()
|
||||
detectLeakedClosableObjects()
|
||||
detectLeakedRegistrationObjects()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
detectContentUriWithoutPermission()
|
||||
}
|
||||
detectFileUriExposure()
|
||||
penaltyLog()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.model.TestMangaSource
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
/*
|
||||
This class is for parser development and testing purposes
|
||||
You can open it in the app via Settings -> Debug
|
||||
*/
|
||||
class TestMangaRepository(
|
||||
@Suppress("unused") private val loaderContext: MangaLoaderContext,
|
||||
cache: MemoryContentCache
|
||||
) : CachingMangaRepository(cache) {
|
||||
|
||||
override val source = TestMangaSource
|
||||
|
||||
override val sortOrders: Set<SortOrder> = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = sortOrders.first()
|
||||
set(value) = Unit
|
||||
|
||||
override val filterCapabilities = MangaListFilterCapabilities()
|
||||
|
||||
override suspend fun getFilterOptions() = MangaListFilterOptions()
|
||||
|
||||
override suspend fun getList(
|
||||
offset: Int,
|
||||
order: SortOrder?,
|
||||
filter: MangaListFilter?
|
||||
): List<Manga> = TODO("Get manga list by filter")
|
||||
|
||||
override suspend fun getDetailsImpl(
|
||||
manga: Manga
|
||||
): Manga = TODO("Fetch manga details")
|
||||
|
||||
override suspend fun getPagesImpl(
|
||||
chapter: MangaChapter
|
||||
): List<MangaPage> = TODO("Get pages for specific chapter")
|
||||
|
||||
override suspend fun getPageUrl(
|
||||
page: MangaPage
|
||||
): String = TODO("Return direct url of page image or page.url if it is already a direct url")
|
||||
|
||||
override suspend fun getRelatedMangaImpl(
|
||||
seed: Manga
|
||||
): List<Manga> = TODO("Get list of related manga. This method is optional and parser library has a default implementation")
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.preference.Preference
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.KotatsuApp
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.TestMangaSource
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
|
||||
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
|
||||
class DebugSettingsFragment : BasePreferenceFragment(R.string.debug), Preference.OnPreferenceChangeListener,
|
||||
Preference.OnPreferenceClickListener {
|
||||
|
||||
private val application
|
||||
get() = requireContext().applicationContext as KotatsuApp
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_debug)
|
||||
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.let { pref ->
|
||||
pref.isChecked = application.isLeakCanaryEnabled
|
||||
pref.onPreferenceChangeListener = this
|
||||
pref.onContainerClickListener = this
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
findPreference<SplitSwitchPreference>(KEY_LEAK_CANARY)?.isChecked = application.isLeakCanaryEnabled
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) {
|
||||
KEY_WORK_INSPECTOR -> {
|
||||
startActivity(WorkInspector.getIntent(preference.context))
|
||||
true
|
||||
}
|
||||
|
||||
KEY_TEST_PARSER -> {
|
||||
router.openList(TestMangaSource, null, null)
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
override fun onPreferenceClick(preference: Preference): Boolean = when (preference.key) {
|
||||
KEY_LEAK_CANARY -> {
|
||||
startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
|
||||
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean = when (preference.key) {
|
||||
KEY_LEAK_CANARY -> {
|
||||
application.isLeakCanaryEnabled = newValue as Boolean
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val KEY_LEAK_CANARY = "leak_canary"
|
||||
const val KEY_WORK_INSPECTOR = "work_inspector"
|
||||
const val KEY_TEST_PARSER = "test_parser"
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.core.view.MenuProvider
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import leakcanary.LeakCanary
|
||||
import org.koitharu.kotatsu.KotatsuApp
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.workinspector.WorkInspector
|
||||
|
||||
class SettingsMenuProvider(
|
||||
private val context: Context,
|
||||
) : MenuProvider {
|
||||
|
||||
private val application: KotatsuApp
|
||||
get() = context.applicationContext as KotatsuApp
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_settings, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
super.onPrepareMenu(menu)
|
||||
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
|
||||
menu.findItem(R.id.action_ssiv_debug).isChecked = SubsamplingScaleImageView.isDebug
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_leaks -> {
|
||||
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_works -> {
|
||||
context.startActivity(WorkInspector.getIntent(context))
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_leakcanary -> {
|
||||
val checked = !menuItem.isChecked
|
||||
menuItem.isChecked = checked
|
||||
application.isLeakCanaryEnabled = checked
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_ssiv_debug -> {
|
||||
val checked = !menuItem.isChecked
|
||||
menuItem.isChecked = checked
|
||||
SubsamplingScaleImageView.isDebug = checked
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
12
app/src/debug/res/drawable/ic_debug.xml
Normal file
12
app/src/debug/res/drawable/ic_debug.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000"
|
||||
android:pathData="M20,8H17.19C16.74,7.2 16.12,6.5 15.37,6L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.05,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6C7.87,6.5 7.26,7.21 6.81,8H4V10H6.09C6.03,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.03,15.67 6.09,16H4V18H6.81C8.47,20.87 12.14,21.84 15,20.18C15.91,19.66 16.67,18.9 17.19,18H20V16H17.91C17.97,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.97,10.33 17.91,10H20V8M16,15A4,4 0 0,1 12,19A4,4 0 0,1 8,15V11A4,4 0 0,1 12,7A4,4 0 0,1 16,11V15M14,10V12H10V10H14M10,14H14V16H10V14Z" />
|
||||
</vector>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_ssiv_debug"
|
||||
android:checkable="true"
|
||||
android:title="SSIV debug"
|
||||
app:showAsAction="never"
|
||||
tools:ignore="HardcodedText" />
|
||||
<item
|
||||
android:id="@+id/action_leakcanary"
|
||||
android:checkable="true"
|
||||
android:title="LeakCanary"
|
||||
app:showAsAction="never"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_leaks"
|
||||
android:title="@string/leak_canary_display_activity_label"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_works"
|
||||
android:title="@string/wi_lib_name"
|
||||
app:showAsAction="never" />
|
||||
|
||||
</menu>
|
||||
23
app/src/debug/res/xml/pref_debug.xml
Normal file
23
app/src/debug/res/xml/pref_debug.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
|
||||
android:key="leak_canary"
|
||||
android:persistent="false"
|
||||
android:title="LeakCanary" />
|
||||
|
||||
<Preference
|
||||
android:key="work_inspector"
|
||||
android:persistent="false"
|
||||
android:title="@string/wi_lib_name" />
|
||||
|
||||
<Preference
|
||||
android:key="test_parser"
|
||||
android:persistent="false"
|
||||
android:title="@string/test_parser"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
|
||||
</androidx.preference.PreferenceScreen>
|
||||
11
app/src/debug/res/xml/pref_root_debug.xml
Normal file
11
app/src/debug/res/xml/pref_root_debug.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.DebugSettingsFragment"
|
||||
android:icon="@drawable/ic_debug"
|
||||
android:key="debug"
|
||||
android:title="@string/debug" />
|
||||
|
||||
</androidx.preference.PreferenceScreen>
|
||||
@@ -51,9 +51,11 @@
|
||||
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
||||
android:extractNativeLibs="true"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:restoreAnyVersion="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
@@ -287,6 +289,9 @@
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.scrobbling.discord.ui.DiscordAuthActivity"
|
||||
android:label="@string/discord" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
@@ -404,6 +409,13 @@
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name="org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler$DiscardReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.koitharu.kotatsu.CAPTCHA_DISCARD" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver
|
||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
|
||||
android:exported="true"
|
||||
|
||||
@@ -30,21 +30,19 @@ constructor(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
) {
|
||||
val oldDetails =
|
||||
if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails =
|
||||
if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails, replaceExisting = true)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
@@ -101,11 +99,11 @@ constructor(
|
||||
mangaId = newDetails.id,
|
||||
rating = prevInfo.rating,
|
||||
status =
|
||||
prevInfo.status ?: when {
|
||||
newHistory == null -> ScrobblingStatus.PLANNED
|
||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||
else -> ScrobblingStatus.READING
|
||||
},
|
||||
prevInfo.status ?: when {
|
||||
newHistory == null -> ScrobblingStatus.PLANNED
|
||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||
else -> ScrobblingStatus.READING
|
||||
},
|
||||
comment = prevInfo.comment,
|
||||
)
|
||||
if (newHistory != null) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase.NoAlternativesException
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
@@ -47,7 +48,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
}
|
||||
|
||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||
@@ -58,7 +59,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
val result = runCatchingCancellable {
|
||||
autoFixUseCase.invoke(mangaId)
|
||||
}
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = buildNotification(startId, result)
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
@@ -67,7 +68,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
|
||||
notificationManager.notify(TAG, startId, notification)
|
||||
}
|
||||
@@ -75,7 +76,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private fun startForeground(jobContext: IntentJobContext) {
|
||||
val title = applicationContext.getString(R.string.fixing_manga)
|
||||
val title = getString(R.string.fixing_manga)
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
|
||||
.setName(title)
|
||||
.setShowBadge(false)
|
||||
@@ -85,7 +86,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setDefaults(0)
|
||||
@@ -97,7 +98,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.addAction(
|
||||
appcompatR.drawable.abc_ic_clear_material,
|
||||
applicationContext.getString(android.R.string.cancel),
|
||||
getString(android.R.string.cancel),
|
||||
jobContext.getCancelIntent(),
|
||||
)
|
||||
.build()
|
||||
@@ -110,7 +111,7 @@ class AutoFixService : CoroutineIntentService() {
|
||||
}
|
||||
|
||||
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification {
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
@@ -119,17 +120,17 @@ class AutoFixService : CoroutineIntentService() {
|
||||
if (replacement != null) {
|
||||
notification.setLargeIcon(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(applicationContext)
|
||||
ImageRequest.Builder(this)
|
||||
.data(replacement.coverUrl)
|
||||
.mangaSourceExtra(replacement.source)
|
||||
.build(),
|
||||
).toBitmapOrNull(),
|
||||
)
|
||||
notification.setSubText(replacement.title)
|
||||
val intent = AppRouter.detailsIntent(applicationContext, replacement)
|
||||
val intent = AppRouter.detailsIntent(this, replacement)
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
this,
|
||||
replacement.id.toInt(),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
@@ -143,35 +144,35 @@ class AutoFixService : CoroutineIntentService() {
|
||||
},
|
||||
)
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||
.setContentTitle(getString(R.string.fixed))
|
||||
.setContentText(
|
||||
applicationContext.getString(
|
||||
getString(
|
||||
R.string.manga_replaced,
|
||||
seed.title,
|
||||
seed.source.getTitle(applicationContext),
|
||||
seed.source.getTitle(this),
|
||||
replacement.title,
|
||||
replacement.source.getTitle(applicationContext),
|
||||
replacement.source.getTitle(this),
|
||||
),
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_stat_done)
|
||||
} else {
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
|
||||
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
|
||||
.setContentTitle(getString(R.string.fixing_manga))
|
||||
.setContentText(getString(R.string.no_fix_required, seed.title))
|
||||
.setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
notification
|
||||
.setContentTitle(applicationContext.getString(R.string.error_occurred))
|
||||
.setContentTitle(getString(R.string.error_occurred))
|
||||
.setContentText(
|
||||
if (error is AutoFixUseCase.NoAlternativesException) {
|
||||
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||
if (error is NoAlternativesException) {
|
||||
getString(R.string.no_alternatives_found, error.seed.manga.title)
|
||||
} else {
|
||||
error.getDisplayMessage(applicationContext.resources)
|
||||
error.getDisplayMessage(resources)
|
||||
},
|
||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
ErrorReporterReceiver.getNotificationAction(
|
||||
context = applicationContext,
|
||||
context = this,
|
||||
e = error,
|
||||
notificationId = startId,
|
||||
notificationTag = TAG,
|
||||
|
||||
@@ -26,12 +26,17 @@ import org.koitharu.kotatsu.backups.data.model.CategoryBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.MangaBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.ScrobblingBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.SourceBackup
|
||||
import org.koitharu.kotatsu.backups.data.model.StatisticBackup
|
||||
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.filter.data.PersistableFilter
|
||||
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
import java.io.InputStream
|
||||
@@ -43,220 +48,267 @@ import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class BackupRepository @Inject constructor(
|
||||
private val database: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
private val tapGridSettings: TapGridSettings,
|
||||
private val database: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
private val tapGridSettings: TapGridSettings,
|
||||
private val mangaSourcesRepository: MangaSourcesRepository,
|
||||
private val savedFiltersRepository: SavedFiltersRepository,
|
||||
) {
|
||||
|
||||
private val json = Json {
|
||||
allowSpecialFloatingPointValues = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
useAlternativeNames = false
|
||||
}
|
||||
private val json = Json {
|
||||
allowSpecialFloatingPointValues = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
useAlternativeNames = false
|
||||
}
|
||||
|
||||
suspend fun createBackup(
|
||||
output: ZipOutputStream,
|
||||
progress: FlowCollector<Progress>?,
|
||||
) {
|
||||
progress?.emit(Progress.INDETERMINATE)
|
||||
var commonProgress = Progress(0, BackupSection.entries.size)
|
||||
for (section in BackupSection.entries) {
|
||||
when (section) {
|
||||
BackupSection.INDEX -> output.writeJsonArray(
|
||||
section = BackupSection.INDEX,
|
||||
data = flowOf(BackupIndex()),
|
||||
serializer = serializer(),
|
||||
)
|
||||
suspend fun createBackup(
|
||||
output: ZipOutputStream,
|
||||
progress: FlowCollector<Progress>?,
|
||||
) {
|
||||
progress?.emit(Progress.INDETERMINATE)
|
||||
var commonProgress = Progress(0, BackupSection.entries.size)
|
||||
for (section in BackupSection.entries) {
|
||||
when (section) {
|
||||
BackupSection.INDEX -> output.writeJsonArray(
|
||||
section = BackupSection.INDEX,
|
||||
data = flowOf(BackupIndex()),
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.HISTORY -> output.writeJsonArray(
|
||||
section = BackupSection.HISTORY,
|
||||
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
BackupSection.HISTORY -> output.writeJsonArray(
|
||||
section = BackupSection.HISTORY,
|
||||
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.CATEGORIES -> output.writeJsonArray(
|
||||
section = BackupSection.CATEGORIES,
|
||||
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
BackupSection.CATEGORIES -> output.writeJsonArray(
|
||||
section = BackupSection.CATEGORIES,
|
||||
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.FAVOURITES -> output.writeJsonArray(
|
||||
section = BackupSection.FAVOURITES,
|
||||
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
BackupSection.FAVOURITES -> output.writeJsonArray(
|
||||
section = BackupSection.FAVOURITES,
|
||||
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.SETTINGS -> output.writeString(
|
||||
section = BackupSection.SETTINGS,
|
||||
data = dumpSettings(),
|
||||
)
|
||||
BackupSection.SETTINGS -> output.writeString(
|
||||
section = BackupSection.SETTINGS,
|
||||
data = dumpSettings(),
|
||||
)
|
||||
|
||||
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
||||
section = BackupSection.SETTINGS_READER_GRID,
|
||||
data = dumpReaderGridSettings(),
|
||||
)
|
||||
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
||||
section = BackupSection.SETTINGS_READER_GRID,
|
||||
data = dumpReaderGridSettings(),
|
||||
)
|
||||
|
||||
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
||||
section = BackupSection.BOOKMARKS,
|
||||
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
||||
section = BackupSection.BOOKMARKS,
|
||||
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.SOURCES -> output.writeJsonArray(
|
||||
section = BackupSection.SOURCES,
|
||||
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
}
|
||||
BackupSection.SOURCES -> output.writeJsonArray(
|
||||
section = BackupSection.SOURCES,
|
||||
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
suspend fun restoreBackup(
|
||||
input: ZipInputStream,
|
||||
sections: Set<BackupSection>,
|
||||
progress: FlowCollector<Progress>?,
|
||||
): CompositeResult {
|
||||
progress?.emit(Progress.INDETERMINATE)
|
||||
var commonProgress = Progress(0, sections.size)
|
||||
var entry = input.nextEntry
|
||||
var result = CompositeResult.EMPTY
|
||||
while (entry != null) {
|
||||
val section = BackupSection.of(entry)
|
||||
if (section in sections) {
|
||||
result = result + when (section) {
|
||||
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
|
||||
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getHistoryDao().upsert(it.toEntity())
|
||||
}
|
||||
BackupSection.SCROBBLING -> output.writeJsonArray(
|
||||
section = BackupSection.SCROBBLING,
|
||||
data = database.getScrobblingDao().dumpEnabled().map { ScrobblingBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||
}
|
||||
BackupSection.STATS -> output.writeJsonArray(
|
||||
section = BackupSection.STATS,
|
||||
data = database.getStatsDao().dumpEnabled().map { StatisticBackup(it) },
|
||||
serializer = serializer(),
|
||||
)
|
||||
|
||||
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getFavouritesDao().upsert(it.toEntity())
|
||||
}
|
||||
BackupSection.SAVED_FILTERS -> {
|
||||
val sources = mangaSourcesRepository.getEnabledSources()
|
||||
val filters = sources.flatMap { source ->
|
||||
savedFiltersRepository.getAll(source)
|
||||
}
|
||||
output.writeJsonArray(
|
||||
section = BackupSection.SAVED_FILTERS,
|
||||
data = filters.asFlow(),
|
||||
serializer = serializer(),
|
||||
)
|
||||
}
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
}
|
||||
|
||||
BackupSection.SETTINGS -> input.readMap().let {
|
||||
settings.upsertAll(it)
|
||||
CompositeResult.success()
|
||||
}
|
||||
suspend fun restoreBackup(
|
||||
input: ZipInputStream,
|
||||
sections: Set<BackupSection>,
|
||||
progress: FlowCollector<Progress>?,
|
||||
): CompositeResult {
|
||||
progress?.emit(Progress.INDETERMINATE)
|
||||
var commonProgress = Progress(0, sections.size)
|
||||
var entry = input.nextEntry
|
||||
var result = CompositeResult.EMPTY
|
||||
while (entry != null) {
|
||||
val section = BackupSection.of(entry)
|
||||
if (section in sections) {
|
||||
result += when (section) {
|
||||
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
|
||||
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getHistoryDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||
tapGridSettings.upsertAll(it)
|
||||
CompositeResult.success()
|
||||
}
|
||||
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||
}
|
||||
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getFavouritesDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||
getSourcesDao().upsert(it.toEntity())
|
||||
}
|
||||
BackupSection.SETTINGS -> input.readMap().let {
|
||||
settings.upsertAll(it)
|
||||
CompositeResult.success()
|
||||
}
|
||||
|
||||
null -> CompositeResult.EMPTY // skip unknown entries
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
}
|
||||
input.closeEntry()
|
||||
entry = input.nextEntry
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
return result
|
||||
}
|
||||
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||
tapGridSettings.upsertAll(it)
|
||||
CompositeResult.success()
|
||||
}
|
||||
|
||||
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
||||
section: BackupSection,
|
||||
data: Flow<T>,
|
||||
serializer: SerializationStrategy<T>,
|
||||
) {
|
||||
data.onStart {
|
||||
putNextEntry(ZipEntry(section.entryName))
|
||||
write("[")
|
||||
}.onCompletion { error ->
|
||||
if (error == null) {
|
||||
write("]")
|
||||
}
|
||||
closeEntry()
|
||||
flush()
|
||||
}.collectIndexed { index, value ->
|
||||
if (index > 0) {
|
||||
write(",")
|
||||
}
|
||||
json.encodeToStream(serializer, value, this)
|
||||
}
|
||||
}
|
||||
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||
upsertManga(it.manga)
|
||||
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||
}
|
||||
|
||||
private fun <T> InputStream.readJsonArray(
|
||||
serializer: DeserializationStrategy<T>,
|
||||
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||
getSourcesDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
private fun InputStream.readMap(): Map<String, Any?> {
|
||||
val jo = JSONArray(readString()).getJSONObject(0)
|
||||
val map = ArrayMap<String, Any?>(jo.length())
|
||||
val keys = jo.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
map[key] = jo.get(key)
|
||||
}
|
||||
return map
|
||||
}
|
||||
BackupSection.SCROBBLING -> input.readJsonArray<ScrobblingBackup>(serializer()).restoreToDb {
|
||||
getScrobblingDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.writeString(
|
||||
section: BackupSection,
|
||||
data: String,
|
||||
) {
|
||||
putNextEntry(ZipEntry(section.entryName))
|
||||
try {
|
||||
write("[")
|
||||
write(data)
|
||||
write("]")
|
||||
} finally {
|
||||
closeEntry()
|
||||
flush()
|
||||
}
|
||||
}
|
||||
BackupSection.STATS -> input.readJsonArray<StatisticBackup>(serializer()).restoreToDb {
|
||||
getStatsDao().upsert(it.toEntity())
|
||||
}
|
||||
|
||||
private fun OutputStream.write(str: String) = write(str.toByteArray())
|
||||
BackupSection.SAVED_FILTERS -> input.readJsonArray<PersistableFilter>(serializer())
|
||||
.restoreWithoutTransaction {
|
||||
savedFiltersRepository.save(it)
|
||||
}
|
||||
|
||||
private fun InputStream.readString(): String = readBytes().decodeToString()
|
||||
null -> CompositeResult.EMPTY // skip unknown entries
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
commonProgress++
|
||||
}
|
||||
input.closeEntry()
|
||||
entry = input.nextEntry
|
||||
}
|
||||
progress?.emit(commonProgress)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun dumpSettings(): String {
|
||||
val map = settings.getAllValues().toMutableMap()
|
||||
map.remove(AppSettings.KEY_APP_PASSWORD)
|
||||
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
||||
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
||||
return JSONObject(map).toString()
|
||||
}
|
||||
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
||||
section: BackupSection,
|
||||
data: Flow<T>,
|
||||
serializer: SerializationStrategy<T>,
|
||||
) {
|
||||
data.onStart {
|
||||
putNextEntry(ZipEntry(section.entryName))
|
||||
write("[")
|
||||
}.onCompletion { error ->
|
||||
if (error == null) {
|
||||
write("]")
|
||||
}
|
||||
closeEntry()
|
||||
flush()
|
||||
}.collectIndexed { index, value ->
|
||||
if (index > 0) {
|
||||
write(",")
|
||||
}
|
||||
json.encodeToStream(serializer, value, this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dumpReaderGridSettings(): String {
|
||||
return JSONObject(tapGridSettings.getAllValues()).toString()
|
||||
}
|
||||
private fun <T> InputStream.readJsonArray(
|
||||
serializer: DeserializationStrategy<T>,
|
||||
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||
|
||||
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
||||
val tags = manga.tags.map { it.toEntity() }
|
||||
getTagsDao().upsert(tags)
|
||||
getMangaDao().upsert(manga.toEntity(), tags)
|
||||
}
|
||||
private fun InputStream.readMap(): Map<String, Any?> {
|
||||
val jo = JSONArray(readString()).getJSONObject(0)
|
||||
val map = ArrayMap<String, Any?>(jo.length())
|
||||
val keys = jo.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
map[key] = jo.get(key)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
|
||||
return fold(CompositeResult.EMPTY) { result, item ->
|
||||
result + runCatchingCancellable {
|
||||
database.withTransaction {
|
||||
database.block(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun ZipOutputStream.writeString(
|
||||
section: BackupSection,
|
||||
data: String,
|
||||
) {
|
||||
putNextEntry(ZipEntry(section.entryName))
|
||||
try {
|
||||
write("[")
|
||||
write(data)
|
||||
write("]")
|
||||
} finally {
|
||||
closeEntry()
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun OutputStream.write(str: String) = write(str.toByteArray())
|
||||
|
||||
private fun InputStream.readString(): String = readBytes().decodeToString()
|
||||
|
||||
private fun dumpSettings(): String {
|
||||
val map = settings.getAllValues().toMutableMap()
|
||||
map.remove(AppSettings.KEY_APP_PASSWORD)
|
||||
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
||||
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
||||
return JSONObject(map).toString()
|
||||
}
|
||||
|
||||
private fun dumpReaderGridSettings(): String {
|
||||
return JSONObject(tapGridSettings.getAllValues()).toString()
|
||||
}
|
||||
|
||||
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
||||
val tags = manga.tags.map { it.toEntity() }
|
||||
getTagsDao().upsert(tags)
|
||||
getMangaDao().upsert(manga.toEntity(), tags)
|
||||
}
|
||||
|
||||
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
|
||||
return fold(CompositeResult.EMPTY) { result, item ->
|
||||
result + runCatchingCancellable {
|
||||
database.withTransaction {
|
||||
database.block(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <T> Sequence<T>.restoreWithoutTransaction(crossinline block: suspend (T) -> Unit): CompositeResult {
|
||||
return fold(CompositeResult.EMPTY) { result, item ->
|
||||
result + runCatchingCancellable {
|
||||
block(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.koitharu.kotatsu.backups.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
|
||||
@Serializable
|
||||
class ScrobblingBackup(
|
||||
@SerialName("scrobbler") val scrobbler: Int,
|
||||
@SerialName("id") val id: Int,
|
||||
@SerialName("manga_id") val mangaId: Long,
|
||||
@SerialName("target_id") val targetId: Long,
|
||||
@SerialName("status") val status: String?,
|
||||
@SerialName("chapter") val chapter: Int,
|
||||
@SerialName("comment") val comment: String?,
|
||||
@SerialName("rating") val rating: Float,
|
||||
) {
|
||||
|
||||
constructor(entity: ScrobblingEntity) : this(
|
||||
scrobbler = entity.scrobbler,
|
||||
id = entity.id,
|
||||
mangaId = entity.mangaId,
|
||||
targetId = entity.targetId,
|
||||
status = entity.status,
|
||||
chapter = entity.chapter,
|
||||
comment = entity.comment,
|
||||
rating = entity.rating,
|
||||
)
|
||||
|
||||
fun toEntity() = ScrobblingEntity(
|
||||
scrobbler = scrobbler,
|
||||
id = id,
|
||||
mangaId = mangaId,
|
||||
targetId = targetId,
|
||||
status = status,
|
||||
chapter = chapter,
|
||||
comment = comment,
|
||||
rating = rating,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.koitharu.kotatsu.backups.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||
|
||||
@Serializable
|
||||
class StatisticBackup(
|
||||
@SerialName("manga_id") val mangaId: Long,
|
||||
@SerialName("started_at") val startedAt: Long,
|
||||
@SerialName("duration") val duration: Long,
|
||||
@SerialName("pages") val pages: Int,
|
||||
) {
|
||||
|
||||
constructor(entity: StatsEntity) : this(
|
||||
mangaId = entity.mangaId,
|
||||
startedAt = entity.startedAt,
|
||||
duration = entity.duration,
|
||||
pages = entity.pages,
|
||||
)
|
||||
|
||||
fun toEntity() = StatsEntity(
|
||||
mangaId = mangaId,
|
||||
startedAt = startedAt,
|
||||
duration = duration,
|
||||
pages = pages,
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import kotlinx.coroutines.runBlocking
|
||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.filter.data.SavedFiltersRepository
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
import java.io.File
|
||||
import java.io.FileDescriptor
|
||||
@@ -36,15 +38,22 @@ class AppBackupAgent : BackupAgent() {
|
||||
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
super.onFullBackup(data)
|
||||
val file =
|
||||
createBackupFile(
|
||||
this,
|
||||
BackupRepository(
|
||||
MangaDatabase(context = applicationContext),
|
||||
AppSettings(applicationContext),
|
||||
TapGridSettings(applicationContext),
|
||||
val file = createBackupFile(
|
||||
this,
|
||||
BackupRepository(
|
||||
database = MangaDatabase(context = applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
tapGridSettings = TapGridSettings(applicationContext),
|
||||
mangaSourcesRepository = MangaSourcesRepository(
|
||||
context = applicationContext,
|
||||
db = MangaDatabase(context = applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
),
|
||||
)
|
||||
savedFiltersRepository = SavedFiltersRepository(
|
||||
context = applicationContext,
|
||||
),
|
||||
),
|
||||
)
|
||||
try {
|
||||
fullBackupFile(file, data)
|
||||
} finally {
|
||||
@@ -68,6 +77,14 @@ class AppBackupAgent : BackupAgent() {
|
||||
database = MangaDatabase(applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
tapGridSettings = TapGridSettings(applicationContext),
|
||||
mangaSourcesRepository = MangaSourcesRepository(
|
||||
context = applicationContext,
|
||||
db = MangaDatabase(context = applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
),
|
||||
savedFiltersRepository = SavedFiltersRepository(
|
||||
context = applicationContext,
|
||||
),
|
||||
),
|
||||
)
|
||||
destination.delete()
|
||||
@@ -90,8 +107,12 @@ class AppBackupAgent : BackupAgent() {
|
||||
@VisibleForTesting
|
||||
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
||||
val sections = EnumSet.allOf(BackupSection::class.java)
|
||||
// managed externally
|
||||
sections.remove(BackupSection.SETTINGS)
|
||||
sections.remove(BackupSection.SETTINGS_READER_GRID)
|
||||
runBlocking {
|
||||
repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
|
||||
repository.restoreBackup(input, sections, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,16 @@ enum class BackupSection(
|
||||
SETTINGS_READER_GRID("reader_grid"),
|
||||
BOOKMARKS("bookmarks"),
|
||||
SOURCES("sources"),
|
||||
SCROBBLING("scrobbling"),
|
||||
STATS("statistics"),
|
||||
SAVED_FILTERS("saved_filters"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
fun of(entry: ZipEntry): BackupSection? {
|
||||
val name = entry.name.lowercase(Locale.ROOT)
|
||||
return entries.first { x -> x.entryName == name }
|
||||
return entries.find { x -> x.entryName == name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.backups.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -27,24 +28,13 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||
createNotificationChannel()
|
||||
createNotificationChannel(this)
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
showResultNotification(null, CompositeResult.failure(error))
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.backup_restore))
|
||||
.setShowBadge(true)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(false)
|
||||
.build()
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
protected fun IntentJobContext.showResultNotification(
|
||||
fileUri: Uri?,
|
||||
result: CompositeResult,
|
||||
@@ -128,8 +118,19 @@ abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
||||
.setBigContentTitle(title),
|
||||
)
|
||||
|
||||
protected companion object {
|
||||
companion object {
|
||||
|
||||
const val CHANNEL_ID = "backup_restore"
|
||||
|
||||
fun createNotificationChannel(context: Context) {
|
||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(context.getString(R.string.backup_restore))
|
||||
.setShowBadge(true)
|
||||
.setVibrationEnabled(false)
|
||||
.setSound(null, null)
|
||||
.setLightsEnabled(false)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
package org.koitharu.kotatsu.backups.ui.periodical
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
||||
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import java.util.zip.ZipOutputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -40,7 +49,7 @@ class PeriodicalBackupService : CoroutineIntentService() {
|
||||
}
|
||||
externalBackupStorage.put(output)
|
||||
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
|
||||
if (settings.isBackupTelegramUploadEnabled) {
|
||||
if (settings.isBackupTelegramUploadEnabled && telegramBackupUploader.isAvailable) {
|
||||
telegramBackupUploader.uploadBackup(output)
|
||||
}
|
||||
} finally {
|
||||
@@ -48,5 +57,49 @@ class PeriodicalBackupService : CoroutineIntentService() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun IntentJobContext.onError(error: Throwable) = Unit
|
||||
override fun IntentJobContext.onError(error: Throwable) {
|
||||
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return
|
||||
}
|
||||
BaseBackupRestoreService.createNotificationChannel(applicationContext)
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setDefaults(0)
|
||||
.setSilent(true)
|
||||
.setAutoCancel(true)
|
||||
val title = getString(R.string.periodic_backups)
|
||||
val message = getString(
|
||||
R.string.inline_preference_pattern,
|
||||
getString(R.string.packup_creation_failed),
|
||||
error.getDisplayMessage(resources),
|
||||
)
|
||||
notification
|
||||
.setContentText(message)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(message)
|
||||
.setSummaryText(getString(R.string.packup_creation_failed))
|
||||
.setBigContentTitle(title),
|
||||
)
|
||||
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, TAG)?.let { action ->
|
||||
notification.addAction(action)
|
||||
}
|
||||
notification.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
applicationContext,
|
||||
0,
|
||||
AppRouter.periodicBackupSettingsIntent(applicationContext),
|
||||
0,
|
||||
false,
|
||||
),
|
||||
)
|
||||
NotificationManagerCompat.from(applicationContext).notify(TAG, startId, notification.build())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val CHANNEL_ID = BaseBackupRestoreService.CHANNEL_ID
|
||||
const val TAG = "periodical_backup"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceCategory
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -37,6 +38,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_backup_periodic)
|
||||
findPreference<PreferenceCategory>(AppSettings.KEY_BACKUP_TG)?.isVisible = viewModel.isTelegramAvailable
|
||||
findPreference<EditTextPreference>(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider =
|
||||
EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary)
|
||||
}
|
||||
@@ -84,6 +86,11 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
|
||||
"" -> null
|
||||
else -> path
|
||||
}
|
||||
preference.icon = if (path == null) {
|
||||
getWarningIcon()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
||||
|
||||
@@ -27,6 +27,9 @@ class PeriodicalBackupSettingsViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val isTelegramAvailable
|
||||
get() = telegramUploader.isAvailable
|
||||
|
||||
val lastBackupDate = MutableStateFlow<Date?>(null)
|
||||
val backupsDirectory = MutableStateFlow<String?>("")
|
||||
val isTelegramCheckLoading = MutableStateFlow(false)
|
||||
|
||||
@@ -30,10 +30,13 @@ class TelegramBackupUploader @Inject constructor(
|
||||
|
||||
private val botToken = context.getString(R.string.tg_backup_bot_token)
|
||||
|
||||
val isAvailable: Boolean
|
||||
get() = botToken.isNotEmpty()
|
||||
|
||||
suspend fun uploadBackup(file: File) {
|
||||
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||
val multipartBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.Companion.FORM)
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("chat_id", requireChatId())
|
||||
.addFormDataPart("document", file.name, requestBody)
|
||||
.build()
|
||||
|
||||
@@ -23,6 +23,9 @@ data class BackupSectionModel(
|
||||
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||
BackupSection.BOOKMARKS -> R.string.bookmarks
|
||||
BackupSection.SOURCES -> R.string.remote_sources
|
||||
BackupSection.SCROBBLING -> R.string.tracking
|
||||
BackupSection.STATS -> R.string.statistics
|
||||
BackupSection.SAVED_FILTERS -> R.string.saved_filters
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Looper
|
||||
import android.webkit.WebResourceRequest
|
||||
@@ -15,7 +16,7 @@ import java.io.ByteArrayInputStream
|
||||
|
||||
open class BrowserClient(
|
||||
private val callback: BrowserCallback,
|
||||
private val adBlock: AdBlock,
|
||||
private val adBlock: AdBlock?,
|
||||
) : WebViewClient() {
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,7 @@ open class BrowserClient(
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
url: String?
|
||||
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock.shouldLoadUrl(url, view?.getUrlSafe())) {
|
||||
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock?.shouldLoadUrl(url, view?.getUrlSafe()) ?: true) {
|
||||
super.shouldInterceptRequest(view, url)
|
||||
} else {
|
||||
emptyResponse()
|
||||
@@ -57,15 +58,17 @@ open class BrowserClient(
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.getUrlSafe())) {
|
||||
super.shouldInterceptRequest(view, request)
|
||||
} else {
|
||||
emptyResponse()
|
||||
}
|
||||
): WebResourceResponse? =
|
||||
if (request == null || adBlock?.shouldLoadUrl(request.url.toString(), view?.getUrlSafe()) ?: true) {
|
||||
super.shouldInterceptRequest(view, request)
|
||||
} else {
|
||||
emptyResponse()
|
||||
}
|
||||
|
||||
private fun emptyResponse(): WebResourceResponse =
|
||||
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
|
||||
|
||||
@SuppressLint("WrongThread")
|
||||
@AnyThread
|
||||
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
url
|
||||
|
||||
@@ -42,18 +42,21 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
|
||||
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
|
||||
import org.koitharu.kotatsu.core.util.AcraScreenLogger
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageKeyer
|
||||
import org.koitharu.kotatsu.local.data.CacheDir
|
||||
import org.koitharu.kotatsu.local.data.FaviconCache
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.PageCache
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
|
||||
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
||||
@@ -101,7 +104,7 @@ interface AppModule {
|
||||
fun provideCoil(
|
||||
@LocalizedAppContext context: Context,
|
||||
@MangaHttpClient okHttpClientProvider: Provider<OkHttpClient>,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
faviconFetcherFactory: FaviconFetcher.Factory,
|
||||
imageProxyInterceptor: ImageProxyInterceptor,
|
||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||
@@ -138,7 +141,7 @@ interface AppModule {
|
||||
add(SvgDecoder.Factory())
|
||||
add(CbzFetcher.Factory())
|
||||
add(AvifImageDecoder.Factory())
|
||||
add(FaviconFetcher.Factory(mangaRepositoryFactory))
|
||||
add(faviconFetcherFactory)
|
||||
add(MangaPageKeyer())
|
||||
add(pageFetcherFactory)
|
||||
add(imageProxyInterceptor)
|
||||
@@ -195,5 +198,29 @@ interface AppModule {
|
||||
fun provideWorkManager(
|
||||
@ApplicationContext context: Context,
|
||||
): WorkManager = WorkManager.getInstance(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@PageCache
|
||||
fun providePageCache(
|
||||
@ApplicationContext context: Context,
|
||||
) = LocalStorageCache(
|
||||
context = context,
|
||||
dir = CacheDir.PAGES,
|
||||
defaultSize = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
|
||||
minSize = FileSize.MEGABYTES.convert(20, FileSize.BYTES),
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@FaviconCache
|
||||
fun provideFaviconCache(
|
||||
@ApplicationContext context: Context,
|
||||
) = LocalStorageCache(
|
||||
context = context,
|
||||
dir = CacheDir.FAVICONS,
|
||||
defaultSize = FileSize.MEGABYTES.convert(8, FileSize.BYTES),
|
||||
minSize = FileSize.MEGABYTES.convert(2, FileSize.BYTES),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.room.InvalidationTracker
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.internal.platform.PlatformRegistry
|
||||
import org.acra.ACRA
|
||||
import org.acra.ReportField
|
||||
import org.acra.config.dialog
|
||||
@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.os.AppValidator
|
||||
import org.koitharu.kotatsu.core.os.RomCompat
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
|
||||
@@ -62,9 +61,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
@Inject
|
||||
lateinit var workScheduleManager: WorkScheduleManager
|
||||
|
||||
@Inject
|
||||
lateinit var workManagerProvider: Provider<WorkManager>
|
||||
|
||||
@Inject
|
||||
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
|
||||
|
||||
@@ -79,6 +75,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
PlatformRegistry.applicationContext = this // TODO replace with OkHttp.initialize
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
@@ -97,7 +94,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
||||
localStorageChanges.collect(localMangaIndexProvider.get())
|
||||
}
|
||||
workScheduleManager.init()
|
||||
WorkServiceStopHelper(workManagerProvider).setup()
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.BadParcelableException
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
@@ -65,7 +64,7 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
e: Throwable,
|
||||
notificationId: Int,
|
||||
notificationTag: String?,
|
||||
): PendingIntent? = try {
|
||||
): PendingIntent? = runCatching {
|
||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||
intent.setAction(ACTION_REPORT)
|
||||
intent.setData("err://${e.hashCode()}".toUri())
|
||||
@@ -73,9 +72,9 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
||||
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
|
||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||
} catch (e: BadParcelableException) {
|
||||
}.onFailure { e ->
|
||||
// probably cannot write exception as serializable
|
||||
e.printStackTraceDebug()
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration23To24
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration25To26
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration26To27
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||
@@ -69,7 +70,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 26
|
||||
const val DATABASE_VERSION = 27
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
@@ -140,6 +141,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration24To23(),
|
||||
Migration24To25(),
|
||||
Migration25To26(),
|
||||
Migration26To27(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -34,6 +34,9 @@ abstract class MangaDao {
|
||||
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
||||
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
|
||||
|
||||
@Query("SELECT author FROM manga WHERE manga.source = :source AND author IS NOT NULL AND author != '' GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
|
||||
abstract suspend fun findAuthorsBySource(source: String, limit: Int): List<String>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||
|
||||
@@ -26,6 +26,7 @@ data class MangaPrefsEntity(
|
||||
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
||||
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
|
||||
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
|
||||
@ColumnInfo(name = "cf_book") val cfBookEffect: Boolean,
|
||||
@ColumnInfo(name = "title_override") val titleOverride: String?,
|
||||
@ColumnInfo(name = "cover_override") val coverUrlOverride: String?,
|
||||
@ColumnInfo(name = "content_rating_override") val contentRatingOverride: String?,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration26To27 : Migration(26, 27) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE preferences ADD COLUMN cf_book INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
class EmptyMangaException(
|
||||
val reason: EmptyMangaReason?,
|
||||
val manga: Manga,
|
||||
cause: Throwable?
|
||||
) : IllegalStateException(cause)
|
||||
@@ -5,16 +5,15 @@ import android.app.Notification
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.collection.MutableScatterMap
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil3.EventListener
|
||||
@@ -26,6 +25,7 @@ import coil3.request.allowConversionToBitmap
|
||||
import coil3.request.allowHardware
|
||||
import coil3.request.lifecycle
|
||||
import coil3.size.Scale
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.model.getTitle
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||
@@ -55,6 +56,7 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import org.koitharu.kotatsu.parsers.util.mapToArray
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
@@ -65,27 +67,13 @@ class CaptchaHandler @Inject constructor(
|
||||
@LocalizedAppContext private val context: Context,
|
||||
private val databaseProvider: Provider<MangaDatabase>,
|
||||
private val coilProvider: Provider<ImageLoader>,
|
||||
private val webViewExecutor: WebViewExecutor,
|
||||
) : EventListener() {
|
||||
|
||||
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return
|
||||
goAsync {
|
||||
handleException(MangaSource(sourceName), exception = null, notify = false)
|
||||
}
|
||||
}
|
||||
},
|
||||
IntentFilter().apply { addAction(ACTION_DISCARD) },
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
|
||||
|
||||
suspend fun discard(source: MangaSource) {
|
||||
@@ -95,10 +83,18 @@ class CaptchaHandler @Inject constructor(
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
super.onError(request, result)
|
||||
val e = result.throwable
|
||||
if (e is CloudFlareException && request.extras[ignoreCaptchaKey] != true) {
|
||||
if (e is CloudFlareException) {
|
||||
val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope
|
||||
scope.launch {
|
||||
handleException(e.source, e, true)
|
||||
if (
|
||||
handleException(
|
||||
source = e.source,
|
||||
exception = e,
|
||||
notify = request.extras[suppressCaptchaKey] != true,
|
||||
)
|
||||
) {
|
||||
coilProvider.get().enqueue(request) // TODO check if ok
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,11 +102,14 @@ class CaptchaHandler @Inject constructor(
|
||||
private suspend fun handleException(
|
||||
source: MangaSource,
|
||||
exception: CloudFlareException?,
|
||||
notify: Boolean
|
||||
notify: Boolean,
|
||||
): Boolean = withContext(Dispatchers.Default) {
|
||||
if (source == UnknownMangaSource) {
|
||||
return@withContext false
|
||||
}
|
||||
if (exception != null && webViewExecutor.tryResolveCaptcha(exception, RESOLVE_TIMEOUT)) {
|
||||
return@withContext true
|
||||
}
|
||||
mutex.withLock {
|
||||
var removedException: CloudFlareProtectedException? = null
|
||||
if (exception is CloudFlareProtectedException) {
|
||||
@@ -121,21 +120,21 @@ class CaptchaHandler @Inject constructor(
|
||||
val dao = databaseProvider.get().getSourcesDao()
|
||||
dao.setCfState(source.name, exception?.state ?: CloudFlareHelper.PROTECTION_NOT_DETECTED)
|
||||
|
||||
val exceptions = dao.findAllCaptchaRequired().mapNotNull {
|
||||
it.source.toMangaSourceOrNull()
|
||||
}.filterNot {
|
||||
SourceSettings(context, it).isCaptchaNotificationsDisabled
|
||||
}.mapNotNull {
|
||||
exceptionMap[it]
|
||||
}
|
||||
if (notify && context.checkNotificationPermission(CHANNEL_ID)) {
|
||||
val exceptions = dao.findAllCaptchaRequired().mapNotNull {
|
||||
it.source.toMangaSourceOrNull()
|
||||
}.filterNot {
|
||||
SourceSettings(context, it).isCaptchaNotificationsDisabled
|
||||
}.mapNotNull {
|
||||
exceptionMap[it]
|
||||
}
|
||||
if (removedException != null) {
|
||||
NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode())
|
||||
}
|
||||
notify(exceptions)
|
||||
}
|
||||
}
|
||||
true
|
||||
false
|
||||
}
|
||||
|
||||
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -169,6 +168,15 @@ class CaptchaHandler @Inject constructor(
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSmallIcon(R.drawable.ic_bot)
|
||||
.setGroup(GROUP_CAPTCHA)
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivities(
|
||||
context, GROUP_NOTIFICATION_ID,
|
||||
exceptions.mapToArray { e ->
|
||||
AppRouter.cloudFlareResolveIntent(context, e)
|
||||
},
|
||||
0, false,
|
||||
),
|
||||
)
|
||||
.setContentText(
|
||||
context.getString(
|
||||
R.string.captcha_required_summary, context.getString(R.string.app_name),
|
||||
@@ -189,7 +197,6 @@ class CaptchaHandler @Inject constructor(
|
||||
|
||||
private suspend fun buildNotification(exception: CloudFlareProtectedException): Notification {
|
||||
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
|
||||
.setData(exception.url.toUri())
|
||||
val discardIntent = Intent(ACTION_DISCARD)
|
||||
.putExtra(AppRouter.KEY_SOURCE, exception.source.name)
|
||||
.setData("source://${exception.source.name}".toUri())
|
||||
@@ -242,6 +249,7 @@ class CaptchaHandler @Inject constructor(
|
||||
.data(source.faviconUri())
|
||||
.allowHardware(false)
|
||||
.allowConversionToBitmap(true)
|
||||
.suppressCaptchaErrors()
|
||||
.mangaSourceExtra(source)
|
||||
.size(context.resources.getNotificationIconSize())
|
||||
.scale(Scale.FILL)
|
||||
@@ -251,13 +259,27 @@ class CaptchaHandler @Inject constructor(
|
||||
it.printStackTraceDebug()
|
||||
}.getOrNull()
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DiscardReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var captchaHandler: CaptchaHandler
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val sourceName = intent?.getStringExtra(AppRouter.KEY_SOURCE) ?: return
|
||||
goAsync {
|
||||
captchaHandler.handleException(MangaSource(sourceName), exception = null, notify = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
||||
extras[ignoreCaptchaKey] = true
|
||||
fun ImageRequest.Builder.suppressCaptchaErrors() = apply {
|
||||
extras[suppressCaptchaKey] = true
|
||||
}
|
||||
|
||||
val ignoreCaptchaKey = Extras.Key(false)
|
||||
private val suppressCaptchaKey = Extras.Key(false)
|
||||
|
||||
private const val CHANNEL_ID = "captcha"
|
||||
private const val TAG = CHANNEL_ID
|
||||
@@ -265,5 +287,6 @@ class CaptchaHandler @Inject constructor(
|
||||
private const val GROUP_NOTIFICATION_ID = 34
|
||||
private const val SETTINGS_ACTION_CODE = 3
|
||||
private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD"
|
||||
private const val RESOLVE_TIMEOUT = 20_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ import androidx.collection.MutableScatterMap
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.async
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
@@ -32,164 +35,221 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.net.ssl.SSLException
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class ExceptionResolver @AssistedInject constructor(
|
||||
@Assisted private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
class ExceptionResolver private constructor(
|
||||
private val host: Host,
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
|
||||
|
||||
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||
handleActivityResult(BrowserActivity.TAG, true)
|
||||
}
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
private val browserActionContract = host.registerForActivityResult(BrowserActivity.Contract()) {
|
||||
handleActivityResult(BrowserActivity.TAG, true)
|
||||
}
|
||||
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||
}
|
||||
private val cloudflareContract = host.registerForActivityResult(CloudFlareActivity.Contract()) {
|
||||
handleActivityResult(CloudFlareActivity.TAG, it)
|
||||
}
|
||||
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router()?.showErrorDialog(e, url)
|
||||
}
|
||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||
host.router.showErrorDialog(e, url)
|
||||
}
|
||||
|
||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
|
||||
when (e) {
|
||||
is CloudFlareProtectedException -> resolveCF(e)
|
||||
is AuthRequiredException -> resolveAuthException(e.source)
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> {
|
||||
showSslErrorDialog()
|
||||
false
|
||||
}
|
||||
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||
|
||||
is ProxyConfigException -> {
|
||||
host.router()?.openProxySettings()
|
||||
false
|
||||
}
|
||||
is ProxyConfigException -> {
|
||||
host.router.openProxySettings()
|
||||
false
|
||||
}
|
||||
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
is NotFoundException -> {
|
||||
openInBrowser(e.url)
|
||||
false
|
||||
}
|
||||
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
is EmptyMangaException -> {
|
||||
when (e.reason) {
|
||||
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
|
||||
EmptyMangaReason.LOADING_ERROR -> Unit
|
||||
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
|
||||
else -> Unit
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
is ScrobblerAuthRequiredException -> {
|
||||
val authHelper = scrobblerAuthHelperProvider.get()
|
||||
if (authHelper.isAuthorized(e.scrobbler)) {
|
||||
true
|
||||
} else {
|
||||
host.withContext {
|
||||
authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
): Boolean = suspendCoroutine { cont ->
|
||||
continuations[BrowserActivity.TAG] = cont
|
||||
browserActionContract.launch(e)
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}.await()
|
||||
|
||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(e)
|
||||
}
|
||||
private suspend fun resolveBrowserAction(
|
||||
e: InteractiveActionRequiredException
|
||||
): Boolean = suspendCoroutine { cont ->
|
||||
continuations[BrowserActivity.TAG] = cont
|
||||
browserActionContract.launch(e)
|
||||
}
|
||||
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||
continuations[CloudFlareActivity.TAG] = cont
|
||||
cloudflareContract.launch(e)
|
||||
}
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router()?.openBrowser(url, null, null)
|
||||
}
|
||||
private suspend fun resolveAuthException(source: MangaSource): Boolean = suspendCoroutine { cont ->
|
||||
continuations[SourceAuthActivity.TAG] = cont
|
||||
sourceAuthContract.launch(source)
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router()?.openAlternatives(manga)
|
||||
}
|
||||
private fun openInBrowser(url: String) {
|
||||
host.router.openBrowser(url, null, null)
|
||||
}
|
||||
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
host.router.openAlternatives(manga)
|
||||
}
|
||||
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.getContext() ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||
continuations.remove(tag)?.resume(result)
|
||||
}
|
||||
|
||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
||||
getContext()?.apply(block)
|
||||
}
|
||||
private fun showSslErrorDialog() {
|
||||
val ctx = host.context ?: return
|
||||
if (settings.isSSLBypassEnabled) {
|
||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
buildAlertDialog(ctx) {
|
||||
setTitle(R.string.ignore_ssl_errors)
|
||||
setMessage(R.string.ignore_ssl_errors_summary)
|
||||
setPositiveButton(R.string.apply) { _, _ ->
|
||||
settings.isSSLBypassEnabled = true
|
||||
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
|
||||
ctx.restartApplication()
|
||||
}
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun Host.router(): AppRouter? = when (this) {
|
||||
is FragmentActivity -> router
|
||||
is Fragment -> router
|
||||
else -> null
|
||||
}
|
||||
class Factory @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||
) {
|
||||
|
||||
interface Host : ActivityResultCaller {
|
||||
fun create(fragment: Fragment) = ExceptionResolver(
|
||||
host = Host.FragmentHost(fragment),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
|
||||
fun getChildFragmentManager(): FragmentManager
|
||||
fun create(activity: FragmentActivity) = ExceptionResolver(
|
||||
host = Host.ActivityHost(activity),
|
||||
settings = settings,
|
||||
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||
)
|
||||
}
|
||||
|
||||
fun getContext(): Context?
|
||||
}
|
||||
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
val context: Context?
|
||||
|
||||
fun create(host: Host): ExceptionResolver
|
||||
}
|
||||
val router: AppRouter
|
||||
|
||||
companion object {
|
||||
val fragmentManager: FragmentManager
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
inline fun withContext(block: Context.() -> Unit) {
|
||||
context?.apply(block)
|
||||
}
|
||||
|
||||
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
class ActivityHost(val activity: FragmentActivity) : Host,
|
||||
ActivityResultCaller by activity,
|
||||
LifecycleOwner by activity {
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
override val context: Context
|
||||
get() = activity
|
||||
|
||||
is InteractiveActionRequiredException -> R.string._continue
|
||||
override val router: AppRouter
|
||||
get() = activity.router
|
||||
|
||||
else -> 0
|
||||
}
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = activity.supportFragmentManager
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
class FragmentHost(val fragment: Fragment) : Host,
|
||||
ActivityResultCaller by fragment {
|
||||
|
||||
override val context: Context?
|
||||
get() = fragment.context
|
||||
|
||||
override val router: AppRouter
|
||||
get() = fragment.router
|
||||
|
||||
override val fragmentManager: FragmentManager
|
||||
get() = fragment.childFragmentManager
|
||||
|
||||
override val lifecycle: Lifecycle
|
||||
get() = fragment.viewLifecycleOwner.lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@StringRes
|
||||
fun getResolveStringId(e: Throwable) = when (e) {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is ScrobblerAuthRequiredException,
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
|
||||
is NotFoundException -> if (e.url.isHttpUrl()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
is SSLException,
|
||||
is CertPathValidatorException -> R.string.fix
|
||||
|
||||
is ProxyConfigException -> R.string.settings
|
||||
|
||||
is InteractiveActionRequiredException -> R.string._continue
|
||||
|
||||
is EmptyMangaException -> when (e.reason) {
|
||||
EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0
|
||||
EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives
|
||||
else -> 0
|
||||
}
|
||||
|
||||
else -> 0
|
||||
}
|
||||
|
||||
fun canResolve(e: Throwable) = getResolveStringId(e) != 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.image
|
||||
import coil3.intercept.Interceptor
|
||||
import coil3.network.httpHeaders
|
||||
import coil3.request.ImageResult
|
||||
import org.koitharu.kotatsu.core.model.unwrap
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
@@ -10,7 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
class MangaSourceHeaderInterceptor : Interceptor {
|
||||
|
||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
||||
val mangaSource = chain.request.extras[mangaSourceKey] as? MangaParserSource ?: return chain.proceed()
|
||||
val mangaSource = chain.request.extras[mangaSourceKey]?.unwrap() as? MangaParserSource ?: return chain.proceed()
|
||||
val request = chain.request
|
||||
val newHeaders = request.httpHeaders.newBuilder()
|
||||
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
||||
|
||||
@@ -55,6 +55,7 @@ val MangaState.titleResId: Int
|
||||
MangaState.ABANDONED -> R.string.state_abandoned
|
||||
MangaState.PAUSED -> R.string.state_paused
|
||||
MangaState.UPCOMING -> R.string.state_upcoming
|
||||
MangaState.RESTRICTED -> R.string.unavailable
|
||||
}
|
||||
|
||||
@get:DrawableRes
|
||||
@@ -65,6 +66,7 @@ val MangaState.iconResId: Int
|
||||
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
|
||||
MangaState.PAUSED -> R.drawable.ic_action_pause
|
||||
MangaState.UPCOMING -> materialR.drawable.ic_clock_black_24dp
|
||||
MangaState.RESTRICTED -> R.drawable.ic_disable
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
|
||||
@@ -28,11 +28,15 @@ data object UnknownMangaSource : MangaSource {
|
||||
override val name = "UNKNOWN"
|
||||
}
|
||||
|
||||
data object TestMangaSource : MangaSource {
|
||||
override val name = "TEST"
|
||||
}
|
||||
|
||||
fun MangaSource(name: String?): MangaSource {
|
||||
when (name ?: return UnknownMangaSource) {
|
||||
UnknownMangaSource.name -> return UnknownMangaSource
|
||||
|
||||
LocalMangaSource.name -> return LocalMangaSource
|
||||
TestMangaSource.name -> return TestMangaSource
|
||||
}
|
||||
if (name.startsWith("content:")) {
|
||||
val parts = name.substringAfter(':').splitTwoParts('/') ?: return UnknownMangaSource
|
||||
@@ -92,6 +96,7 @@ fun MangaSource.getSummary(context: Context): String? = when (val source = unwra
|
||||
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||
is MangaParserSource -> source.title
|
||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||
TestMangaSource -> context.getString(R.string.test_parser)
|
||||
is ExternalMangaSource -> source.resolveName(context)
|
||||
else -> context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.descriptors.serialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
object MangaSourceSerializer : KSerializer<MangaSource> {
|
||||
|
||||
override val descriptor: SerialDescriptor = serialDescriptor<String>()
|
||||
|
||||
override fun serialize(
|
||||
encoder: Encoder,
|
||||
value: MangaSource
|
||||
) = encoder.encodeString(value.name)
|
||||
|
||||
override fun deserialize(decoder: Decoder): MangaSource = MangaSource(decoder.decodeString())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.network
|
||||
|
||||
import android.util.Log
|
||||
import dagger.Lazy
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
@@ -36,7 +35,8 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||
} else {
|
||||
if (BuildConfig.DEBUG && source == null) {
|
||||
Log.w("Http", "Request without source tag: ${request.url}")
|
||||
IllegalArgumentException("Request without source tag: ${request.url}")
|
||||
.printStackTrace()
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.annotation.WorkerThread
|
||||
import androidx.core.util.Predicate
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.koitharu.kotatsu.parsers.util.newBuilder
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.webkit.WebView
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||
import kotlin.coroutines.Continuation
|
||||
|
||||
class CaptchaContinuationClient(
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val targetUrl: String,
|
||||
continuation: Continuation<Unit>,
|
||||
) : ContinuationResumeWebViewClient(continuation) {
|
||||
|
||||
private val oldClearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) = Unit
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
checkClearance(view)
|
||||
}
|
||||
|
||||
private fun checkClearance(view: WebView?) {
|
||||
val clearance = CloudFlareHelper.getClearanceCookie(cookieJar, targetUrl)
|
||||
if (clearance != null && clearance != oldClearance) {
|
||||
resumeContinuation(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,22 @@ package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class ContinuationResumeWebViewClient(
|
||||
open class ContinuationResumeWebViewClient(
|
||||
private val continuation: Continuation<Unit>,
|
||||
) : WebViewClient() {
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
view?.webViewClient = WebViewClient() // reset to default
|
||||
continuation.resume(Unit)
|
||||
resumeContinuation(view)
|
||||
}
|
||||
|
||||
protected fun resumeContinuation(view: WebView?) {
|
||||
if (continuation !is CancellableContinuation || continuation.isActive) {
|
||||
view?.webViewClient = WebViewClient() // reset to default
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package org.koitharu.kotatsu.core.network.webview
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AndroidRuntimeException
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.annotation.MainThread
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareException
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Singleton
|
||||
class WebViewExecutor @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val proxyProvider: ProxyProvider,
|
||||
private val cookieJar: MutableCookieJar,
|
||||
private val mangaRepositoryFactoryProvider: Provider<MangaRepository.Factory>,
|
||||
) {
|
||||
|
||||
private var webViewCached: WeakReference<WebView>? = null
|
||||
private val mutex = Mutex()
|
||||
|
||||
val defaultUserAgent: String? by lazy {
|
||||
try {
|
||||
WebSettings.getDefaultUserAgent(context)
|
||||
} catch (e: AndroidRuntimeException) {
|
||||
e.printStackTraceDebug()
|
||||
// Probably WebView is not available
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun evaluateJs(baseUrl: String?, script: String): String? = mutex.withLock {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
try {
|
||||
if (!baseUrl.isNullOrEmpty()) {
|
||||
suspendCoroutine { cont ->
|
||||
webView.webViewClient = ContinuationResumeWebViewClient(cont)
|
||||
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
|
||||
}
|
||||
}
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
webView.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun tryResolveCaptcha(exception: CloudFlareException, timeout: Long): Boolean = mutex.withLock {
|
||||
runCatchingCancellable {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
try {
|
||||
exception.source.getUserAgent()?.let {
|
||||
webView.settings.userAgentString = it
|
||||
}
|
||||
withTimeout(timeout) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
webView.webViewClient = CaptchaContinuationClient(
|
||||
cookieJar = cookieJar,
|
||||
targetUrl = exception.url,
|
||||
continuation = cont,
|
||||
)
|
||||
webView.loadUrl(exception.url)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
webView.reset()
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
exception.addSuppressed(e)
|
||||
e.printStackTraceDebug()
|
||||
}.isSuccess
|
||||
}
|
||||
|
||||
private suspend fun obtainWebView(): WebView {
|
||||
webViewCached?.get()?.let {
|
||||
return it
|
||||
}
|
||||
return withContext(Dispatchers.Main.immediate) {
|
||||
webViewCached?.get()?.let {
|
||||
return@withContext it
|
||||
}
|
||||
WebView(context).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
proxyProvider.applyWebViewConfig()
|
||||
it.onResume()
|
||||
it.resumeTimers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaSource.getUserAgent(): String? {
|
||||
val repository = mangaRepositoryFactoryProvider.get().create(this) as? ParserMangaRepository
|
||||
return repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun WebView.reset() {
|
||||
stopLoading()
|
||||
webViewClient = WebViewClient()
|
||||
settings.userAgentString = defaultUserAgent
|
||||
loadDataWithBaseURL(null, " ", "text/html", null, null)
|
||||
clearHistory()
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ class AppShortcutManager @Inject constructor(
|
||||
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
|
||||
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
|
||||
)
|
||||
mangaRepository.storeManga(manga)
|
||||
mangaRepository.storeManga(manga, replaceExisting = true)
|
||||
val title = manga.title.ifEmpty {
|
||||
manga.altTitles.firstOrNull()
|
||||
}.ifNullOrEmpty {
|
||||
@@ -173,6 +173,7 @@ class AppShortcutManager @Inject constructor(
|
||||
coil.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(source.faviconUri())
|
||||
.mangaSourceExtra(source)
|
||||
.size(iconSize)
|
||||
.scale(Scale.FIT)
|
||||
.build(),
|
||||
|
||||
@@ -80,12 +80,7 @@ class NetworkState(
|
||||
if (settings.isOfflineCheckDisabled) {
|
||||
return true
|
||||
}
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activeNetwork?.let { isOnline(it) } == true
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activeNetworkInfo?.isConnected == true
|
||||
}
|
||||
return activeNetwork?.let { isOnline(it) } == true
|
||||
}
|
||||
|
||||
private fun ConnectivityManager.isOnline(network: Network): Boolean {
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
|
||||
// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr
|
||||
class OpenDocumentTreeHelper(
|
||||
@@ -28,38 +27,42 @@ class OpenDocumentTreeHelper(
|
||||
callback,
|
||||
)
|
||||
|
||||
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback)
|
||||
private val pickFileTreeLauncherPrimaryStorage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractPrimaryStorage(flags), callback)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult(
|
||||
contract = OpenDocumentTreeContractLegacy(flags),
|
||||
private val pickFileTreeLauncherDefault = activityResultCaller.registerForActivityResult(
|
||||
contract = OpenDocumentTreeContractDefault(flags),
|
||||
callback = callback,
|
||||
)
|
||||
|
||||
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
|
||||
if (pickFileTreeLauncherQ == null) {
|
||||
pickFileTreeLauncherLegacy.launch(input, options)
|
||||
return
|
||||
}
|
||||
try {
|
||||
pickFileTreeLauncherQ.launch(input, options)
|
||||
pickFileTreeLauncherDefault.launch(input, options)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTraceDebug()
|
||||
pickFileTreeLauncherLegacy.launch(input, options)
|
||||
if (pickFileTreeLauncherPrimaryStorage != null) {
|
||||
try {
|
||||
pickFileTreeLauncherPrimaryStorage.launch(input, options)
|
||||
} catch (e2: Exception) {
|
||||
e.addSuppressed(e2)
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unregister() {
|
||||
pickFileTreeLauncherQ?.unregister()
|
||||
pickFileTreeLauncherLegacy.unregister()
|
||||
pickFileTreeLauncherPrimaryStorage?.unregister()
|
||||
pickFileTreeLauncherDefault.unregister()
|
||||
}
|
||||
|
||||
override val contract: ActivityResultContract<Uri?, *>
|
||||
get() = pickFileTreeLauncherQ?.contract ?: pickFileTreeLauncherLegacy.contract
|
||||
get() = pickFileTreeLauncherPrimaryStorage?.contract ?: pickFileTreeLauncherDefault.contract
|
||||
|
||||
private open class OpenDocumentTreeContractLegacy(
|
||||
private open class OpenDocumentTreeContractDefault(
|
||||
private val flags: Int,
|
||||
) : ActivityResultContracts.OpenDocumentTree() {
|
||||
|
||||
@@ -71,9 +74,9 @@ class OpenDocumentTreeHelper(
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private class OpenDocumentTreeContractQ(
|
||||
private class OpenDocumentTreeContractPrimaryStorage(
|
||||
private val flags: Int,
|
||||
) : OpenDocumentTreeContractLegacy(flags) {
|
||||
) : OpenDocumentTreeContractDefault(flags) {
|
||||
|
||||
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
|
||||
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
|
||||
import java.util.EnumSet
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override val searchQueryCapabilities: MangaSearchQueryCapabilities
|
||||
get() = MangaSearchQueryCapabilities()
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
|
||||
|
||||
override suspend fun getList(query: MangaSearchQuery): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
open class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
|
||||
import org.koitharu.kotatsu.core.db.entity.ContentRating
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
|
||||
@@ -41,7 +43,7 @@ class MangaDataRepository @Inject constructor(
|
||||
|
||||
suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) {
|
||||
db.withTransaction {
|
||||
storeManga(manga)
|
||||
storeManga(manga, replaceExisting = false)
|
||||
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
|
||||
db.getPreferencesDao().upsert(entity.copy(mode = mode.id))
|
||||
}
|
||||
@@ -49,7 +51,7 @@ class MangaDataRepository @Inject constructor(
|
||||
|
||||
suspend fun saveColorFilter(manga: Manga, colorFilter: ReaderColorFilter?) {
|
||||
db.withTransaction {
|
||||
storeManga(manga)
|
||||
storeManga(manga, replaceExisting = false)
|
||||
val entity = db.getPreferencesDao().find(manga.id) ?: newEntity(manga.id)
|
||||
db.getPreferencesDao().upsert(
|
||||
entity.copy(
|
||||
@@ -87,10 +89,11 @@ class MangaDataRepository @Inject constructor(
|
||||
return map
|
||||
}
|
||||
|
||||
suspend fun setOverride(mangaId: Long, override: MangaOverride?) {
|
||||
suspend fun setOverride(manga: Manga, override: MangaOverride?) {
|
||||
db.withTransaction {
|
||||
storeManga(manga, replaceExisting = false)
|
||||
val dao = db.getPreferencesDao()
|
||||
val entity = dao.find(mangaId) ?: newEntity(mangaId)
|
||||
val entity = dao.find(manga.id) ?: newEntity(manga.id)
|
||||
dao.upsert(
|
||||
entity.copy(
|
||||
titleOverride = override?.title?.nullIfEmpty(),
|
||||
@@ -127,7 +130,10 @@ class MangaDataRepository @Inject constructor(
|
||||
else -> null
|
||||
}
|
||||
|
||||
suspend fun storeManga(manga: Manga) {
|
||||
suspend fun storeManga(manga: Manga, replaceExisting: Boolean) {
|
||||
if (!replaceExisting && db.getMangaDao().find(manga.id) != null) {
|
||||
return
|
||||
}
|
||||
db.withTransaction {
|
||||
// avoid storing local manga if remote one is already stored
|
||||
val existing = if (manga.isLocal) {
|
||||
@@ -185,7 +191,12 @@ class MangaDataRepository @Inject constructor(
|
||||
emitInitialState = emitInitialState,
|
||||
)
|
||||
|
||||
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && chapters.isNullOrEmpty()) {
|
||||
fun observeFavoritesTrigger(emitInitialState: Boolean) = db.invalidationTracker.createFlow(
|
||||
tables = arrayOf(TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES),
|
||||
emitInitialState = emitInitialState,
|
||||
)
|
||||
|
||||
private suspend fun Manga.withCachedChaptersIfNeeded(flag: Boolean): Manga = if (flag && !isLocal && chapters.isNullOrEmpty()) {
|
||||
val cachedChapters = db.getChaptersDao().findAll(id)
|
||||
if (cachedChapters.isEmpty()) {
|
||||
this
|
||||
@@ -197,8 +208,14 @@ class MangaDataRepository @Inject constructor(
|
||||
}
|
||||
|
||||
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
|
||||
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
|
||||
ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)
|
||||
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale || cfBookEffect) {
|
||||
ReaderColorFilter(
|
||||
brightness = cfBrightness,
|
||||
contrast = cfContrast,
|
||||
isInverted = cfInvert,
|
||||
isGrayscale = cfGrayscale,
|
||||
isBookBackground = cfBookEffect
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -219,10 +236,11 @@ class MangaDataRepository @Inject constructor(
|
||||
private fun newEntity(mangaId: Long) = MangaPrefsEntity(
|
||||
mangaId = mangaId,
|
||||
mode = -1,
|
||||
cfBrightness = 0f,
|
||||
cfContrast = 0f,
|
||||
cfInvert = false,
|
||||
cfGrayscale = false,
|
||||
cfBrightness = ReaderColorFilter.EMPTY.brightness,
|
||||
cfContrast = ReaderColorFilter.EMPTY.contrast,
|
||||
cfInvert = ReaderColorFilter.EMPTY.isInverted,
|
||||
cfGrayscale = ReaderColorFilter.EMPTY.isGrayscale,
|
||||
cfBookEffect = ReaderColorFilter.EMPTY.isBookBackground,
|
||||
titleOverride = null,
|
||||
coverUrlOverride = null,
|
||||
contentRatingOverride = null,
|
||||
|
||||
@@ -3,15 +3,8 @@ package org.koitharu.kotatsu.core.parser
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -22,11 +15,8 @@ import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.network.webview.ContinuationResumeWebViewClient
|
||||
import org.koitharu.kotatsu.core.network.webview.WebViewExecutor
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeType
|
||||
import org.koitharu.kotatsu.core.util.ext.use
|
||||
@@ -37,25 +27,19 @@ import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.map
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@Singleton
|
||||
class MangaLoaderContextImpl @Inject constructor(
|
||||
@MangaHttpClient override val httpClient: OkHttpClient,
|
||||
override val cookieJar: MutableCookieJar,
|
||||
@ApplicationContext private val androidContext: Context,
|
||||
private val webViewExecutor: WebViewExecutor,
|
||||
) : MangaLoaderContext() {
|
||||
|
||||
private var webViewCached: WeakReference<WebView>? = null
|
||||
private val webViewUserAgent by lazy { obtainWebViewUserAgent() }
|
||||
private val jsMutex = Mutex()
|
||||
private val jsTimeout = TimeUnit.SECONDS.toMillis(4)
|
||||
|
||||
@Deprecated("Provide a base url")
|
||||
@@ -63,25 +47,10 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
override suspend fun evaluateJs(script: String): String? = evaluateJs("", script)
|
||||
|
||||
override suspend fun evaluateJs(baseUrl: String, script: String): String? = withTimeout(jsTimeout) {
|
||||
jsMutex.withLock {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
val webView = obtainWebView()
|
||||
if (baseUrl.isNotEmpty()) {
|
||||
suspendCoroutine { cont ->
|
||||
webView.webViewClient = ContinuationResumeWebViewClient(cont)
|
||||
webView.loadDataWithBaseURL(baseUrl, " ", "text/html", null, null)
|
||||
}
|
||||
}
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
webViewExecutor.evaluateJs(baseUrl, script)
|
||||
}
|
||||
|
||||
override fun getDefaultUserAgent(): String = webViewUserAgent
|
||||
override fun getDefaultUserAgent(): String = webViewExecutor.defaultUserAgent ?: UserAgents.FIREFOX_MOBILE
|
||||
|
||||
override fun getConfig(source: MangaSource): MangaSourceConfig {
|
||||
return SourceSettings(androidContext, source)
|
||||
@@ -118,28 +87,4 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
}
|
||||
|
||||
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
|
||||
private fun obtainWebViewUserAgent(): String {
|
||||
val mainDispatcher = Dispatchers.Main.immediate
|
||||
return if (!mainDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
|
||||
obtainWebViewUserAgentImpl()
|
||||
} else {
|
||||
runBlocking(mainDispatcher) {
|
||||
obtainWebViewUserAgentImpl()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebViewUserAgentImpl() = runCatching {
|
||||
obtainWebView().settings.userAgentString.sanitizeHeaderValue()
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
|
||||
fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
|
||||
return when (source) {
|
||||
MangaParserSource.DUMMY -> DummyParser(loaderContext)
|
||||
else -> loaderContext.newParserInstance(source)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.MangaSourceInfo
|
||||
import org.koitharu.kotatsu.core.model.TestMangaSource
|
||||
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
@@ -85,11 +86,16 @@ interface MangaRepository {
|
||||
|
||||
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
|
||||
is MangaParserSource -> ParserMangaRepository(
|
||||
parser = MangaParser(source, loaderContext),
|
||||
parser = loaderContext.newParserInstance(source),
|
||||
cache = contentCache,
|
||||
mirrorSwitcher = mirrorSwitcher,
|
||||
)
|
||||
|
||||
TestMangaSource -> TestMangaRepository(
|
||||
loaderContext = loaderContext,
|
||||
cache = contentCache,
|
||||
)
|
||||
|
||||
is ExternalMangaSource -> if (source.isAvailable(context)) {
|
||||
ExternalMangaRepository(
|
||||
contentResolver = context.contentResolver,
|
||||
|
||||
@@ -53,6 +53,9 @@ class ExternalPluginContentSource(
|
||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||
if (!filter.author.isNullOrEmpty()) {
|
||||
uri.appendQueryParameter("author", filter.author)
|
||||
}
|
||||
if (!filter.query.isNullOrEmpty()) {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
@@ -196,6 +199,7 @@ class ExternalPluginContentSource(
|
||||
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
|
||||
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
|
||||
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
|
||||
isAuthorSearchSupported = cursor.getBooleanOrDefault(COLUMN_AUTHOR, false),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -10,15 +10,21 @@ import coil3.ColorImage
|
||||
import coil3.ImageLoader
|
||||
import coil3.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import coil3.size.pxOrElse
|
||||
import coil3.toAndroidUri
|
||||
import coil3.toBitmap
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
@@ -26,9 +32,16 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||
import org.koitharu.kotatsu.core.util.ext.fetch
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
|
||||
import org.koitharu.kotatsu.local.data.FaviconCache
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageCache
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import coil3.Uri as CoilUri
|
||||
|
||||
class FaviconFetcher(
|
||||
@@ -36,6 +49,7 @@ class FaviconFetcher(
|
||||
private val options: Options,
|
||||
private val imageLoader: ImageLoader,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val localStorageCache: LocalStorageCache,
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
@@ -61,15 +75,29 @@ class FaviconFetcher(
|
||||
options.size.width.pxOrElse { FALLBACK_SIZE },
|
||||
options.size.height.pxOrElse { FALLBACK_SIZE },
|
||||
)
|
||||
val cacheKey = options.diskCacheKey ?: "${repository.source.name}_$sizePx"
|
||||
if (options.diskCachePolicy.readEnabled) {
|
||||
localStorageCache[cacheKey]?.let { file ->
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
|
||||
mimeType = MimeTypes.probeMimeType(file)?.toString(),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
}
|
||||
}
|
||||
var favicons = repository.getFavicons()
|
||||
var lastError: Exception? = null
|
||||
while (favicons.isNotEmpty()) {
|
||||
coroutineContext.ensureActive()
|
||||
currentCoroutineContext().ensureActive()
|
||||
val icon = favicons.find(sizePx) ?: throwNSEE(lastError)
|
||||
try {
|
||||
val result = imageLoader.fetch(icon.url, options)
|
||||
if (result != null) {
|
||||
return result
|
||||
return if (options.diskCachePolicy.writeEnabled) {
|
||||
writeToCache(cacheKey, result)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
} else {
|
||||
favicons -= icon
|
||||
}
|
||||
@@ -97,8 +125,39 @@ class FaviconFetcher(
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private suspend fun writeToCache(key: String, result: FetchResult): FetchResult = runCatchingCancellable {
|
||||
when (result) {
|
||||
is ImageFetchResult -> {
|
||||
if (result.dataSource == DataSource.NETWORK) {
|
||||
localStorageCache.set(key, result.image.toBitmap()).asFetchResult()
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
is SourceFetchResult -> {
|
||||
if (result.dataSource == DataSource.NETWORK) {
|
||||
result.source.source().use {
|
||||
localStorageCache.set(key, it, result.mimeType?.toMimeTypeOrNull()).asFetchResult()
|
||||
}
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
it.printStackTraceDebug()
|
||||
}.getOrDefault(result)
|
||||
|
||||
private fun File.asFetchResult() = SourceFetchResult(
|
||||
source = ImageSource(toOkioPath(), FileSystem.SYSTEM),
|
||||
mimeType = MimeTypes.probeMimeType(this)?.toString(),
|
||||
dataSource = DataSource.DISK,
|
||||
)
|
||||
|
||||
class Factory @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
@FaviconCache private val faviconCache: LocalStorageCache,
|
||||
) : Fetcher.Factory<CoilUri> {
|
||||
|
||||
override fun create(
|
||||
@@ -106,7 +165,7 @@ class FaviconFetcher(
|
||||
options: Options,
|
||||
imageLoader: ImageLoader
|
||||
): Fetcher? = if (data.scheme == URI_SCHEME_FAVICON) {
|
||||
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory)
|
||||
FaviconFetcher(data.toAndroidUri(), options, imageLoader, mangaRepositoryFactory, faviconCache)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -15,12 +15,17 @@ import androidx.core.os.LocaleListCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeChanges
|
||||
import org.koitharu.kotatsu.core.util.ext.putAll
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
@@ -82,6 +87,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isNavBarPinned: Boolean
|
||||
get() = prefs.getBoolean(KEY_NAV_PINNED, false)
|
||||
|
||||
val isMainFabEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_MAIN_FAB, true)
|
||||
|
||||
var gridSize: Int
|
||||
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
||||
@@ -130,6 +138,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
||||
|
||||
@get:FloatRange(0.0, 1.0)
|
||||
var readerDoublePagesSensitivity: Float
|
||||
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
|
||||
set(@FloatRange(0.0, 1.0) value) = prefs.edit { putFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, value) }
|
||||
|
||||
val readerScreenOrientation: Int
|
||||
get() = prefs.getString(KEY_READER_ORIENTATION, null)?.toIntOrNull()
|
||||
?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
@@ -143,6 +156,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderControlAlwaysLTR: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_CONTROL_LTR, false)
|
||||
|
||||
val isReaderNavigationInverted: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_NAVIGATION_INVERTED, false)
|
||||
|
||||
val isReaderFullscreenEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_FULLSCREEN, true)
|
||||
|
||||
@@ -393,24 +409,37 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderBarTransparent: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
|
||||
|
||||
val isReaderChapterToastEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_CHAPTER_TOAST, true)
|
||||
|
||||
val isReaderKeepScreenOn: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
||||
|
||||
var readerColorFilter: ReaderColorFilter?
|
||||
get() = runCatching {
|
||||
val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness)
|
||||
val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast)
|
||||
val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted)
|
||||
val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale)
|
||||
ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty }
|
||||
ReaderColorFilter(
|
||||
brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness),
|
||||
contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast),
|
||||
isInverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted),
|
||||
isGrayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale),
|
||||
isBookBackground = prefs.getBoolean(KEY_CF_BOOK, ReaderColorFilter.EMPTY.isBookBackground),
|
||||
).takeUnless { it.isEmpty }
|
||||
}.getOrNull()
|
||||
set(value) {
|
||||
prefs.edit {
|
||||
val cf = value ?: ReaderColorFilter.EMPTY
|
||||
putFloat(KEY_CF_BRIGHTNESS, cf.brightness)
|
||||
putFloat(KEY_CF_CONTRAST, cf.contrast)
|
||||
putBoolean(KEY_CF_INVERTED, cf.isInverted)
|
||||
putBoolean(KEY_CF_GRAYSCALE, cf.isGrayscale)
|
||||
if (value != null) {
|
||||
putFloat(KEY_CF_BRIGHTNESS, value.brightness)
|
||||
putFloat(KEY_CF_CONTRAST, value.contrast)
|
||||
putBoolean(KEY_CF_INVERTED, value.isInverted)
|
||||
putBoolean(KEY_CF_GRAYSCALE, value.isGrayscale)
|
||||
putBoolean(KEY_CF_BOOK, value.isBookBackground)
|
||||
} else {
|
||||
remove(KEY_CF_BRIGHTNESS)
|
||||
remove(KEY_CF_CONTRAST)
|
||||
remove(KEY_CF_INVERTED)
|
||||
remove(KEY_CF_GRAYSCALE)
|
||||
remove(KEY_CF_BOOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +496,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) }
|
||||
|
||||
var isWebtoonPullGestureEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_WEBTOON_PULL_GESTURE, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_PULL_GESTURE, value) }
|
||||
|
||||
@get:FloatRange(from = 0.0, to = 0.5)
|
||||
val defaultWebtoonZoomOut: Float
|
||||
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
|
||||
@@ -481,6 +514,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
var isReaderAutoscrollFabVisible: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_AUTOSCROLL_FAB, true)
|
||||
set(value) = prefs.edit { putBoolean(KEY_READER_AUTOSCROLL_FAB, value) }
|
||||
|
||||
val isPagesPreloadEnabled: Boolean
|
||||
get() {
|
||||
if (isBackgroundNetworkRestricted()) {
|
||||
@@ -496,14 +533,24 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val is32BitColorsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
|
||||
|
||||
val isDiscordRpcEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_DISCORD_RPC, false)
|
||||
|
||||
val isDiscordRpcSkipNsfw: Boolean
|
||||
get() = prefs.getBoolean(KEY_DISCORD_RPC_SKIP_NSFW, false)
|
||||
|
||||
var discordToken: String?
|
||||
get() = prefs.getString(KEY_DISCORD_TOKEN, null)?.trim()?.nullIfEmpty()
|
||||
set(value) = prefs.edit { putString(KEY_DISCORD_TOKEN, value?.nullIfEmpty()) }
|
||||
|
||||
val isPeriodicalBackupEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
|
||||
|
||||
val periodicalBackupFrequency: Long
|
||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
|
||||
val periodicalBackupFrequency: Float
|
||||
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toFloatOrNull() ?: 7f
|
||||
|
||||
val periodicalBackupFrequencyMillis: Long
|
||||
get() = TimeUnit.DAYS.toMillis(periodicalBackupFrequency)
|
||||
get() = (TimeUnit.DAYS.toMillis(1) * periodicalBackupFrequency).toLong()
|
||||
|
||||
val periodicalBackupMaxCount: Int
|
||||
get() = if (prefs.getBoolean(KEY_BACKUP_PERIODICAL_TRIM, true)) {
|
||||
@@ -585,7 +632,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun observe() = prefs.observe()
|
||||
fun observeChanges() = prefs.observeChanges()
|
||||
|
||||
fun observe(vararg keys: String): Flow<String?> = prefs.observeChanges()
|
||||
.filter { key -> key == null || key in keys }
|
||||
.onStart { emit(null) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun getAllValues(): Map<String, *> = prefs.all
|
||||
|
||||
@@ -629,8 +681,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
||||
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
|
||||
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
||||
const val KEY_READER_FULLSCREEN = "reader_fullscreen"
|
||||
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
|
||||
const val KEY_READER_ORIENTATION = "reader_orientation"
|
||||
@@ -696,6 +750,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_SYNC_SETTINGS = "sync_settings"
|
||||
const val KEY_READER_BAR = "reader_bar"
|
||||
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
|
||||
const val KEY_READER_CHAPTER_TOAST = "reader_chapter_toast"
|
||||
const val KEY_READER_BACKGROUND = "reader_background"
|
||||
const val KEY_READER_SCREEN_ON = "reader_screen_on"
|
||||
const val KEY_SHORTCUTS = "dynamic_shortcuts"
|
||||
@@ -707,6 +762,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_WEBTOON_GAPS = "webtoon_gaps"
|
||||
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
|
||||
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
|
||||
const val KEY_WEBTOON_PULL_GESTURE = "webtoon_pull_gesture"
|
||||
const val KEY_PREFETCH_CONTENT = "prefetch_content"
|
||||
const val KEY_APP_LOCALE = "app_locale"
|
||||
const val KEY_SOURCES_GRID = "sources_grid"
|
||||
@@ -714,6 +770,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_TIPS_CLOSED = "tips_closed"
|
||||
const val KEY_SSL_BYPASS = "ssl_bypass"
|
||||
const val KEY_READER_AUTOSCROLL_SPEED = "as_speed"
|
||||
const val KEY_READER_AUTOSCROLL_FAB = "as_fab"
|
||||
const val KEY_MIRROR_SWITCHING = "mirror_switching"
|
||||
const val KEY_PROXY = "proxy"
|
||||
const val KEY_PROXY_TYPE = "proxy_type_2"
|
||||
@@ -729,6 +786,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_NAV_MAIN = "nav_main"
|
||||
const val KEY_NAV_LABELS = "nav_labels"
|
||||
const val KEY_NAV_PINNED = "nav_pinned"
|
||||
const val KEY_MAIN_FAB = "main_fab"
|
||||
const val KEY_32BIT_COLOR = "enhanced_colors"
|
||||
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
||||
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
||||
@@ -736,6 +794,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_CF_CONTRAST = "cf_contrast"
|
||||
const val KEY_CF_INVERTED = "cf_inverted"
|
||||
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||
const val KEY_CF_BOOK = "cf_book"
|
||||
const val KEY_PAGES_TAB = "pages_tab"
|
||||
const val KEY_DETAILS_TAB = "details_tab"
|
||||
const val KEY_DETAILS_LAST_TAB = "details_last_tab"
|
||||
@@ -753,6 +812,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
|
||||
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
|
||||
const val KEY_TAGS_WARNINGS = "tags_warnings"
|
||||
const val KEY_DISCORD_RPC = "discord_rpc"
|
||||
const val KEY_DISCORD_RPC_SKIP_NSFW = "discord_rpc_skip_nsfw"
|
||||
const val KEY_DISCORD_TOKEN = "discord_token"
|
||||
|
||||
// keys for non-persistent preferences
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
@@ -765,6 +827,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PROXY_TEST = "proxy_test"
|
||||
const val KEY_OPEN_BROWSER = "open_browser"
|
||||
const val KEY_HANDLE_LINKS = "handle_links"
|
||||
const val KEY_BACKUP_TG = "backup_periodic_tg"
|
||||
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
|
||||
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
|
||||
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
|
||||
|
||||
@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.transform
|
||||
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
|
||||
var lastValue: T = valueProducer()
|
||||
emit(lastValue)
|
||||
observe().collect {
|
||||
observeChanges().collect {
|
||||
if (it == key) {
|
||||
val value = valueProducer()
|
||||
if (value != lastValue) {
|
||||
@@ -25,7 +25,7 @@ fun <T> AppSettings.observeAsStateFlow(
|
||||
scope: CoroutineScope,
|
||||
key: String,
|
||||
valueProducer: AppSettings.() -> T,
|
||||
): StateFlow<T> = observe().transform {
|
||||
): StateFlow<T> = observeChanges().transform {
|
||||
if (it == key) {
|
||||
emit(valueProducer())
|
||||
}
|
||||
|
||||
@@ -13,10 +13,14 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
|
||||
import java.io.File
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
|
||||
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
|
||||
private val prefs = context.getSharedPreferences(
|
||||
source.name.replace(File.separatorChar, '$'),
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
|
||||
var defaultSortOrder: SortOrder?
|
||||
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
|
||||
@@ -66,8 +70,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
|
||||
companion object {
|
||||
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
const val KEY_SLOWDOWN = "slowdown"
|
||||
const val KEY_DOMAIN = "domain"
|
||||
const val KEY_NO_CAPTCHA = "no_captcha"
|
||||
const val KEY_SLOWDOWN = "slowdown"
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
|
||||
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
AppCompatActivity(),
|
||||
ExceptionResolver.Host,
|
||||
OnApplyWindowInsetsListener,
|
||||
ScreenshotPolicyHelper.ContentContainer {
|
||||
|
||||
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
||||
|
||||
override fun getContext() = this
|
||||
|
||||
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
|
||||
|
||||
protected fun setContentView(binding: B) {
|
||||
this.viewBinding = binding
|
||||
super.setContentView(binding.root)
|
||||
|
||||
@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
|
||||
abstract class BaseFragment<B : ViewBinding> :
|
||||
OnApplyWindowInsetsListener,
|
||||
Fragment(),
|
||||
ExceptionResolver.Host {
|
||||
Fragment() {
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.OnApplyWindowInsetsListener
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
@@ -34,8 +36,7 @@ import com.google.android.material.R as materialR
|
||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
PreferenceFragmentCompat(),
|
||||
OnApplyWindowInsetsListener,
|
||||
RecyclerViewOwner,
|
||||
ExceptionResolver.Host {
|
||||
RecyclerViewOwner {
|
||||
|
||||
protected lateinit var exceptionResolver: ExceptionResolver
|
||||
private set
|
||||
@@ -86,6 +87,12 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||
(activity as? SettingsActivity)?.setSectionTitle(title)
|
||||
}
|
||||
|
||||
protected fun getWarningIcon(): Drawable? = context?.let { ctx ->
|
||||
ContextCompat.getDrawable(ctx, R.drawable.ic_alert_outline)?.also {
|
||||
it.setTint(ContextCompat.getColor(ctx, R.color.warning))
|
||||
}
|
||||
}
|
||||
|
||||
private fun focusPreference(key: String) {
|
||||
val pref = findPreference<Preference>(key)
|
||||
if (pref == null) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.util.ext.EventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
|
||||
@@ -43,13 +44,13 @@ abstract class BaseViewModel : ViewModel() {
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
||||
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block)
|
||||
|
||||
protected fun launchLoadingJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) {
|
||||
loadingCounter.increment()
|
||||
try {
|
||||
block()
|
||||
@@ -80,10 +81,28 @@ abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { _, throwable ->
|
||||
throwable.printStackTraceDebug()
|
||||
if (throwable !is CancellationException) {
|
||||
errorEvent.call(throwable)
|
||||
private fun CoroutineContext.withDefaultExceptionHandler() =
|
||||
if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) {
|
||||
this
|
||||
} else {
|
||||
this + EventExceptionHandler(errorEvent)
|
||||
}
|
||||
|
||||
protected object SkipErrors : AbstractCoroutineContextElement(Key) {
|
||||
|
||||
private object Key : CoroutineContext.Key<SkipErrors>
|
||||
}
|
||||
|
||||
protected class EventExceptionHandler(
|
||||
private val event: MutableEventFlow<Throwable>,
|
||||
) : AbstractCoroutineContextElement(CoroutineExceptionHandler),
|
||||
CoroutineExceptionHandler {
|
||||
|
||||
override fun handleException(context: CoroutineContext, exception: Throwable) {
|
||||
exception.printStackTraceDebug()
|
||||
if (context[SkipErrors.key] == null && exception !is CancellationException) {
|
||||
event.call(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui
|
||||
|
||||
import android.view.View
|
||||
|
||||
fun interface OnContextClickListenerCompat {
|
||||
|
||||
fun onContextClick(v: View): Boolean
|
||||
}
|
||||
@@ -2,10 +2,17 @@ package org.koitharu.kotatsu.core.ui.dialog
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.CompoundButton.OnCheckedChangeListener
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.UiContext
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -15,54 +22,103 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegatesManager
|
||||
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
|
||||
import org.koitharu.kotatsu.databinding.ViewDialogAutocompleteBinding
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
inline fun buildAlertDialog(
|
||||
@UiContext context: Context,
|
||||
isCentered: Boolean = false,
|
||||
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||
@UiContext context: Context,
|
||||
isCentered: Boolean = false,
|
||||
block: MaterialAlertDialogBuilder.() -> Unit,
|
||||
): AlertDialog = MaterialAlertDialogBuilder(
|
||||
context,
|
||||
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
|
||||
context,
|
||||
if (isCentered) materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered else 0,
|
||||
).apply(block).create()
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setCheckbox(
|
||||
@StringRes textResId: Int,
|
||||
isChecked: Boolean,
|
||||
onCheckedChangeListener: OnCheckedChangeListener
|
||||
@StringRes textResId: Int,
|
||||
isChecked: Boolean,
|
||||
onCheckedChangeListener: OnCheckedChangeListener
|
||||
) = apply {
|
||||
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||
binding.checkbox.setText(textResId)
|
||||
binding.checkbox.isChecked = isChecked
|
||||
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
|
||||
setView(binding.root)
|
||||
val binding = DialogCheckboxBinding.inflate(LayoutInflater.from(context))
|
||||
binding.checkbox.setText(textResId)
|
||||
binding.checkbox.isChecked = isChecked
|
||||
binding.checkbox.setOnCheckedChangeListener(onCheckedChangeListener)
|
||||
setView(binding.root)
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||
list: List<T>,
|
||||
delegate: AdapterDelegate<List<T>>,
|
||||
list: List<T>,
|
||||
delegate: AdapterDelegate<List<T>>,
|
||||
) = apply {
|
||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||
delegatesManager.addDelegate(delegate)
|
||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||
delegatesManager.addDelegate(delegate)
|
||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder, T> B.setRecyclerViewList(
|
||||
list: List<T>,
|
||||
vararg delegates: AdapterDelegate<List<T>>,
|
||||
list: List<T>,
|
||||
vararg delegates: AdapterDelegate<List<T>>,
|
||||
) = apply {
|
||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||
delegates.forEach { delegatesManager.addDelegate(it) }
|
||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||
val delegatesManager = AdapterDelegatesManager<List<T>>()
|
||||
delegates.forEach { delegatesManager.addDelegate(it) }
|
||||
setRecyclerViewList(ListDelegationAdapter(delegatesManager).also { it.items = list })
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setRecyclerViewList(adapter: RecyclerView.Adapter<*>) = apply {
|
||||
val recyclerView = RecyclerView(context)
|
||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
recyclerView.updatePadding(
|
||||
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
||||
)
|
||||
recyclerView.clipToPadding = false
|
||||
recyclerView.adapter = adapter
|
||||
setView(recyclerView)
|
||||
val recyclerView = RecyclerView(context)
|
||||
recyclerView.layoutManager = LinearLayoutManager(context)
|
||||
recyclerView.updatePadding(
|
||||
top = context.resources.getDimensionPixelOffset(R.dimen.list_spacing),
|
||||
)
|
||||
recyclerView.clipToPadding = false
|
||||
recyclerView.adapter = adapter
|
||||
setView(recyclerView)
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setEditText(
|
||||
inputType: Int,
|
||||
singleLine: Boolean,
|
||||
): EditText {
|
||||
val editText = AppCompatEditText(context)
|
||||
editText.inputType = inputType
|
||||
if (singleLine) {
|
||||
editText.setSingleLine()
|
||||
editText.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
val layout = FrameLayout(context)
|
||||
val lp = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
val horizontalMargin = context.resources.getDimensionPixelOffset(R.dimen.screen_padding)
|
||||
lp.setMargins(
|
||||
horizontalMargin,
|
||||
context.resources.getDimensionPixelOffset(R.dimen.margin_small),
|
||||
horizontalMargin,
|
||||
0,
|
||||
)
|
||||
layout.addView(editText, lp)
|
||||
setView(layout)
|
||||
return editText
|
||||
}
|
||||
|
||||
fun <B : AlertDialog.Builder> B.setEditText(
|
||||
entries: List<CharSequence>,
|
||||
inputType: Int,
|
||||
singleLine: Boolean,
|
||||
): EditText {
|
||||
if (entries.isEmpty()) {
|
||||
return setEditText(inputType, singleLine)
|
||||
}
|
||||
val binding = ViewDialogAutocompleteBinding.inflate(LayoutInflater.from(context))
|
||||
binding.autoCompleteTextView.setAdapter(
|
||||
ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, entries),
|
||||
)
|
||||
binding.dropdown.setOnClickListener {
|
||||
binding.autoCompleteTextView.showDropDown()
|
||||
}
|
||||
binding.autoCompleteTextView.inputType = inputType
|
||||
if (singleLine) {
|
||||
binding.autoCompleteTextView.setSingleLine()
|
||||
binding.autoCompleteTextView.imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
setView(binding.root)
|
||||
return binding.autoCompleteTextView
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ class RememberCheckListener(
|
||||
var isChecked: Boolean = initialValue
|
||||
private set
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
this.isChecked = isChecked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import coil3.asImage
|
||||
import coil3.request.Disposable
|
||||
import coil3.request.ImageRequest
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.ignoreCaptchaErrors
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler.Companion.suppressCaptchaErrors
|
||||
import org.koitharu.kotatsu.core.image.CoilImageView
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||
@@ -57,7 +57,7 @@ class FaviconView @JvmOverloads constructor(
|
||||
.fallback(fallbackFactory)
|
||||
.placeholder(placeholderFactory)
|
||||
.mangaSourceExtra(mangaSource)
|
||||
.ignoreCaptchaErrors()
|
||||
.suppressCaptchaErrors()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,16 @@ package org.koitharu.kotatsu.core.ui.list
|
||||
|
||||
import android.view.View
|
||||
import android.view.View.OnClickListener
|
||||
import android.view.View.OnContextClickListener
|
||||
import android.view.View.OnLongClickListener
|
||||
import androidx.core.util.Function
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
|
||||
class AdapterDelegateClickListenerAdapter<I, O>(
|
||||
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
|
||||
private val clickListener: OnListItemClickListener<O>,
|
||||
private val itemMapper: Function<I, O>,
|
||||
) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat {
|
||||
) : OnClickListener, OnLongClickListener, OnContextClickListener {
|
||||
|
||||
override fun onClick(v: View) {
|
||||
clickListener.onItemClick(mappedItem(), v)
|
||||
@@ -33,7 +32,7 @@ class AdapterDelegateClickListenerAdapter<I, O>(
|
||||
fun attach(itemView: View) {
|
||||
itemView.setOnClickListener(this)
|
||||
itemView.setOnLongClickListener(this)
|
||||
itemView.setOnContextClickListenerCompat(this)
|
||||
itemView.setOnContextClickListener(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -186,6 +186,7 @@ class ListSelectionController(
|
||||
|
||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||
if (event == Lifecycle.Event.ON_CREATE) {
|
||||
source.lifecycle.removeObserver(this)
|
||||
val registry = registryOwner.savedStateRegistry
|
||||
registry.registerSavedStateProvider(PROVIDER_NAME, this@ListSelectionController)
|
||||
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
|
||||
|
||||
@@ -5,7 +5,10 @@ import android.view.View
|
||||
import androidx.annotation.Px
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDecoration() {
|
||||
class SpacingItemDecoration(
|
||||
@Px private val spacing: Int,
|
||||
private val withBottomPadding: Boolean,
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
@@ -13,6 +16,6 @@ class SpacingItemDecoration(@Px private val spacing: Int) : RecyclerView.ItemDec
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
outRect.set(spacing, spacing, spacing, spacing)
|
||||
outRect.set(spacing, spacing, spacing, if (withBottomPadding) spacing else 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
||||
OnApplyWindowInsetsListener,
|
||||
ExceptionResolver.Host {
|
||||
OnApplyWindowInsetsListener {
|
||||
|
||||
private var waitingForDismissAllowingStateLoss = false
|
||||
private var isFitToContentsDisabled = false
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.ui.util
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.view.ViewGroup
|
||||
import android.view.Window
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
@@ -14,7 +13,6 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -37,14 +35,10 @@ class ActionModeDelegate : OnBackPressedCallback(false) {
|
||||
listeners?.forEach { it.onActionModeStarted(mode) }
|
||||
if (window != null) {
|
||||
val ctx = window.context
|
||||
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
ColorUtils.compositeColors(
|
||||
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
|
||||
ctx.getThemeColor(materialR.attr.colorSurface),
|
||||
)
|
||||
} else {
|
||||
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
|
||||
}
|
||||
val actionModeColor = ColorUtils.compositeColors(
|
||||
ContextCompat.getColor(ctx, materialR.color.m3_appbar_overlay_color),
|
||||
ctx.getThemeColor(materialR.attr.colorSurface),
|
||||
)
|
||||
defaultStatusBarColor = window.statusBarColor
|
||||
window.statusBarColor = actionModeColor
|
||||
val insets = ViewCompat.getRootWindowInsets(window.decorView)
|
||||
|
||||
@@ -4,12 +4,10 @@ import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.view.MenuProvider
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
|
||||
class PopupMenuMediator(
|
||||
private val provider: MenuProvider,
|
||||
) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener,
|
||||
) : View.OnLongClickListener, View.OnContextClickListener, PopupMenu.OnMenuItemClickListener,
|
||||
PopupMenu.OnDismissListener {
|
||||
|
||||
override fun onContextClick(v: View): Boolean = onLongClick(v)
|
||||
@@ -37,6 +35,6 @@ class PopupMenuMediator(
|
||||
|
||||
fun attach(view: View) {
|
||||
view.setOnLongClickListener(this)
|
||||
view.setOnContextClickListenerCompat(this)
|
||||
view.setOnContextClickListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ class ChipsView @JvmOverloads constructor(
|
||||
val data = it.tag
|
||||
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
|
||||
}
|
||||
private val chipOnLongClickListener = OnLongClickListener {
|
||||
val chip = it as Chip
|
||||
val data = it.tag
|
||||
onChipLongClickListener?.onChipLongClick(chip, data) ?: false
|
||||
}
|
||||
private val chipStyle: Int
|
||||
private val iconsVisible: Boolean
|
||||
var onChipClickListener: OnChipClickListener? = null
|
||||
@@ -66,6 +71,8 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
var onChipCloseClickListener: OnChipCloseClickListener? = null
|
||||
|
||||
var onChipLongClickListener: OnChipLongClickListener? = null
|
||||
|
||||
init {
|
||||
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
|
||||
chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
|
||||
@@ -145,6 +152,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
setOnCloseIconClickListener(chipOnCloseListener)
|
||||
setEnsureMinTouchTargetSize(false)
|
||||
setOnClickListener(chipOnClickListener)
|
||||
setOnLongClickListener(chipOnLongClickListener)
|
||||
isElegantTextHeight = false
|
||||
}
|
||||
|
||||
@@ -276,4 +284,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
|
||||
fun onChipCloseClick(chip: Chip, data: Any?)
|
||||
}
|
||||
|
||||
fun interface OnChipLongClickListener {
|
||||
|
||||
fun onChipLongClick(chip: Chip, data: Any?): Boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ open class StackLayout @JvmOverloads constructor(
|
||||
val h = b - t - paddingTop - paddingBottom
|
||||
visibleChildren.clear()
|
||||
children.filterNotTo(visibleChildren) { it.isGone }
|
||||
if (w <= 0 || h <= 0 || visibleChildren.isEmpty) {
|
||||
if (w <= 0 || h <= 0 || visibleChildren.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val xStep = w / (visibleChildren.size + 1)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.koitharu.kotatsu.core.ui.widgets
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class TouchBlockLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
|
||||
var isTouchEventsAllowed = true
|
||||
|
||||
override fun onInterceptTouchEvent(
|
||||
ev: MotionEvent?
|
||||
): Boolean = if (isTouchEventsAllowed) {
|
||||
super.onInterceptTouchEvent(ev)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,38 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
open class MultiMutex<T : Any> : Set<T> {
|
||||
open class MultiMutex<T : Any> {
|
||||
|
||||
private val delegates = ArrayMap<T, Mutex>()
|
||||
private val delegates = ConcurrentHashMap<T, Mutex>()
|
||||
|
||||
override val size: Int
|
||||
get() = delegates.size
|
||||
@VisibleForTesting
|
||||
val size: Int
|
||||
get() = delegates.count { it.value.isLocked }
|
||||
|
||||
override fun contains(element: T): Boolean = synchronized(delegates) {
|
||||
delegates.containsKey(element)
|
||||
}
|
||||
fun isNotEmpty() = delegates.any { it.value.isLocked }
|
||||
|
||||
override fun containsAll(elements: Collection<T>): Boolean = synchronized(delegates) {
|
||||
elements.all { x -> delegates.containsKey(x) }
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean = delegates.isEmpty()
|
||||
|
||||
override fun iterator(): Iterator<T> = synchronized(delegates) {
|
||||
delegates.keys.toList()
|
||||
}.iterator()
|
||||
|
||||
fun isLocked(element: T): Boolean = synchronized(delegates) {
|
||||
delegates[element]?.isLocked == true
|
||||
}
|
||||
|
||||
fun tryLock(element: T): Boolean {
|
||||
val mutex = synchronized(delegates) {
|
||||
delegates.getOrPut(element, ::Mutex)
|
||||
}
|
||||
return mutex.tryLock()
|
||||
}
|
||||
fun isEmpty() = delegates.none { it.value.isLocked }
|
||||
|
||||
suspend fun lock(element: T) {
|
||||
val mutex = synchronized(delegates) {
|
||||
delegates.getOrPut(element, ::Mutex)
|
||||
}
|
||||
val mutex = delegates.computeIfAbsent(element) { Mutex() }
|
||||
mutex.lock()
|
||||
}
|
||||
|
||||
fun unlock(element: T) {
|
||||
synchronized(delegates) {
|
||||
delegates.remove(element)?.unlock()
|
||||
}
|
||||
delegates[element]?.unlock()
|
||||
}
|
||||
|
||||
suspend inline fun <R> withLock(element: T, block: () -> R): R {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
lock(element)
|
||||
return try {
|
||||
lock(element)
|
||||
block()
|
||||
} finally {
|
||||
unlock(element)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkQuery
|
||||
import androidx.work.impl.foreground.SystemForegroundService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Workaround for issue
|
||||
* https://issuetracker.google.com/issues/270245927
|
||||
* https://issuetracker.google.com/issues/280504155
|
||||
*/
|
||||
class WorkServiceStopHelper(
|
||||
private val workManagerProvider: Provider<WorkManager>,
|
||||
) {
|
||||
|
||||
fun setup() {
|
||||
processLifecycleScope.launch(Dispatchers.Default) {
|
||||
workManagerProvider.get()
|
||||
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
||||
.map { it.isEmpty() }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest {
|
||||
if (it) {
|
||||
delay(1_000)
|
||||
stopWorkerService()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun stopWorkerService() {
|
||||
SystemForegroundService.getInstance()?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,28 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
|
||||
flow: Flow<T1>,
|
||||
flow2: Flow<T2>,
|
||||
flow3: Flow<T3>,
|
||||
flow4: Flow<T4>,
|
||||
flow5: Flow<T5>,
|
||||
flow6: Flow<T6>,
|
||||
flow7: Flow<T7>,
|
||||
transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R,
|
||||
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> ->
|
||||
transform(
|
||||
args[0] as T1,
|
||||
args[1] as T2,
|
||||
args[2] as T3,
|
||||
args[3] as T4,
|
||||
args[4] as T5,
|
||||
args[5] as T6,
|
||||
args[6] as T7,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
|
||||
|
||||
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CheckResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
@@ -12,6 +15,7 @@ import okio.FileSystem
|
||||
import okio.IOException
|
||||
import okio.Path
|
||||
import okio.Source
|
||||
import okio.source
|
||||
import org.koitharu.kotatsu.core.util.CancellableSource
|
||||
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -57,3 +61,8 @@ fun FileSystem.isRegularFile(path: Path) = try {
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
fun ContentResolver.openSource(uri: Uri): Source = checkNotNull(openInputStream(uri)) {
|
||||
"Cannot open input stream from $uri"
|
||||
}.source()
|
||||
|
||||
@@ -37,7 +37,7 @@ fun <E : Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?)
|
||||
putString(key, value?.name)
|
||||
}
|
||||
|
||||
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||
fun SharedPreferences.observeChanges(): Flow<String?> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
trySendBlocking(key)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||
|
||||
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
|
||||
emit(valueProducer())
|
||||
observe().collect { upstreamKey ->
|
||||
observeChanges().collect { upstreamKey ->
|
||||
if (upstreamKey == key) {
|
||||
emit(valueProducer())
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.res.Resources
|
||||
import android.database.sqlite.SQLiteFullException
|
||||
import androidx.annotation.DrawableRes
|
||||
import coil3.network.HttpException
|
||||
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.http2.StreamResetException
|
||||
import okio.FileNotFoundException
|
||||
@@ -20,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||
@@ -51,6 +54,7 @@ import java.net.SocketException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipException
|
||||
|
||||
private const val MSG_NO_SPACE_LEFT = "No space left on device"
|
||||
private const val MSG_CONNECTION_RESET = "Connection reset"
|
||||
@@ -59,212 +63,219 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
||||
private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$")
|
||||
|
||||
fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
?: resources.getString(R.string.error_occurred)
|
||||
|
||||
private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = when (this) {
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
is CancellationException -> cause?.getDisplayMessageOrNull(resources) ?: message
|
||||
is CaughtException -> cause.getDisplayMessageOrNull(resources)
|
||||
is WrapperIOException -> cause.getDisplayMessageOrNull(resources)
|
||||
is ScrobblerAuthRequiredException -> resources.getString(
|
||||
R.string.scrobbler_auth_required,
|
||||
resources.getString(scrobbler.titleResId),
|
||||
)
|
||||
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
|
||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||
is InteractiveActionRequiredException -> resources.getString(R.string.additional_action_required)
|
||||
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required_message)
|
||||
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
|
||||
is ActivityNotFoundException,
|
||||
is UnsupportedOperationException,
|
||||
-> resources.getString(R.string.operation_not_supported)
|
||||
|
||||
is TooManyRequestExceptions -> {
|
||||
val delay = getRetryDelay()
|
||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||
resources.formatDurationShort(delay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (formattedTime != null) {
|
||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||
} else {
|
||||
resources.getString(R.string.too_many_requests_message)
|
||||
}
|
||||
}
|
||||
is TooManyRequestExceptions -> {
|
||||
val delay = getRetryDelay()
|
||||
val formattedTime = if (delay > 0L && delay < Long.MAX_VALUE) {
|
||||
resources.formatDurationShort(delay)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (formattedTime != null) {
|
||||
resources.getString(R.string.too_many_requests_message_retry, formattedTime)
|
||||
} else {
|
||||
resources.getString(R.string.too_many_requests_message)
|
||||
}
|
||||
}
|
||||
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException -> message
|
||||
is ZipException -> resources.getString(R.string.error_corrupted_zip, this.message.orEmpty())
|
||||
is SQLiteFullException -> resources.getString(R.string.error_no_space_left)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> parseMessage(resources) ?: message
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
|
||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||
is SyncApiException,
|
||||
is ContentUnavailableException -> message
|
||||
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
is ParseException -> shortMessage
|
||||
is ConnectException,
|
||||
is UnknownHostException,
|
||||
is NoRouteToHostException,
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
is ImageDecodeException -> {
|
||||
val type = format?.substringBefore('/')
|
||||
val formatString = format.ifNullOrEmpty { resources.getString(R.string.unknown).lowercase(Locale.getDefault()) }
|
||||
if (type.isNullOrEmpty() || type == "image") {
|
||||
resources.getString(R.string.error_image_format, formatString)
|
||||
} else {
|
||||
resources.getString(R.string.error_not_image, formatString)
|
||||
}
|
||||
}
|
||||
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
is IncompatiblePluginException -> {
|
||||
cause?.getDisplayMessageOrNull(resources)?.let {
|
||||
resources.getString(R.string.plugin_incompatible_with_cause, it)
|
||||
} ?: resources.getString(R.string.plugin_incompatible)
|
||||
}
|
||||
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
|
||||
else -> mapDisplayMessage(message, resources) ?: message
|
||||
else -> mapDisplayMessage(message, resources) ?: message
|
||||
}.takeUnless { it.isNullOrBlank() }
|
||||
|
||||
@DrawableRes
|
||||
fun Throwable.getDisplayIcon(): Int = when (this) {
|
||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
is AuthRequiredException -> R.drawable.ic_auth_key_large
|
||||
is CloudFlareProtectedException -> R.drawable.ic_bot_large
|
||||
is UnknownHostException,
|
||||
is SocketTimeoutException,
|
||||
is ConnectException,
|
||||
is NoRouteToHostException,
|
||||
is ProtocolException -> R.drawable.ic_plug_large
|
||||
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
is CloudFlareBlockedException -> R.drawable.ic_denied_large
|
||||
|
||||
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||
else -> R.drawable.ic_error_large
|
||||
is InteractiveActionRequiredException -> R.drawable.ic_interaction_large
|
||||
else -> R.drawable.ic_error_large
|
||||
}
|
||||
|
||||
fun Throwable.getCauseUrl(): String? = when (this) {
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause.getCauseUrl()
|
||||
is WrapperIOException -> cause.getCauseUrl()
|
||||
is NoDataReceivedException -> url
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is InteractiveActionRequiredException -> url
|
||||
is HttpStatusException -> url
|
||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||
else -> null
|
||||
is ParseException -> url
|
||||
is NotFoundException -> url
|
||||
is TooManyRequestExceptions -> url
|
||||
is CaughtException -> cause.getCauseUrl()
|
||||
is WrapperIOException -> cause.getCauseUrl()
|
||||
is NoDataReceivedException -> url
|
||||
is CloudFlareBlockedException -> url
|
||||
is CloudFlareProtectedException -> url
|
||||
is InteractiveActionRequiredException -> url
|
||||
is HttpStatusException -> url
|
||||
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
|
||||
is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() }
|
||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
|
||||
404 -> resources.getString(R.string.not_found_404)
|
||||
403 -> resources.getString(R.string.access_denied_403)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
HttpURLConnection.HTTP_NOT_FOUND -> resources.getString(R.string.not_found_404)
|
||||
HttpURLConnection.HTTP_FORBIDDEN -> resources.getString(R.string.access_denied_403)
|
||||
HttpURLConnection.HTTP_GATEWAY_TIMEOUT -> resources.getString(R.string.network_unavailable)
|
||||
in 500..599 -> resources.getString(R.string.server_error, statusCode)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun mapDisplayMessage(msg: String?, resources: Resources): String? = when {
|
||||
msg.isNullOrEmpty() -> null
|
||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
|
||||
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||
else -> null
|
||||
msg.isNullOrEmpty() -> null
|
||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||
msg == MSG_CONNECTION_RESET -> resources.getString(R.string.error_connection_reset)
|
||||
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Throwable.isReportable(): Boolean {
|
||||
if (this is Error) {
|
||||
return true
|
||||
}
|
||||
if (this is CaughtException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (this is WrapperIOException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (ExceptionResolver.canResolve(this)) {
|
||||
return false
|
||||
}
|
||||
if (this is ParseException
|
||||
|| this.isNetworkError()
|
||||
|| this is CloudFlareBlockedException
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
if (this is Error) {
|
||||
return true
|
||||
}
|
||||
if (this is CaughtException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (this is WrapperIOException) {
|
||||
return cause.isReportable()
|
||||
}
|
||||
if (ExceptionResolver.canResolve(this)) {
|
||||
return false
|
||||
}
|
||||
if (this is ParseException
|
||||
|| this.isNetworkError()
|
||||
|| this is CloudFlareBlockedException
|
||||
|| this is CloudFlareProtectedException
|
||||
|| this is BadBackupFormatException
|
||||
|| this is WrongPasswordException
|
||||
|| this is TooManyRequestExceptions
|
||||
|| this is HttpStatusException
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun Throwable.isNetworkError(): Boolean {
|
||||
return this is UnknownHostException
|
||||
|| this is SocketTimeoutException
|
||||
|| this is StreamResetException
|
||||
|| this is SocketException
|
||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||
return this is UnknownHostException
|
||||
|| this is SocketTimeoutException
|
||||
|| this is StreamResetException
|
||||
|| this is SocketException
|
||||
|| this is HttpException && response.code == HttpURLConnection.HTTP_GATEWAY_TIMEOUT
|
||||
}
|
||||
|
||||
fun Throwable.report(silent: Boolean = false) {
|
||||
val exception = CaughtException(this)
|
||||
if (!silent) {
|
||||
exception.sendWithAcra()
|
||||
} else if (!BuildConfig.DEBUG) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
}
|
||||
val exception = CaughtException(this)
|
||||
if (!silent) {
|
||||
exception.sendWithAcra()
|
||||
} else if (!BuildConfig.DEBUG) {
|
||||
exception.sendSilentlyWithAcra()
|
||||
}
|
||||
}
|
||||
|
||||
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT)
|
||||
|
||||
fun FileNotFoundException.getFile(): File? {
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
return groups.getOrNull(1)?.let { File(it) }
|
||||
}
|
||||
|
||||
fun FileNotFoundException.parseMessage(resources: Resources): String? {
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
/*
|
||||
Examples:
|
||||
/storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system)
|
||||
/storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory)
|
||||
/storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error)
|
||||
*/
|
||||
val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null
|
||||
val path = groups.getOrNull(1)
|
||||
val error = groups.getOrNull(2)
|
||||
val baseMessageIs = when (error) {
|
||||
"EROFS" -> R.string.no_write_permission_to_file
|
||||
"ENOENT" -> R.string.file_not_found
|
||||
else -> return null
|
||||
}
|
||||
return if (path.isNullOrEmpty()) {
|
||||
resources.getString(baseMessageIs)
|
||||
} else {
|
||||
resources.getString(
|
||||
R.string.inline_preference_pattern,
|
||||
resources.getString(baseMessageIs),
|
||||
path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import com.google.android.material.slider.RangeSlider
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||
@@ -169,12 +168,16 @@ fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setOnContextClickListener(listener::onContextClick)
|
||||
fun View.setTooltipCompat(tooltip: CharSequence?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
tooltipText = tooltip
|
||||
} else if (!isLongClickable) { // don't use TooltipCompat if has a LongClickListener
|
||||
TooltipCompat.setTooltipText(this, tooltip)
|
||||
}
|
||||
}
|
||||
|
||||
fun View.setTooltipCompat(@StringRes tooltipResId: Int) = setTooltipCompat(context.getString(tooltipResId))
|
||||
|
||||
val Toolbar.menuView: ActionMenuView?
|
||||
get() {
|
||||
menu // to call ensureMenu()
|
||||
@@ -201,7 +204,7 @@ fun Chip.setProgressIcon() {
|
||||
fun View.setContentDescriptionAndTooltip(@StringRes resId: Int) {
|
||||
val text = resources.getString(resId)
|
||||
contentDescription = text
|
||||
TooltipCompat.setTooltipText(this, text)
|
||||
setTooltipCompat(text)
|
||||
}
|
||||
|
||||
fun View.getWindowBounds(): Rect {
|
||||
|
||||
@@ -7,97 +7,115 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||
import java.util.Locale
|
||||
|
||||
data class MangaDetails(
|
||||
private val manga: Manga,
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
private val manga: Manga,
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
val isLoaded: Boolean,
|
||||
) {
|
||||
|
||||
constructor(manga: Manga) : this(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = null,
|
||||
description = null,
|
||||
isLoaded = false,
|
||||
)
|
||||
constructor(manga: Manga) : this(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = null,
|
||||
description = null,
|
||||
isLoaded = false,
|
||||
)
|
||||
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
|
||||
val branches: Set<String?>
|
||||
get() = chapters.keys
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
val local: LocalManga?
|
||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||
|
||||
val local: LocalManga?
|
||||
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
|
||||
val coverUrl: String?
|
||||
get() = override?.coverUrl
|
||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||
.ifNullOrEmpty { manga.coverUrl }
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
|
||||
val coverUrl: String?
|
||||
get() = override?.coverUrl
|
||||
.ifNullOrEmpty { manga.largeCoverUrl }
|
||||
.ifNullOrEmpty { manga.coverUrl }
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
val isRestricted: Boolean
|
||||
get() = manga.state == MangaState.RESTRICTED
|
||||
|
||||
fun toManga() = manga.withOverride(override)
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
fun toManga() = mergedManga
|
||||
|
||||
fun filterChapters(branch: String?) = copy(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
copy(manga = manga.filterChapters(branch))
|
||||
},
|
||||
)
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
return it
|
||||
}
|
||||
return manga.source.getLocale()
|
||||
}
|
||||
|
||||
private fun mergeChapters(): List<MangaChapter> {
|
||||
val chapters = manga.chapters
|
||||
val localChapters = local?.manga?.chapters.orEmpty()
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return localChapters
|
||||
}
|
||||
val localMap = if (localChapters.isNotEmpty()) {
|
||||
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = ArrayList<MangaChapter>(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
result += local ?: chapter
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
result.addAll(localMap.values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun filterChapters(branch: String?) = copy(
|
||||
manga = manga.filterChapters(branch),
|
||||
localManga = localManga?.run {
|
||||
copy(manga = manga.filterChapters(branch))
|
||||
},
|
||||
)
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
private fun mergeChapters(): List<MangaChapter> {
|
||||
val chapters = manga.chapters
|
||||
val localChapters = local?.manga?.chapters.orEmpty()
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return localChapters
|
||||
}
|
||||
val localMap = if (localChapters.isNotEmpty()) {
|
||||
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val result = ArrayList<MangaChapter>(chapters.size)
|
||||
for (chapter in chapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
result += local ?: chapter
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
result.addAll(localMap.values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun findAppropriateLocale(name: String?): Locale? {
|
||||
if (name.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
return Locale.getAvailableLocales().find { lc ->
|
||||
name.contains(lc.getDisplayName(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) ||
|
||||
name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,30 +9,31 @@ import androidx.core.text.parseAsHtml
|
||||
import coil3.request.CachePolicy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.nav.MangaIntent
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.peek
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.recoverNotNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
class DetailsLoadUseCase @Inject constructor(
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
@@ -40,84 +41,116 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val recoverUseCase: RecoverMangaUseCase,
|
||||
private val imageGetter: Html.ImageGetter,
|
||||
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
|
||||
private val networkState: NetworkState,
|
||||
) {
|
||||
|
||||
operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = channelFlow {
|
||||
operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = flow {
|
||||
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) {
|
||||
"Cannot resolve intent $intent"
|
||||
}
|
||||
val override = mangaDataRepository.getOverride(manga.id)
|
||||
send(
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = null,
|
||||
description = manga.description?.parseAsHtml(withImages = false),
|
||||
isLoaded = false,
|
||||
),
|
||||
)
|
||||
val local = if (!manga.isLocal) {
|
||||
async {
|
||||
localMangaRepository.findSavedManga(manga)
|
||||
}
|
||||
if (manga.isLocal) {
|
||||
loadLocal(manga, override, force)
|
||||
} else {
|
||||
null
|
||||
loadRemote(manga, override, force)
|
||||
}
|
||||
if (!force && networkState.isOfflineOrRestricted()) {
|
||||
// try to avoid loading if has saved manga
|
||||
val localManga = local?.await()
|
||||
if (manga.isLocal || localManga != null) {
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = manga,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = manga.description?.parseAsHtml(withImages = true)?.trim(),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
return@channelFlow
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Load local manga + try to load the linked remote one if network is not restricted
|
||||
* Suppress any network errors
|
||||
*/
|
||||
private suspend fun FlowCollector<MangaDetails>.loadLocal(manga: Manga, override: MangaOverride?, force: Boolean) {
|
||||
val skipNetworkLoad = !force && networkState.isOfflineOrRestricted()
|
||||
val localDetails = localMangaRepository.getDetails(manga)
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = localDetails,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = localDetails.description?.parseAsHtml(withImages = false),
|
||||
isLoaded = skipNetworkLoad,
|
||||
),
|
||||
)
|
||||
if (skipNetworkLoad) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
val details = getDetails(manga, force)
|
||||
launch { mangaDataRepository.updateChapters(details) }
|
||||
launch { updateTracker(details) }
|
||||
send(
|
||||
val remoteManga = localMangaRepository.getRemoteManga(manga)
|
||||
if (remoteManga == null) {
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = details,
|
||||
localManga = local?.peek(),
|
||||
manga = localDetails,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = details.description?.parseAsHtml(withImages = false)?.trim(),
|
||||
isLoaded = false,
|
||||
),
|
||||
)
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = details,
|
||||
localManga = local?.await(),
|
||||
override = override,
|
||||
description = details.description?.parseAsHtml(withImages = true)?.trim(),
|
||||
description = localDetails.description?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
local?.await()?.manga?.also { localManga ->
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = localManga,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = localManga.description?.parseAsHtml(withImages = false)?.trim(),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
} ?: close(e)
|
||||
} else {
|
||||
val remoteDetails = getDetails(remoteManga, force).getOrNull()
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = remoteDetails ?: remoteManga,
|
||||
localManga = LocalManga(localDetails),
|
||||
override = override,
|
||||
description = (remoteDetails ?: localDetails).description?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
if (remoteDetails != null) {
|
||||
mangaDataRepository.updateChapters(remoteDetails)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load remote manga + saved one if available
|
||||
* Throw network errors after loading local manga only
|
||||
*/
|
||||
private suspend fun FlowCollector<MangaDetails>.loadRemote(
|
||||
manga: Manga,
|
||||
override: MangaOverride?,
|
||||
force: Boolean
|
||||
) = coroutineScope {
|
||||
val remoteDeferred = async {
|
||||
getDetails(manga, force)
|
||||
}
|
||||
val localManga = localMangaRepository.findSavedManga(manga, withDetails = true)
|
||||
if (localManga != null) {
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = manga,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = localManga.manga.description?.parseAsHtml(withImages = true),
|
||||
isLoaded = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
val remoteDetails = remoteDeferred.await().getOrThrow()
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = remoteDetails,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = (remoteDetails.description
|
||||
?: localManga?.manga?.description)?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
mangaDataRepository.updateChapters(remoteDetails)
|
||||
}
|
||||
|
||||
private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable {
|
||||
val repository = mangaRepositoryFactory.create(seed.source)
|
||||
if (repository is CachingMangaRepository) {
|
||||
@@ -131,20 +164,18 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrThrow()
|
||||
|
||||
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? {
|
||||
return if (withImages) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
parseAsHtml(imageGetter = imageGetter)
|
||||
}.filterSpans()
|
||||
} else {
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
parseAsHtml()
|
||||
}.filterSpans().sanitize()
|
||||
}.takeUnless { it.isBlank() }
|
||||
}
|
||||
|
||||
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? = if (withImages) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
parseAsHtml(imageGetter = imageGetter)
|
||||
}.filterSpans()
|
||||
} else {
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
parseAsHtml()
|
||||
}.filterSpans().sanitize()
|
||||
}.trim().nullIfEmpty()
|
||||
|
||||
private fun Spanned.filterSpans(): Spanned {
|
||||
val spannable = SpannableString.valueOf(this)
|
||||
val spans = spannable.getSpans<ForegroundColorSpan>()
|
||||
@@ -153,10 +184,4 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
}
|
||||
return spannable
|
||||
}
|
||||
|
||||
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
|
||||
newChaptersUseCaseProvider.get()(details)
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,17 @@ class ProgressUpdateUseCase @Inject constructor(
|
||||
}
|
||||
val chapter = details.findChapterById(history.chapterId) ?: return PROGRESS_NONE
|
||||
val chapters = details.getChapters(chapter.branch)
|
||||
val chapterRepo = if (repo.source == chapter.source) {
|
||||
repo
|
||||
} else {
|
||||
mangaRepositoryFactory.create(chapter.source)
|
||||
}
|
||||
val chaptersCount = chapters.size
|
||||
if (chaptersCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
}
|
||||
val chapterIndex = chapters.indexOfFirst { x -> x.id == history.chapterId }
|
||||
val pagesCount = repo.getPages(chapter).size
|
||||
val pagesCount = chapterRepo.getPages(chapter).size
|
||||
if (pagesCount == 0) {
|
||||
return PROGRESS_NONE
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class ReadingTimeUseCase @Inject constructor(
|
||||
// Impossible task, I guess. Good luck on this.
|
||||
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
|
||||
if (isOnHistoryBranch) {
|
||||
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||
averageTimeSec = (averageTimeSec * (1f - history.percent)).roundToInt()
|
||||
}
|
||||
if (averageTimeSec < 60) {
|
||||
return null
|
||||
|
||||
@@ -16,6 +16,7 @@ fun MangaDetails.mapChapters(
|
||||
branch: String?,
|
||||
bookmarks: List<Bookmark>,
|
||||
isGrid: Boolean,
|
||||
isDownloadedOnly: Boolean,
|
||||
): List<ChapterListItem> {
|
||||
val remoteChapters = chapters[branch].orEmpty()
|
||||
val localChapters = local?.manga?.getChapters(branch).orEmpty()
|
||||
@@ -35,19 +36,21 @@ fun MangaDetails.mapChapters(
|
||||
null
|
||||
}
|
||||
var isUnread = currentChapterId !in ids
|
||||
for (chapter in remoteChapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
if (chapter.id == currentChapterId) {
|
||||
isUnread = true
|
||||
if (!isDownloadedOnly || local?.manga?.chapters == null) {
|
||||
for (chapter in remoteChapters) {
|
||||
val local = localMap?.remove(chapter.id)
|
||||
if (chapter.id == currentChapterId) {
|
||||
isUnread = true
|
||||
}
|
||||
result += (local ?: chapter).toListItem(
|
||||
isCurrent = chapter.id == currentChapterId,
|
||||
isUnread = isUnread,
|
||||
isNew = isUnread && result.size >= newFrom,
|
||||
isDownloaded = local != null,
|
||||
isBookmarked = chapter.id in bookmarked,
|
||||
isGrid = isGrid,
|
||||
)
|
||||
}
|
||||
result += (local ?: chapter).toListItem(
|
||||
isCurrent = chapter.id == currentChapterId,
|
||||
isUnread = isUnread,
|
||||
isNew = isUnread && result.size >= newFrom,
|
||||
isDownloaded = local != null,
|
||||
isBookmarked = chapter.id in bookmarked,
|
||||
isGrid = isGrid,
|
||||
)
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
for (chapter in localMap.values) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.SpannedString
|
||||
import android.view.Gravity
|
||||
@@ -11,7 +10,6 @@ import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
@@ -80,6 +78,7 @@ import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.setTooltipCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.start
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
@@ -209,9 +208,7 @@ class DetailsActivity :
|
||||
|
||||
override fun onProvideAssistContent(outContent: AssistContent) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
viewModel.getMangaOrNull()?.publicUrl?.toUriOrNull()?.let { outContent.webUri = it }
|
||||
}
|
||||
|
||||
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.contentRating == ContentRating.ADULT }
|
||||
@@ -260,7 +257,7 @@ class DetailsActivity :
|
||||
R.id.button_scrobbling_more -> {
|
||||
router.showScrobblingSelectorSheet(
|
||||
manga = viewModel.getMangaOrNull() ?: return,
|
||||
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler
|
||||
scrobblerService = viewModel.scrobblingInfo.value.firstOrNull()?.scrobbler,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -389,7 +386,7 @@ class DetailsActivity :
|
||||
mangaGridItemAD(
|
||||
sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
|
||||
) { item, view ->
|
||||
router.openDetails(item)
|
||||
router.openDetails(item.toMangaWithOverride())
|
||||
},
|
||||
).also { rv.adapter = it }
|
||||
adapter.items = related
|
||||
@@ -455,7 +452,7 @@ class DetailsActivity :
|
||||
textViewSourceLabel.isVisible = false
|
||||
} else {
|
||||
textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity)
|
||||
TooltipCompat.setTooltipText(textViewSource, manga.source.getSummary(this@DetailsActivity))
|
||||
textViewSource.setTooltipCompat(manga.source.getSummary(this@DetailsActivity))
|
||||
textViewSourceLabel.isVisible = textViewSource.isVisible == true
|
||||
}
|
||||
val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip)
|
||||
|
||||
@@ -140,6 +140,7 @@ class DetailsViewModel @Inject constructor(
|
||||
get() = scrobblers.any { it.isEnabled }
|
||||
|
||||
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
|
||||
.withErrorHandling()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val relatedManga: StateFlow<List<MangaListModel>> = manga.mapLatest {
|
||||
@@ -182,7 +183,7 @@ class DetailsViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
loadingJob = doLoad(force = false)
|
||||
launchJob(Dispatchers.Default) {
|
||||
launchJob(Dispatchers.Default + SkipErrors) {
|
||||
val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
|
||||
val h = history.firstOrNull()
|
||||
if (h != null) {
|
||||
|
||||
@@ -106,7 +106,7 @@ class ReadButtonDelegate(
|
||||
}
|
||||
|
||||
private fun openReader(isIncognitoMode: Boolean) {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val manga = viewModel.getMangaOrNull() ?: return
|
||||
if (viewModel.historyInfo.value.isChapterMissing) {
|
||||
Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
|
||||
.show() // TODO
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user