Compare commits
247 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3e8e423962 | ||
|
|
881473f495 | ||
|
|
dc8ecf2b12 | ||
|
|
19d7a98968 | ||
|
|
b2538065a9 | ||
|
|
ebf37e3d08 | ||
|
|
8c37135a40 | ||
|
|
ca716f9a34 | ||
|
|
112ac70648 | ||
|
|
694e4d4baf | ||
|
|
e74afa06a1 | ||
|
|
4494cc1888 | ||
|
|
b13d9078d4 | ||
|
|
5999301de8 | ||
|
|
601b4016e6 | ||
|
|
4d13b8f7b0 | ||
|
|
6b95ec829e | ||
|
|
1c658fa3c3 | ||
|
|
32a8a8fed2 | ||
|
|
c63e0542a0 | ||
|
|
dea779fc4b | ||
|
|
339b8f7311 | ||
|
|
3c087dde11 | ||
|
|
dd1223229d | ||
|
|
0b393b0e81 | ||
|
|
b24cac9305 | ||
|
|
dc92723526 | ||
|
|
203020e100 | ||
|
|
05eac1eabd | ||
|
|
c7b720ec91 | ||
|
|
58026b6fc0 | ||
|
|
ba2f9dc16c | ||
|
|
97afa29785 | ||
|
|
97f2ff3bbd | ||
|
|
14f185393b | ||
|
|
fc7f5f2cf9 | ||
|
|
4088f50120 | ||
|
|
afa18e086c | ||
|
|
a8cfb3521c | ||
|
|
7047ee6155 | ||
|
|
957b12f338 | ||
|
|
679b1fd2f2 | ||
|
|
367a917c56 | ||
|
|
ed7fdb32a1 | ||
|
|
e8f0aa8388 | ||
|
|
7404612a84 | ||
|
|
512069ca3e | ||
|
|
b53b8eefa3 | ||
|
|
f7509b09c1 | ||
|
|
fbff0ab027 | ||
|
|
17656233ef | ||
|
|
18466a2c1a | ||
|
|
2797ea6a99 | ||
|
|
b4a298ea55 | ||
|
|
f811eeebc9 | ||
|
|
aeee782512 | ||
|
|
fe59a13218 | ||
|
|
c2688517ba | ||
|
|
95019f9eb6 | ||
|
|
f43769bde7 | ||
|
|
c576b62d51 | ||
|
|
722c4466bf | ||
|
|
61b863ae96 | ||
|
|
55ea0d7b2b | ||
|
|
04f56c6d84 | ||
|
|
e7aae4e72a | ||
|
|
3547e7afb8 | ||
|
|
a07e5ab278 | ||
|
|
1ddc32cbd4 | ||
|
|
80a30d059f | ||
|
|
437e6809bf | ||
|
|
b9d4c070eb | ||
|
|
4ef6908e82 | ||
|
|
b854ca8807 | ||
|
|
db89bdfdff | ||
|
|
dc1df527b2 | ||
|
|
584e93fbbf | ||
|
|
c2d4258afc | ||
|
|
60dca5f8c3 | ||
|
|
5d1afab071 | ||
|
|
851e417370 | ||
|
|
71a82ae187 | ||
|
|
0e54e4778e | ||
|
|
053ce880e4 | ||
|
|
67b1e4e862 | ||
|
|
e04a877310 | ||
|
|
48a605eeb0 | ||
|
|
aed08f18bb | ||
|
|
0c626cd2a3 | ||
|
|
82e6aa335b | ||
|
|
f79575e8d5 | ||
|
|
ac0dc0a94a | ||
|
|
7de4ac2b89 | ||
|
|
e01ddc0db7 | ||
|
|
5745eca683 | ||
|
|
8a8ee46234 | ||
|
|
26489627f2 | ||
|
|
341ced2d83 | ||
|
|
c5dd0eb375 | ||
|
|
33045ae36f | ||
|
|
442ebe5919 | ||
|
|
363bcbad18 | ||
|
|
5721cf71d3 | ||
|
|
ed8cc8d01f | ||
|
|
6c38f59e0f | ||
|
|
4d07311afc | ||
|
|
8ec81bb33f | ||
|
|
eb322d0dcd | ||
|
|
42a929d3f1 | ||
|
|
978167ad3f | ||
|
|
9767e1a87d | ||
|
|
4b822d6684 | ||
|
|
b59e41ef62 | ||
|
|
89ddfd3037 | ||
|
|
d97a2bba52 | ||
|
|
e72a8b2b8e | ||
|
|
c3c1d94f92 | ||
|
|
1f8c5a894a | ||
|
|
9dd05fcc70 | ||
|
|
e6487fb199 | ||
|
|
9d9f611091 | ||
|
|
a7c21515cd | ||
|
|
01f1a37bc1 | ||
|
|
5a24f43db3 | ||
|
|
61b2e96bf1 | ||
|
|
affbd0cdb6 | ||
|
|
a2c40a302b | ||
|
|
6ac6353e6a | ||
|
|
203dc1801a | ||
|
|
dc4fbf61a9 | ||
|
|
1fac005db7 | ||
|
|
e9ee658385 | ||
|
|
0785ba70ce | ||
|
|
c4c6867fef | ||
|
|
447a44208f | ||
|
|
eb9bd2ad5f | ||
|
|
5030d2c4c0 | ||
|
|
6b9fc7dd50 | ||
|
|
c7ca0d9707 | ||
|
|
e9a38d0d03 | ||
|
|
bc6ce75268 | ||
|
|
e545f19339 | ||
|
|
ac4682d62c | ||
|
|
3afb446564 | ||
|
|
0ed2232ac2 | ||
|
|
8d9129daaf | ||
|
|
f799606688 | ||
|
|
64adc4f58d | ||
|
|
f6aad3355a | ||
|
|
0badf10a8b | ||
|
|
e5118f5266 | ||
|
|
157d5e6c05 | ||
|
|
a02a8ff9db | ||
|
|
b1497f2ace | ||
|
|
099590c419 | ||
|
|
41d7fd1b86 | ||
|
|
d3d7912bb8 | ||
|
|
12f1ffd019 | ||
|
|
19d0fe97a0 | ||
|
|
771954ffb8 | ||
|
|
f4997f5a7f | ||
|
|
ff5a873d3b | ||
|
|
1b5720f2a5 | ||
|
|
a52730fff0 | ||
|
|
2dfc9b75a2 | ||
|
|
cc6f004e0e | ||
|
|
fa37c72923 | ||
|
|
ab2235d0ca | ||
|
|
cbf707b403 | ||
|
|
8971c7a6a2 | ||
|
|
1576c9cdde | ||
|
|
a0b8603510 | ||
|
|
5b899b16d0 | ||
|
|
a4b9acd622 | ||
|
|
c458f1eafb | ||
|
|
8f8abcc3f6 | ||
|
|
b4b9f90edc | ||
|
|
7cc777f0a6 | ||
|
|
61c068d4ee | ||
|
|
ff021b56f4 | ||
|
|
94ef64c4b7 | ||
|
|
8ad28fd509 | ||
|
|
7148ebcf34 | ||
|
|
1229e9626e | ||
|
|
4ec9a91644 | ||
|
|
1bbe1204e6 | ||
|
|
3aaddfd513 | ||
|
|
f5514728fe | ||
|
|
4fcb3a969b | ||
|
|
23f3182769 | ||
|
|
b84e10e69f | ||
|
|
ce3a1969c8 | ||
|
|
8282ca7d60 | ||
|
|
104d8da655 | ||
|
|
52c39ad40c | ||
|
|
842ecaaff6 | ||
|
|
8d325aea0a | ||
|
|
6cb090309a | ||
|
|
8d78b19128 | ||
|
|
5d890cb3d0 | ||
|
|
257f583f78 | ||
|
|
d45bab3879 | ||
|
|
c871255eb7 | ||
|
|
1a8045b89f | ||
|
|
f91f55fa66 | ||
|
|
10bd46f077 | ||
|
|
bd4fecc3b6 | ||
|
|
d542fa6bb6 |
22
README.md
22
README.md
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
||||||
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
||||||
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
|
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,18 +24,18 @@
|
|||||||
|
|
||||||
<div align="left">
|
<div align="left">
|
||||||
|
|
||||||
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources)
|
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1200+ manga sources)
|
||||||
* Search manga by name, genres, and more filters
|
* Search manga by name, genres and more filters
|
||||||
* Favorites organized by user-defined categories
|
* Favorites organized by user-defined categories
|
||||||
* Reading history, bookmarks, and incognito mode support
|
* Reading history, bookmarks and incognito mode support
|
||||||
* Download manga and read it offline. Third-party CBZ archives are also supported
|
* Download manga and read it offline. Third-party CBZ archives are also supported
|
||||||
* Clean and convenient Material You UI, optimized for phones, tablets, and desktop
|
* Clean and convenient Material You UI, optimized for phones, tablets and desktop
|
||||||
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
|
||||||
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
* Notifications about new chapters with updates feed, manga recommendations (with filters)
|
||||||
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
|
||||||
* Password / fingerprint-protected access to the app
|
* Password / fingerprint-protected access to the app
|
||||||
* Automatically sync app data with other devices on the same account
|
* Automatically sync app data with other devices on the same account
|
||||||
* Support for older devices running Android 5+
|
* Support for older devices running Android 5.0+
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -88,6 +88,16 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
|
|||||||
|
|
||||||
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||||
|
|
||||||
|
### Certificate fingerprints
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE
|
||||||
|
```
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
67:E1:51:00:BB:80:93:01:78:3E:DC:B6:34:8F:A3:BB:F8:30:34:D9:1E:62:86:8A:91:05:3D:BD:70:DB:3F:18
|
||||||
|
```
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
[](http://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import java.time.LocalDateTime
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'kotlin-android'
|
id 'kotlin-android'
|
||||||
id 'kotlin-kapt'
|
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
id 'dagger.hilt.android.plugin'
|
id 'dagger.hilt.android.plugin'
|
||||||
id 'androidx.room'
|
id 'androidx.room'
|
||||||
|
id 'org.jetbrains.kotlin.plugin.serialization'
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -19,80 +19,17 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1012
|
versionCode = 1023
|
||||||
versionName = '8.1.6'
|
versionName = '9.0.1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
arg('room.generateKotlin', 'true')
|
arg('room.generateKotlin', 'true')
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
|
// https://issuetracker.google.com/issues/408030127
|
||||||
generateLocaleConfig false
|
generateLocaleConfig false
|
||||||
}
|
}
|
||||||
resourceConfigurations += [
|
|
||||||
"en",
|
|
||||||
"ab",
|
|
||||||
"ar",
|
|
||||||
"arq",
|
|
||||||
"as",
|
|
||||||
"be",
|
|
||||||
"bn",
|
|
||||||
"ca",
|
|
||||||
"cs",
|
|
||||||
"de",
|
|
||||||
"el",
|
|
||||||
"en-rGB",
|
|
||||||
"enm",
|
|
||||||
"es",
|
|
||||||
"et",
|
|
||||||
"eu",
|
|
||||||
"fa",
|
|
||||||
"fi",
|
|
||||||
"fil",
|
|
||||||
"fr",
|
|
||||||
"frp",
|
|
||||||
"gu",
|
|
||||||
"hi",
|
|
||||||
"hr",
|
|
||||||
"hu",
|
|
||||||
"in",
|
|
||||||
"it",
|
|
||||||
"iw",
|
|
||||||
"ja",
|
|
||||||
"kk",
|
|
||||||
"km",
|
|
||||||
"ko",
|
|
||||||
"lt",
|
|
||||||
"lv",
|
|
||||||
"lzh",
|
|
||||||
"ml",
|
|
||||||
"ms",
|
|
||||||
"my",
|
|
||||||
"nb-rNO",
|
|
||||||
"ne",
|
|
||||||
"nn",
|
|
||||||
"or",
|
|
||||||
"pa",
|
|
||||||
"pa-rPK",
|
|
||||||
"pl",
|
|
||||||
"pt",
|
|
||||||
"pt-rBR",
|
|
||||||
"ro",
|
|
||||||
"ru",
|
|
||||||
"si",
|
|
||||||
"sr",
|
|
||||||
"sv",
|
|
||||||
"ta",
|
|
||||||
"th",
|
|
||||||
"tr",
|
|
||||||
"uk",
|
|
||||||
"vi",
|
|
||||||
"zh-rCN",
|
|
||||||
"zh-rTW",
|
|
||||||
// Specific BCP 47 locales
|
|
||||||
"b+zh+Hans+MO",
|
|
||||||
"b+zh+Hant+MO"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
@@ -135,12 +72,14 @@ android {
|
|||||||
'-opt-in=kotlin.ExperimentalStdlibApi',
|
'-opt-in=kotlin.ExperimentalStdlibApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
'-opt-in=kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi',
|
||||||
|
'-opt-in=kotlinx.coroutines.InternalForInheritanceCoroutinesApi',
|
||||||
'-opt-in=kotlinx.coroutines.FlowPreview',
|
'-opt-in=kotlinx.coroutines.FlowPreview',
|
||||||
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
'-opt-in=kotlin.contracts.ExperimentalContracts',
|
||||||
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
'-opt-in=coil3.annotation.ExperimentalCoilApi',
|
||||||
'-opt-in=coil3.annotation.InternalCoilApi',
|
'-opt-in=coil3.annotation.InternalCoilApi',
|
||||||
|
'-opt-in=kotlinx.serialization.ExperimentalSerializationApi',
|
||||||
'-Xjspecify-annotations=strict',
|
'-Xjspecify-annotations=strict',
|
||||||
'-Xtype-enhancement-improvements-strict-mode',
|
'-Xtype-enhancement-improvements-strict-mode'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
room {
|
room {
|
||||||
@@ -167,13 +106,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
afterEvaluate {
|
|
||||||
compileDebugKotlin {
|
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs += ['-opt-in=org.koitharu.kotatsu.parsers.InternalParsersApi']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dependencies {
|
dependencies {
|
||||||
def parsersVersion = libs.versions.parsers.get()
|
def parsersVersion = libs.versions.parsers.get()
|
||||||
if (System.properties.containsKey('parsersVersionOverride')) {
|
if (System.properties.containsKey('parsersVersionOverride')) {
|
||||||
@@ -201,6 +133,7 @@ dependencies {
|
|||||||
implementation libs.lifecycle.service
|
implementation libs.lifecycle.service
|
||||||
implementation libs.lifecycle.process
|
implementation libs.lifecycle.process
|
||||||
implementation libs.androidx.constraintlayout
|
implementation libs.androidx.constraintlayout
|
||||||
|
implementation libs.androidx.documentfile
|
||||||
implementation libs.androidx.swiperefreshlayout
|
implementation libs.androidx.swiperefreshlayout
|
||||||
implementation libs.androidx.recyclerview
|
implementation libs.androidx.recyclerview
|
||||||
implementation libs.androidx.viewpager2
|
implementation libs.androidx.viewpager2
|
||||||
@@ -221,14 +154,15 @@ dependencies {
|
|||||||
implementation libs.okhttp.tls
|
implementation libs.okhttp.tls
|
||||||
implementation libs.okhttp.dnsoverhttps
|
implementation libs.okhttp.dnsoverhttps
|
||||||
implementation libs.okio
|
implementation libs.okio
|
||||||
|
implementation libs.kotlinx.serialization.json
|
||||||
|
|
||||||
implementation libs.adapterdelegates
|
implementation libs.adapterdelegates
|
||||||
implementation libs.adapterdelegates.viewbinding
|
implementation libs.adapterdelegates.viewbinding
|
||||||
|
|
||||||
implementation libs.hilt.android
|
implementation libs.hilt.android
|
||||||
kapt libs.hilt.compiler
|
ksp libs.hilt.compiler
|
||||||
implementation libs.androidx.hilt.work
|
implementation libs.androidx.hilt.work
|
||||||
kapt libs.androidx.hilt.compiler
|
ksp libs.androidx.hilt.compiler
|
||||||
|
|
||||||
implementation libs.coil.core
|
implementation libs.coil.core
|
||||||
implementation libs.coil.network
|
implementation libs.coil.network
|
||||||
@@ -262,5 +196,5 @@ dependencies {
|
|||||||
androidTestImplementation libs.moshi.kotlin
|
androidTestImplementation libs.moshi.kotlin
|
||||||
|
|
||||||
androidTestImplementation libs.hilt.android.testing
|
androidTestImplementation libs.hilt.android.testing
|
||||||
kaptAndroidTest libs.hilt.android.compiler
|
kspAndroidTest libs.hilt.android.compiler
|
||||||
}
|
}
|
||||||
|
|||||||
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@@ -20,7 +20,7 @@
|
|||||||
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
-keep class org.koitharu.kotatsu.core.exceptions.* { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
|
||||||
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
|
||||||
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }
|
-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; }
|
||||||
-keep class org.jsoup.parser.Tag
|
-keep class org.jsoup.parser.Tag
|
||||||
-keep class org.jsoup.internal.StringUtil
|
-keep class org.jsoup.internal.StringUtil
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,13 +30,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 1552943969433540704,
|
"id": 1552943969433540704,
|
||||||
"name": "1 - 1",
|
"title": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -43,8 +46,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540705,
|
"id": 1552943969433540705,
|
||||||
"name": "1 - 2",
|
"title": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -52,8 +56,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540706,
|
"id": 1552943969433540706,
|
||||||
"name": "1 - 3",
|
"title": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -61,8 +66,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540707,
|
"id": 1552943969433540707,
|
||||||
"name": "1 - 4",
|
"title": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -70,8 +76,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433540708,
|
"id": 1552943969433540708,
|
||||||
"name": "1 - 5",
|
"title": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -79,8 +86,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541665,
|
"id": 1552943969433541665,
|
||||||
"name": "2 - 1",
|
"title": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -88,8 +96,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541666,
|
"id": 1552943969433541666,
|
||||||
"name": "2 - 2",
|
"title": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -97,8 +106,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541667,
|
"id": 1552943969433541667,
|
||||||
"name": "2 - 3",
|
"title": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -106,8 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541668,
|
"id": 1552943969433541668,
|
||||||
"name": "2 - 4",
|
"title": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -115,8 +126,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541669,
|
"id": 1552943969433541669,
|
||||||
"name": "2 - 5",
|
"title": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -124,8 +136,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433541670,
|
"id": 1552943969433541670,
|
||||||
"name": "2 - 6",
|
"title": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -133,8 +146,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542626,
|
"id": 1552943969433542626,
|
||||||
"name": "3 - 1",
|
"title": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -142,8 +156,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542627,
|
"id": 1552943969433542627,
|
||||||
"name": "3 - 2",
|
"title": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -151,8 +166,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 1552943969433542628,
|
"id": 1552943969433542628,
|
||||||
"name": "3 - 3",
|
"title": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -160,4 +176,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,8 +30,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [],
|
"chapters": [],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,13 +30,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"name": "1 - 1",
|
"title": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -43,8 +46,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"name": "1 - 2",
|
"title": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -52,8 +56,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"name": "1 - 3",
|
"title": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -61,8 +66,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"name": "1 - 4",
|
"title": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -70,8 +76,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"name": "1 - 5",
|
"title": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -79,8 +86,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541665,
|
"id": 3552943969433541665,
|
||||||
"name": "2 - 1",
|
"title": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -88,8 +96,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"name": "2 - 2",
|
"title": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -97,8 +106,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"name": "2 - 3",
|
"title": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -106,8 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"name": "2 - 4",
|
"title": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -115,8 +126,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"name": "2 - 5",
|
"title": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -124,8 +136,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"name": "2 - 6",
|
"title": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -133,4 +146,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,13 +30,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"name": "1 - 1",
|
"title": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -43,8 +46,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"name": "1 - 2",
|
"title": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -52,8 +56,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"name": "1 - 3",
|
"title": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -61,8 +66,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"name": "1 - 4",
|
"title": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -70,8 +76,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"name": "1 - 5",
|
"title": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -79,8 +86,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541665,
|
"id": 3552943969433541665,
|
||||||
"name": "2 - 1",
|
"title": "2 - 1",
|
||||||
"number": 6,
|
"number": 6,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/1",
|
"url": "/stranstviia_emanon/vol2/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1415570400000,
|
"uploadDate": 1415570400000,
|
||||||
@@ -88,8 +96,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"name": "2 - 2",
|
"title": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -97,8 +106,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"name": "2 - 3",
|
"title": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -106,8 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"name": "2 - 4",
|
"title": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -115,8 +126,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"name": "2 - 5",
|
"title": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -124,8 +136,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"name": "2 - 6",
|
"title": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -133,8 +146,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542626,
|
"id": 3552943969433542626,
|
||||||
"name": "3 - 1",
|
"title": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -142,8 +156,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542627,
|
"id": 3552943969433542627,
|
||||||
"name": "3 - 2",
|
"title": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -151,8 +166,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542628,
|
"id": 3552943969433542628,
|
||||||
"name": "3 - 3",
|
"title": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -160,4 +176,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,7 +30,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": null,
|
"description": null,
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": -2096681732556647985,
|
"id": -2096681732556647985,
|
||||||
"title": "Странствия Эманон",
|
"title": "Странствия Эманон",
|
||||||
|
"altTitles": [],
|
||||||
"url": "/stranstviia_emanon",
|
"url": "/stranstviia_emanon",
|
||||||
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
"publicUrl": "https://readmanga.io/stranstviia_emanon",
|
||||||
"rating": 0.9400894,
|
"rating": 0.9400894,
|
||||||
@@ -29,13 +30,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"state": "FINISHED",
|
"state": "FINISHED",
|
||||||
|
"authors": [],
|
||||||
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
"largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg",
|
||||||
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
"description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
|
||||||
"chapters": [
|
"chapters": [
|
||||||
{
|
{
|
||||||
"id": 3552943969433540704,
|
"id": 3552943969433540704,
|
||||||
"name": "1 - 1",
|
"title": "1 - 1",
|
||||||
"number": 1,
|
"number": 1,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/1",
|
"url": "/stranstviia_emanon/vol1/1",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -43,8 +46,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540705,
|
"id": 3552943969433540705,
|
||||||
"name": "1 - 2",
|
"title": "1 - 2",
|
||||||
"number": 2,
|
"number": 2,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/2",
|
"url": "/stranstviia_emanon/vol1/2",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -52,8 +56,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540706,
|
"id": 3552943969433540706,
|
||||||
"name": "1 - 3",
|
"title": "1 - 3",
|
||||||
"number": 3,
|
"number": 3,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/3",
|
"url": "/stranstviia_emanon/vol1/3",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -61,8 +66,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540707,
|
"id": 3552943969433540707,
|
||||||
"name": "1 - 4",
|
"title": "1 - 4",
|
||||||
"number": 4,
|
"number": 4,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/4",
|
"url": "/stranstviia_emanon/vol1/4",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -70,8 +76,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433540708,
|
"id": 3552943969433540708,
|
||||||
"name": "1 - 5",
|
"title": "1 - 5",
|
||||||
"number": 5,
|
"number": 5,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol1/5",
|
"url": "/stranstviia_emanon/vol1/5",
|
||||||
"scanlator": "Sad-Robot",
|
"scanlator": "Sad-Robot",
|
||||||
"uploadDate": 1342731600000,
|
"uploadDate": 1342731600000,
|
||||||
@@ -79,8 +86,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541666,
|
"id": 3552943969433541666,
|
||||||
"name": "2 - 2",
|
"title": "2 - 2",
|
||||||
"number": 7,
|
"number": 7,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/2",
|
"url": "/stranstviia_emanon/vol2/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1419976800000,
|
"uploadDate": 1419976800000,
|
||||||
@@ -88,8 +96,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541667,
|
"id": 3552943969433541667,
|
||||||
"name": "2 - 3",
|
"title": "2 - 3",
|
||||||
"number": 8,
|
"number": 8,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/3",
|
"url": "/stranstviia_emanon/vol2/3",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1427922000000,
|
"uploadDate": 1427922000000,
|
||||||
@@ -97,8 +106,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541668,
|
"id": 3552943969433541668,
|
||||||
"name": "2 - 4",
|
"title": "2 - 4",
|
||||||
"number": 9,
|
"number": 9,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/4",
|
"url": "/stranstviia_emanon/vol2/4",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1436907600000,
|
"uploadDate": 1436907600000,
|
||||||
@@ -106,8 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541669,
|
"id": 3552943969433541669,
|
||||||
"name": "2 - 5",
|
"title": "2 - 5",
|
||||||
"number": 10,
|
"number": 10,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/5",
|
"url": "/stranstviia_emanon/vol2/5",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1446674400000,
|
"uploadDate": 1446674400000,
|
||||||
@@ -115,8 +126,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433541670,
|
"id": 3552943969433541670,
|
||||||
"name": "2 - 6",
|
"title": "2 - 6",
|
||||||
"number": 11,
|
"number": 11,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol2/6",
|
"url": "/stranstviia_emanon/vol2/6",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1451512800000,
|
"uploadDate": 1451512800000,
|
||||||
@@ -124,8 +136,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542626,
|
"id": 3552943969433542626,
|
||||||
"name": "3 - 1",
|
"title": "3 - 1",
|
||||||
"number": 12,
|
"number": 12,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/1",
|
"url": "/stranstviia_emanon/vol3/1",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -133,8 +146,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542627,
|
"id": 3552943969433542627,
|
||||||
"name": "3 - 2",
|
"title": "3 - 2",
|
||||||
"number": 13,
|
"number": 13,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/2",
|
"url": "/stranstviia_emanon/vol3/2",
|
||||||
"scanlator": "Sup!",
|
"scanlator": "Sup!",
|
||||||
"uploadDate": 1461618000000,
|
"uploadDate": 1461618000000,
|
||||||
@@ -142,8 +156,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3552943969433542628,
|
"id": 3552943969433542628,
|
||||||
"name": "3 - 3",
|
"title": "3 - 3",
|
||||||
"number": 14,
|
"number": 14,
|
||||||
|
"volume": 0,
|
||||||
"url": "/stranstviia_emanon/vol3/3",
|
"url": "/stranstviia_emanon/vol3/3",
|
||||||
"scanlator": "",
|
"scanlator": "",
|
||||||
"uploadDate": 1465851600000,
|
"uploadDate": 1465851600000,
|
||||||
@@ -151,4 +166,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": "READMANGA_RU"
|
"source": "READMANGA_RU"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
package org.koitharu.kotatsu
|
package org.koitharu.kotatsu
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.squareup.moshi.*
|
import com.squareup.moshi.FromJson
|
||||||
|
import com.squareup.moshi.JsonAdapter
|
||||||
|
import com.squareup.moshi.JsonReader
|
||||||
|
import com.squareup.moshi.JsonWriter
|
||||||
|
import com.squareup.moshi.Moshi
|
||||||
|
import com.squareup.moshi.ToJson
|
||||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.source
|
import okio.source
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import java.util.*
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Date
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
object SampleData {
|
object SampleData {
|
||||||
|
|
||||||
private val moshi = Moshi.Builder()
|
private val moshi = Moshi.Builder()
|
||||||
.add(DateAdapter())
|
.add(DateAdapter())
|
||||||
|
.add(InstantAdapter())
|
||||||
|
.add(MangaSourceAdapter())
|
||||||
.add(KotlinJsonAdapterFactory())
|
.add(KotlinJsonAdapterFactory())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -51,4 +61,36 @@ object SampleData {
|
|||||||
writer.value(value?.time ?: 0L)
|
writer.value(value?.time ?: 0L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private class MangaSourceAdapter : JsonAdapter<MangaSource>() {
|
||||||
|
|
||||||
|
@FromJson
|
||||||
|
override fun fromJson(reader: JsonReader): MangaSource? {
|
||||||
|
val name = reader.nextString() ?: return null
|
||||||
|
return MangaSource(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToJson
|
||||||
|
override fun toJson(writer: JsonWriter, value: MangaSource?) {
|
||||||
|
writer.value(value?.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class InstantAdapter : JsonAdapter<Instant>() {
|
||||||
|
|
||||||
|
@FromJson
|
||||||
|
override fun fromJson(reader: JsonReader): Instant? {
|
||||||
|
val ms = reader.nextLong()
|
||||||
|
return if (ms == 0L) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Instant.ofEpochMilli(ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToJson
|
||||||
|
override fun toJson(writer: JsonWriter, value: Instant?) {
|
||||||
|
writer.value(value?.toEpochMilli() ?: 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import org.junit.Rule
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.koitharu.kotatsu.SampleData
|
import org.koitharu.kotatsu.SampleData
|
||||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.domain.AppBackupAgent
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import androidx.core.content.edit
|
|||||||
import androidx.fragment.app.strictmode.FragmentStrictMode
|
import androidx.fragment.app.strictmode.FragmentStrictMode
|
||||||
import leakcanary.LeakCanary
|
import leakcanary.LeakCanary
|
||||||
import org.koitharu.kotatsu.core.BaseApp
|
import org.koitharu.kotatsu.core.BaseApp
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
|
||||||
|
|
||||||
class KotatsuApp : BaseApp() {
|
class KotatsuApp : BaseApp() {
|
||||||
|
|
||||||
@@ -62,10 +58,6 @@ class KotatsuApp : BaseApp() {
|
|||||||
detectLeakedRegistrationObjects()
|
detectLeakedRegistrationObjects()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
|
||||||
detectFileUriExposure()
|
detectFileUriExposure()
|
||||||
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
|
|
||||||
setClassInstanceLimit(PagesCache::class.java, 1)
|
|
||||||
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
|
|
||||||
setClassInstanceLimit(PageLoader::class.java, 1)
|
|
||||||
penaltyLog()
|
penaltyLog()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
|
||||||
penaltyListener(notifier.executor, notifier)
|
penaltyListener(notifier.executor, notifier)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission
|
||||||
|
android:name="android.permission.FOREGROUND_SERVICE"
|
||||||
|
tools:ignore="ForegroundServicesPolicy" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
@@ -19,17 +21,19 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission
|
||||||
|
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||||
|
tools:ignore="RequestInstallPackagesPolicy" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="29" />
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
tools:ignore="ScopedStorage" />
|
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
@@ -44,7 +48,7 @@
|
|||||||
<application
|
<application
|
||||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
|
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent"
|
||||||
android:dataExtractionRules="@xml/backup_rules"
|
android:dataExtractionRules="@xml/backup_rules"
|
||||||
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
|
||||||
android:fullBackupContent="@xml/backup_content"
|
android:fullBackupContent="@xml/backup_content"
|
||||||
@@ -52,8 +56,8 @@
|
|||||||
android:hasFragileUserData="true"
|
android:hasFragileUserData="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:localeConfig="@xml/locales_config"
|
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
|
android:localeConfig="@xml/locales_config"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
@@ -209,6 +213,9 @@
|
|||||||
android:launchMode="singleTop" />
|
android:launchMode="singleTop" />
|
||||||
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
|
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
|
||||||
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
|
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.settings.override.OverrideConfigActivity"
|
||||||
|
android:label="@string/edit" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
|
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -262,6 +269,24 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
|
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
|
||||||
android:label="@string/tracker_debug_info" />
|
android:label="@string/tracker_debug_info" />
|
||||||
|
<activity
|
||||||
|
android:name="org.koitharu.kotatsu.picker.ui.PageImagePickActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/pick_manga_page">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.GET_CONTENT" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.OPENABLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PICK" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
@@ -272,7 +297,7 @@
|
|||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:label="@string/local_manga_processing" />
|
android:label="@string/local_manga_processing" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
|
android:name="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:label="@string/periodic_backups" />
|
android:label="@string/periodic_backups" />
|
||||||
<service
|
<service
|
||||||
@@ -283,9 +308,13 @@
|
|||||||
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
|
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
|
||||||
android:label="@string/local_manga_processing" />
|
android:label="@string/local_manga_processing" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.settings.backup.RestoreService"
|
android:name="org.koitharu.kotatsu.backups.ui.backup.BackupService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:label="@string/restore_backup" />
|
android:label="@string/creating_backup" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.backups.ui.restore.RestoreService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:label="@string/restoring_backup" />
|
||||||
<service
|
<service
|
||||||
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
android:name="org.koitharu.kotatsu.local.ui.ImportService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
@@ -335,6 +364,9 @@
|
|||||||
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:label="@string/prefetch_content" />
|
android:label="@string/prefetch_content" />
|
||||||
|
<service
|
||||||
|
android:name="org.koitharu.kotatsu.browser.AdListUpdateService"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
|
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
|
||||||
@@ -372,6 +404,13 @@
|
|||||||
tools:node="remove" />
|
tools:node="remove" />
|
||||||
</provider>
|
</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
|
<receiver
|
||||||
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
|
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -30,21 +30,19 @@ constructor(
|
|||||||
oldManga: Manga,
|
oldManga: Manga,
|
||||||
newManga: Manga,
|
newManga: Manga,
|
||||||
) {
|
) {
|
||||||
val oldDetails =
|
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||||
if (oldManga.chapters.isNullOrEmpty()) {
|
runCatchingCancellable {
|
||||||
runCatchingCancellable {
|
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
}.getOrDefault(oldManga)
|
||||||
}.getOrDefault(oldManga)
|
} else {
|
||||||
} else {
|
oldManga
|
||||||
oldManga
|
}
|
||||||
}
|
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||||
val newDetails =
|
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||||
if (newManga.chapters.isNullOrEmpty()) {
|
} else {
|
||||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
newManga
|
||||||
} else {
|
}
|
||||||
newManga
|
mangaDataRepository.storeManga(newDetails, replaceExisting = true)
|
||||||
}
|
|
||||||
mangaDataRepository.storeManga(newDetails)
|
|
||||||
database.withTransaction {
|
database.withTransaction {
|
||||||
// replace favorites
|
// replace favorites
|
||||||
val favoritesDao = database.getFavouritesDao()
|
val favoritesDao = database.getFavouritesDao()
|
||||||
@@ -101,11 +99,11 @@ constructor(
|
|||||||
mangaId = newDetails.id,
|
mangaId = newDetails.id,
|
||||||
rating = prevInfo.rating,
|
rating = prevInfo.rating,
|
||||||
status =
|
status =
|
||||||
prevInfo.status ?: when {
|
prevInfo.status ?: when {
|
||||||
newHistory == null -> ScrobblingStatus.PLANNED
|
newHistory == null -> ScrobblingStatus.PLANNED
|
||||||
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
|
||||||
else -> ScrobblingStatus.READING
|
else -> ScrobblingStatus.READING
|
||||||
},
|
},
|
||||||
comment = prevInfo.comment,
|
comment = prevInfo.comment,
|
||||||
)
|
)
|
||||||
if (newHistory != null) {
|
if (newHistory != null) {
|
||||||
|
|||||||
@@ -21,16 +21,11 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.model.getTitle
|
import org.koitharu.kotatsu.core.model.getTitle
|
||||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaExtra
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
@@ -104,13 +99,6 @@ fun alternativeAD(
|
|||||||
.allowRgb565(true)
|
.allowRgb565(true)
|
||||||
.enqueueWith(coil)
|
.enqueueWith(coil)
|
||||||
}
|
}
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
binding.imageViewCover.setImageAsync(item.manga.coverUrl, item.manga)
|
||||||
size(CoverSizeResolver(binding.imageViewCover))
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
transformations(TrimTransformation())
|
|
||||||
allowRgb565(true)
|
|
||||||
mangaExtra(item.manga)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
|||||||
}
|
}
|
||||||
val listAdapter = BaseListAdapter<ListModel>()
|
val listAdapter = BaseListAdapter<ListModel>()
|
||||||
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
||||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null))
|
||||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
|
.addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
|
||||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
import org.koitharu.kotatsu.core.model.getTitle
|
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.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
@@ -58,7 +59,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
autoFixUseCase.invoke(mangaId)
|
autoFixUseCase.invoke(mangaId)
|
||||||
}
|
}
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
val notification = buildNotification(result)
|
val notification = buildNotification(startId, result)
|
||||||
notificationManager.notify(TAG, startId, notification)
|
notificationManager.notify(TAG, startId, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +68,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
val notification = runBlocking { buildNotification(Result.failure(error)) }
|
val notification = runBlocking { buildNotification(startId, Result.failure(error)) }
|
||||||
notificationManager.notify(TAG, startId, notification)
|
notificationManager.notify(TAG, startId, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
|
private suspend fun buildNotification(startId: Int, result: Result<Pair<Manga, Manga?>>): Notification {
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
.setDefaults(0)
|
.setDefaults(0)
|
||||||
@@ -135,7 +136,11 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
).setVisibility(
|
).setVisibility(
|
||||||
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
|
if (replacement.isNsfw()) {
|
||||||
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
|
} else {
|
||||||
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
|
},
|
||||||
)
|
)
|
||||||
notification
|
notification
|
||||||
.setContentTitle(applicationContext.getString(R.string.fixed))
|
.setContentTitle(applicationContext.getString(R.string.fixed))
|
||||||
@@ -165,12 +170,13 @@ class AutoFixService : CoroutineIntentService() {
|
|||||||
error.getDisplayMessage(applicationContext.resources)
|
error.getDisplayMessage(applicationContext.resources)
|
||||||
},
|
},
|
||||||
).setSmallIcon(android.R.drawable.stat_notify_error)
|
).setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
ErrorReporterReceiver.getNotificationAction(
|
||||||
notification.addAction(
|
context = applicationContext,
|
||||||
R.drawable.ic_alert_outline,
|
e = error,
|
||||||
applicationContext.getString(R.string.report),
|
notificationId = startId,
|
||||||
reportIntent,
|
notificationTag = TAG,
|
||||||
)
|
)?.let { action ->
|
||||||
|
notification.addAction(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return notification.build()
|
return notification.build()
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data
|
||||||
|
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import dagger.Reusable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.collectIndexed
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
|
import kotlinx.serialization.SerializationStrategy
|
||||||
|
import kotlinx.serialization.json.DecodeSequenceMode
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeToSequence
|
||||||
|
import kotlinx.serialization.json.encodeToStream
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BookmarkBackup
|
||||||
|
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.SourceBackup
|
||||||
|
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.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@Reusable
|
||||||
|
class BackupRepository @Inject constructor(
|
||||||
|
private val database: MangaDatabase,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val tapGridSettings: TapGridSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
|
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.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_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.SOURCES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.SOURCES,
|
||||||
|
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
commonProgress++
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||||
|
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getFavouritesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SETTINGS -> input.readMap().let {
|
||||||
|
settings.upsertAll(it)
|
||||||
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||||
|
tapGridSettings.upsertAll(it)
|
||||||
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||||
|
getSourcesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> CompositeResult.EMPTY // skip unknown entries
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
commonProgress++
|
||||||
|
}
|
||||||
|
input.closeEntry()
|
||||||
|
entry = input.nextEntry
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <T> InputStream.readJsonArray(
|
||||||
|
serializer: DeserializationStrategy<T>,
|
||||||
|
): Sequence<T> = json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BackupIndex(
|
||||||
|
@SerialName("app_id") val appId: String,
|
||||||
|
@SerialName("app_version") val appVersion: Int,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor() : this(
|
||||||
|
appId = BuildConfig.APPLICATION_ID,
|
||||||
|
appVersion = BuildConfig.VERSION_CODE,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BookmarkBackup(
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
@SerialName("tags") val tags: Set<TagBackup>,
|
||||||
|
@SerialName("bookmarks") val bookmarks: List<Bookmark>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Bookmark(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("page_id") val pageId: Long,
|
||||||
|
@SerialName("chapter_id") val chapterId: Long,
|
||||||
|
@SerialName("page") val page: Int,
|
||||||
|
@SerialName("scroll") val scroll: Int,
|
||||||
|
@SerialName("image_url") val imageUrl: String,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("percent") val percent: Float,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun toEntity() = BookmarkEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
pageId = pageId,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
createdAt = createdAt,
|
||||||
|
percent = percent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(manga: MangaWithTags, entities: List<BookmarkEntity>) : this(
|
||||||
|
manga = MangaBackup(manga.copy(tags = emptyList())),
|
||||||
|
tags = manga.tags.mapToSet { TagBackup(it) },
|
||||||
|
bookmarks = entities.map {
|
||||||
|
Bookmark(
|
||||||
|
mangaId = it.mangaId,
|
||||||
|
pageId = it.pageId,
|
||||||
|
chapterId = it.chapterId,
|
||||||
|
page = it.page,
|
||||||
|
scroll = it.scroll,
|
||||||
|
imageUrl = it.imageUrl,
|
||||||
|
createdAt = it.createdAt,
|
||||||
|
percent = it.percent,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class CategoryBackup(
|
||||||
|
@SerialName("category_id") val categoryId: Int,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("sort_key") val sortKey: Int,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("order") val order: String = ListSortOrder.NEWEST.name,
|
||||||
|
@SerialName("track") val track: Boolean = true,
|
||||||
|
@SerialName("show_in_lib") val isVisibleInLibrary: Boolean = true,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: FavouriteCategoryEntity) : this(
|
||||||
|
categoryId = entity.categoryId,
|
||||||
|
createdAt = entity.createdAt,
|
||||||
|
sortKey = entity.sortKey,
|
||||||
|
title = entity.title,
|
||||||
|
order = entity.order,
|
||||||
|
track = entity.track,
|
||||||
|
isVisibleInLibrary = entity.isVisibleInLibrary,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = FavouriteCategoryEntity(
|
||||||
|
categoryId = categoryId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
sortKey = sortKey,
|
||||||
|
title = title,
|
||||||
|
order = order,
|
||||||
|
track = track,
|
||||||
|
isVisibleInLibrary = isVisibleInLibrary,
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteManga
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FavouriteBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("category_id") val categoryId: Long,
|
||||||
|
@SerialName("sort_key") val sortKey: Int = 0,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: FavouriteManga) : this(
|
||||||
|
mangaId = entity.manga.id,
|
||||||
|
categoryId = entity.favourite.categoryId,
|
||||||
|
sortKey = entity.favourite.sortKey,
|
||||||
|
isPinned = entity.favourite.isPinned,
|
||||||
|
createdAt = entity.favourite.createdAt,
|
||||||
|
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = FavouriteEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
categoryId = categoryId,
|
||||||
|
sortKey = sortKey,
|
||||||
|
isPinned = isPinned,
|
||||||
|
createdAt = createdAt,
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryWithManga
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class HistoryBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("updated_at") val updatedAt: Long,
|
||||||
|
@SerialName("chapter_id") val chapterId: Long,
|
||||||
|
@SerialName("page") val page: Int,
|
||||||
|
@SerialName("scroll") val scroll: Float,
|
||||||
|
@SerialName("percent") val percent: Float = PROGRESS_NONE,
|
||||||
|
@SerialName("chapters") val chaptersCount: Int = 0,
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: HistoryWithManga) : this(
|
||||||
|
mangaId = entity.manga.id,
|
||||||
|
createdAt = entity.history.createdAt,
|
||||||
|
updatedAt = entity.history.updatedAt,
|
||||||
|
chapterId = entity.history.chapterId,
|
||||||
|
page = entity.history.page,
|
||||||
|
scroll = entity.history.scroll,
|
||||||
|
percent = entity.history.percent,
|
||||||
|
chaptersCount = entity.history.chaptersCount,
|
||||||
|
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = HistoryEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
percent = percent,
|
||||||
|
deletedAt = 0L,
|
||||||
|
chaptersCount = chaptersCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaBackup(
|
||||||
|
@SerialName("id") val id: Long,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("alt_title") val altTitles: String? = null,
|
||||||
|
@SerialName("url") val url: String,
|
||||||
|
@SerialName("public_url") val publicUrl: String,
|
||||||
|
@SerialName("rating") val rating: Float = RATING_UNKNOWN,
|
||||||
|
@SerialName("nsfw") val isNsfw: Boolean = false,
|
||||||
|
@SerialName("content_rating") val contentRating: String? = null,
|
||||||
|
@SerialName("cover_url") val coverUrl: String,
|
||||||
|
@SerialName("large_cover_url") val largeCoverUrl: String? = null,
|
||||||
|
@SerialName("state") val state: String? = null,
|
||||||
|
@SerialName("author") val authors: String? = null,
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("tags") val tags: Set<TagBackup> = emptySet(),
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: MangaWithTags) : this(
|
||||||
|
id = entity.manga.id,
|
||||||
|
title = entity.manga.title,
|
||||||
|
altTitles = entity.manga.altTitles,
|
||||||
|
url = entity.manga.url,
|
||||||
|
publicUrl = entity.manga.publicUrl,
|
||||||
|
rating = entity.manga.rating,
|
||||||
|
isNsfw = entity.manga.isNsfw,
|
||||||
|
contentRating = entity.manga.contentRating,
|
||||||
|
coverUrl = entity.manga.coverUrl,
|
||||||
|
largeCoverUrl = entity.manga.largeCoverUrl,
|
||||||
|
state = entity.manga.state,
|
||||||
|
authors = entity.manga.authors,
|
||||||
|
source = entity.manga.source,
|
||||||
|
tags = entity.tags.mapToSet { TagBackup(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = MangaEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
altTitles = altTitles,
|
||||||
|
url = url,
|
||||||
|
publicUrl = publicUrl,
|
||||||
|
rating = rating,
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
contentRating = contentRating,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
largeCoverUrl = largeCoverUrl,
|
||||||
|
state = state,
|
||||||
|
authors = authors,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SourceBackup(
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("sort_key") val sortKey: Int,
|
||||||
|
@SerialName("used_at") val lastUsedAt: Long,
|
||||||
|
@SerialName("added_in") val addedIn: Int,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
@SerialName("enabled") val isEnabled: Boolean = true, // for compatibility purposes, should be only true
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: MangaSourceEntity) : this(
|
||||||
|
source = entity.source,
|
||||||
|
sortKey = entity.sortKey,
|
||||||
|
lastUsedAt = entity.lastUsedAt,
|
||||||
|
addedIn = entity.addedIn,
|
||||||
|
isPinned = entity.isPinned,
|
||||||
|
isEnabled = entity.isEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = MangaSourceEntity(
|
||||||
|
source = source,
|
||||||
|
isEnabled = isEnabled,
|
||||||
|
sortKey = sortKey,
|
||||||
|
addedIn = addedIn,
|
||||||
|
lastUsedAt = lastUsedAt,
|
||||||
|
isPinned = isPinned,
|
||||||
|
cfState = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TagBackup(
|
||||||
|
@SerialName("id") val id: Long,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("key") val key: String,
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: TagEntity) : this(
|
||||||
|
id = entity.id,
|
||||||
|
title = entity.title,
|
||||||
|
key = entity.key,
|
||||||
|
source = entity.source,
|
||||||
|
isPinned = entity.isPinned,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = TagEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
key = key,
|
||||||
|
source = source,
|
||||||
|
isPinned = isPinned,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.app.backup.BackupAgent
|
||||||
|
import android.app.backup.BackupDataInput
|
||||||
|
import android.app.backup.BackupDataOutput
|
||||||
|
import android.app.backup.FullBackupDataOutput
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.google.common.io.ByteStreams
|
||||||
|
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.reader.data.TapGridSettings
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
class AppBackupAgent : BackupAgent() {
|
||||||
|
|
||||||
|
override fun onBackup(
|
||||||
|
oldState: ParcelFileDescriptor?,
|
||||||
|
data: BackupDataOutput?,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onRestore(
|
||||||
|
data: BackupDataInput?,
|
||||||
|
appVersionCode: Int,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||||
|
super.onFullBackup(data)
|
||||||
|
val file =
|
||||||
|
createBackupFile(
|
||||||
|
this,
|
||||||
|
BackupRepository(
|
||||||
|
MangaDatabase(context = applicationContext),
|
||||||
|
AppSettings(applicationContext),
|
||||||
|
TapGridSettings(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fullBackupFile(file, data)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreFile(
|
||||||
|
data: ParcelFileDescriptor,
|
||||||
|
size: Long,
|
||||||
|
destination: File?,
|
||||||
|
type: Int,
|
||||||
|
mode: Long,
|
||||||
|
mtime: Long
|
||||||
|
) {
|
||||||
|
if (destination?.name?.endsWith(".bk.zip") == true) {
|
||||||
|
restoreBackupFile(
|
||||||
|
data.fileDescriptor,
|
||||||
|
size,
|
||||||
|
BackupRepository(
|
||||||
|
database = MangaDatabase(applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
|
tapGridSettings = TapGridSettings(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
destination.delete()
|
||||||
|
} else {
|
||||||
|
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun createBackupFile(context: Context, repository: BackupRepository): File {
|
||||||
|
val file = BackupUtils.createTempFile(context)
|
||||||
|
ZipOutputStream(file.outputStream()).use { output ->
|
||||||
|
runBlocking {
|
||||||
|
repository.createBackup(output, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||||
|
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
||||||
|
runBlocking {
|
||||||
|
repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -6,7 +6,7 @@ import java.util.Date
|
|||||||
data class BackupFile(
|
data class BackupFile(
|
||||||
val uri: Uri,
|
val uri: Uri,
|
||||||
val dateTime: Date,
|
val dateTime: Date,
|
||||||
): Comparable<BackupFile> {
|
) : Comparable<BackupFile> {
|
||||||
|
|
||||||
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
|
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
import android.app.backup.BackupManager
|
import android.app.backup.BackupManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -13,7 +13,13 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class BackupObserver @Inject constructor(
|
class BackupObserver @Inject constructor(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
|
) : InvalidationTracker.Observer(
|
||||||
|
arrayOf(
|
||||||
|
TABLE_HISTORY,
|
||||||
|
TABLE_FAVOURITES,
|
||||||
|
TABLE_FAVOURITE_CATEGORIES,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
|
||||||
private val backupManager = BackupManager(context)
|
private val backupManager = BackupManager(context)
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
|
||||||
|
enum class BackupSection(
|
||||||
|
val entryName: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
INDEX("index"),
|
||||||
|
HISTORY("history"),
|
||||||
|
CATEGORIES("categories"),
|
||||||
|
FAVOURITES("favourites"),
|
||||||
|
SETTINGS("settings"),
|
||||||
|
SETTINGS_READER_GRID("reader_grid"),
|
||||||
|
BOOKMARKS("bookmarks"),
|
||||||
|
SOURCES("sources"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(entry: ZipEntry): BackupSection? {
|
||||||
|
val name = entry.name.lowercase(Locale.ROOT)
|
||||||
|
return entries.first { x -> x.entryName == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import java.io.File
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object BackupUtils {
|
||||||
|
|
||||||
|
private const val DIR_BACKUPS = "backups"
|
||||||
|
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun createTempFile(context: Context): File {
|
||||||
|
val dir = getAppBackupDir(context)
|
||||||
|
dir.mkdirs()
|
||||||
|
return File(dir, generateFileName(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAppBackupDir(context: Context) = context.run {
|
||||||
|
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBackupDateTime(fileName: String): Date? = try {
|
||||||
|
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateFileName(context: Context) = buildString {
|
||||||
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
|
append('_')
|
||||||
|
append(dateTimeFormat.format(Date()))
|
||||||
|
append(".bk.zip")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -28,7 +28,7 @@ class ExternalBackupStorage @Inject constructor(
|
|||||||
BackupFile(
|
BackupFile(
|
||||||
uri = it.uri,
|
uri = it.uri,
|
||||||
dateTime = it.name?.let { fileName ->
|
dateTime = it.name?.let { fileName ->
|
||||||
BackupZipOutput.parseBackupDateTime(fileName)
|
BackupUtils.parseBackupDateTime(fileName)
|
||||||
} ?: return@mapNotNull null,
|
} ?: return@mapNotNull null,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -44,7 +44,12 @@ class ExternalBackupStorage @Inject constructor(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
|
|
||||||
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
|
||||||
val out = checkNotNull(getRootOrThrow().createFile("application/zip", file.nameWithoutExtension)) {
|
val out = checkNotNull(
|
||||||
|
getRootOrThrow().createFile(
|
||||||
|
"application/zip",
|
||||||
|
file.nameWithoutExtension,
|
||||||
|
),
|
||||||
|
) {
|
||||||
"Cannot create target backup file"
|
"Cannot create target backup file"
|
||||||
}
|
}
|
||||||
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getFileDisplayName
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
protected abstract val notificationTag: String
|
||||||
|
protected abstract val isRestoreService: Boolean
|
||||||
|
|
||||||
|
protected lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
|
createNotificationChannel(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
|
showResultNotification(null, CompositeResult.failure(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun IntentJobContext.showResultNotification(
|
||||||
|
fileUri: Uri?,
|
||||||
|
result: CompositeResult,
|
||||||
|
) {
|
||||||
|
if (!applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSubText(fileUri?.let { contentResolver.getFileDisplayName(it) })
|
||||||
|
when {
|
||||||
|
result.isAllSuccess -> {
|
||||||
|
if (isRestoreService) {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setContentText(getString(R.string.data_restored_success))
|
||||||
|
} else {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.backup_saved))
|
||||||
|
.setContentText(fileUri?.let { contentResolver.getFileDisplayName(it) })
|
||||||
|
.setSubText(null)
|
||||||
|
|
||||||
|
}
|
||||||
|
notification.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.isAllFailed || !isRestoreService -> {
|
||||||
|
val title = getString(if (isRestoreService) R.string.data_not_restored else R.string.error_occurred)
|
||||||
|
val message = result.failures.joinToString("\n") {
|
||||||
|
it.getDisplayMessage(applicationContext.resources)
|
||||||
|
}
|
||||||
|
notification
|
||||||
|
.setContentText(if (isRestoreService) getString(R.string.data_not_restored_text) else message)
|
||||||
|
.setBigText(title, message)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
result.failures.firstNotNullOfOrNull { error ->
|
||||||
|
ErrorReporterReceiver.getNotificationAction(applicationContext, error, startId, notificationTag)
|
||||||
|
}?.let { action ->
|
||||||
|
notification.addAction(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
notification
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setContentText(getString(R.string.data_restored_with_errors))
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
AppRouter.homeIntent(this@BaseBackupRestoreService),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!isRestoreService && fileUri != null) {
|
||||||
|
val shareIntent = ShareCompat.IntentBuilder(this@BaseBackupRestoreService)
|
||||||
|
.setStream(fileUri)
|
||||||
|
.setType("application/zip")
|
||||||
|
.setChooserTitle(R.string.share_backup)
|
||||||
|
.createChooserIntent()
|
||||||
|
notification.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_menu_share_mtrl_alpha,
|
||||||
|
getString(R.string.share),
|
||||||
|
PendingIntentCompat.getActivity(this@BaseBackupRestoreService, 0, shareIntent, 0, false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notificationManager.notify(notificationTag, startId, notification.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun NotificationCompat.Builder.setBigText(title: String, text: CharSequence) = setStyle(
|
||||||
|
NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(text)
|
||||||
|
.setSummaryText(text)
|
||||||
|
.setBigContentTitle(title),
|
||||||
|
)
|
||||||
|
|
||||||
|
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,10 +1,10 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -14,26 +14,14 @@ import org.koitharu.kotatsu.core.ui.AlertDialogFragment
|
|||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||||
|
|
||||||
private val viewModel by viewModels<BackupViewModel>()
|
private val viewModel by viewModels<BackupViewModel>()
|
||||||
|
|
||||||
private val saveFileContract = registerForActivityResult(
|
|
||||||
ActivityResultContracts.CreateDocument("application/zip"),
|
|
||||||
) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
viewModel.saveBackup(uri)
|
|
||||||
} else {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(
|
override fun onCreateViewBinding(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -47,7 +35,6 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
|||||||
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
|
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
|
||||||
viewModel.onBackupDone.observeEvent(viewLifecycleOwner, this::onBackupDone)
|
viewModel.onBackupDone.observeEvent(viewLifecycleOwner, this::onBackupDone)
|
||||||
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
|
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
|
||||||
viewModel.onBackupSaved.observeEvent(viewLifecycleOwner) { onBackupSaved() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
|
||||||
@@ -77,14 +64,7 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onBackupDone(file: File) {
|
private fun onBackupDone(uri: Uri) {
|
||||||
if (!saveFileContract.tryLaunch(file.name)) {
|
|
||||||
Toast.makeText(requireContext(), R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onBackupSaved() {
|
|
||||||
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
class BackupService : BaseBackupRestoreService() {
|
||||||
|
|
||||||
|
override val notificationTag = TAG
|
||||||
|
override val isRestoreService = false
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
val notification = buildNotification(Progress.INDETERMINATE)
|
||||||
|
setForeground(
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
val destination = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
||||||
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
launch {
|
||||||
|
progress.collect {
|
||||||
|
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ZipOutputStream(contentResolver.openOutputStream(destination)).use { output ->
|
||||||
|
repository.createBackup(output, progress)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
try {
|
||||||
|
DocumentFile.fromSingleUri(applicationContext, destination)?.delete()
|
||||||
|
} catch (e2: Throwable) {
|
||||||
|
e.addSuppressed(e2)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
progressUpdateJob?.cancelAndJoin()
|
||||||
|
contentResolver.notifyChange(destination, null)
|
||||||
|
showResultNotification(destination, CompositeResult.success())
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(this@BackupService, R.string.backup_saved, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
||||||
|
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.creating_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(
|
||||||
|
progress.total.coerceAtLeast(0),
|
||||||
|
progress.progress.coerceAtLeast(0),
|
||||||
|
progress.isIndeterminate,
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
if (progress.isIndeterminate) {
|
||||||
|
getString(R.string.processing_)
|
||||||
|
} else {
|
||||||
|
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "BACKUP"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 33
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun start(context: Context, uri: Uri): Boolean = try {
|
||||||
|
val intent = Intent(context, BackupService::class.java)
|
||||||
|
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BackupViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val repository: BackupRepository,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val onBackupDone = MutableEventFlow<Uri>()
|
||||||
|
|
||||||
|
private val destination = savedStateHandle.require<Uri>(AppRouter.KEY_DATA)
|
||||||
|
private val contentResolver: ContentResolver = context.contentResolver
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
ZipOutputStream(checkNotNull(contentResolver.openOutputStream(destination))).use {
|
||||||
|
it.setLevel(Deflater.BEST_COMPRESSION)
|
||||||
|
repository.createBackup(it, progress)
|
||||||
|
}
|
||||||
|
onBackupDone.call(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class PeriodicalBackupService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var externalBackupStorage: ExternalBackupStorage
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var telegramBackupUploader: TelegramBackupUploader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
if (!settings.isPeriodicalBackupEnabled || settings.periodicalBackupDirectory == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastBackupDate = externalBackupStorage.getLastBackupDate()
|
||||||
|
if (lastBackupDate != null && lastBackupDate.time + settings.periodicalBackupFrequencyMillis > System.currentTimeMillis()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val output = BackupUtils.createTempFile(applicationContext)
|
||||||
|
try {
|
||||||
|
ZipOutputStream(output.outputStream()).use {
|
||||||
|
repository.createBackup(it, null)
|
||||||
|
}
|
||||||
|
externalBackupStorage.put(output)
|
||||||
|
externalBackupStorage.trim(settings.periodicalBackupMaxCount)
|
||||||
|
if (settings.isBackupTelegramUploadEnabled) {
|
||||||
|
telegramBackupUploader.uploadBackup(output)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
output.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -6,13 +6,13 @@ import android.os.Bundle
|
|||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.activity.result.ActivityResultCallback
|
import androidx.activity.result.ActivityResultCallback
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.preference.EditTextPreference
|
import androidx.preference.EditTextPreference
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.backup.TelegramBackupUploader
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
|
import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
|
||||||
@@ -85,6 +85,13 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
|
|||||||
"" -> null
|
"" -> null
|
||||||
else -> path
|
else -> path
|
||||||
}
|
}
|
||||||
|
preference.icon = if (path == null) {
|
||||||
|
ContextCompat.getDrawable(preference.context, R.drawable.ic_alert_outline)?.also {
|
||||||
|
it.setTint(ContextCompat.getColor(preference.context, R.color.warning))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
private fun bindLastBackupInfo(lastBackupDate: Date?) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -8,16 +8,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.backup.BackupZipOutput.Companion.DIR_BACKUPS
|
import org.koitharu.kotatsu.backups.domain.BackupUtils
|
||||||
import org.koitharu.kotatsu.core.backup.ExternalBackupStorage
|
import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage
|
||||||
import org.koitharu.kotatsu.core.backup.TelegramBackupUploader
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
import org.koitharu.kotatsu.core.util.ext.resolveFile
|
||||||
import java.io.File
|
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -60,7 +58,7 @@ class PeriodicalBackupSettingsViewModel @Inject constructor(
|
|||||||
backupsDirectory.value = if (dir != null) {
|
backupsDirectory.value = if (dir != null) {
|
||||||
dir.toUserFriendlyString()
|
dir.toUserFriendlyString()
|
||||||
} else {
|
} else {
|
||||||
(appContext.getExternalFilesDir(DIR_BACKUPS) ?: File(appContext.filesDir, DIR_BACKUPS)).path
|
BackupUtils.getAppBackupDir(appContext).path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.backups.ui.periodical
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.annotation.CheckResult
|
import androidx.annotation.CheckResult
|
||||||
@@ -33,7 +33,7 @@ class TelegramBackupUploader @Inject constructor(
|
|||||||
suspend fun uploadBackup(file: File) {
|
suspend fun uploadBackup(file: File) {
|
||||||
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull())
|
||||||
val multipartBody = MultipartBody.Builder()
|
val multipartBody = MultipartBody.Builder()
|
||||||
.setType(MultipartBody.FORM)
|
.setType(MultipartBody.Companion.FORM)
|
||||||
.addFormDataPart("chat_id", requireChatId())
|
.addFormDataPart("chat_id", requireChatId())
|
||||||
.addFormDataPart("document", file.name, requestBody)
|
.addFormDataPart("document", file.name, requestBody)
|
||||||
.build()
|
.build()
|
||||||
@@ -90,4 +90,4 @@ class TelegramBackupUploader @Inject constructor(
|
|||||||
.host("api.telegram.org")
|
.host("api.telegram.org")
|
||||||
.addPathSegment("bot$botToken")
|
.addPathSegment("bot$botToken")
|
||||||
.addPathSegment(method)
|
.addPathSegment(method)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
@@ -8,18 +8,18 @@ import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
|||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
|
||||||
class BackupEntriesAdapter(
|
class BackupSectionsAdapter(
|
||||||
clickListener: OnListItemClickListener<BackupEntryModel>,
|
clickListener: OnListItemClickListener<BackupSectionModel>,
|
||||||
) : BaseListAdapter<BackupEntryModel>() {
|
) : BaseListAdapter<BackupSectionModel>() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener))
|
addDelegate(ListItemType.NAV_ITEM, backupSectionAD(clickListener))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun backupEntryAD(
|
private fun backupSectionAD(
|
||||||
clickListener: OnListItemClickListener<BackupEntryModel>,
|
clickListener: OnListItemClickListener<BackupSectionModel>,
|
||||||
) = adapterDelegateViewBinding<BackupEntryModel, BackupEntryModel, ItemCheckableMultipleBinding>(
|
) = adapterDelegateViewBinding<BackupSectionModel, BackupSectionModel, ItemCheckableMultipleBinding>(
|
||||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
|
data class BackupSectionModel(
|
||||||
|
val section: BackupSection,
|
||||||
|
val isChecked: Boolean,
|
||||||
|
val isEnabled: Boolean,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val titleResId: Int
|
||||||
|
get() = when (section) {
|
||||||
|
BackupSection.INDEX -> 0 // should not appear here
|
||||||
|
BackupSection.HISTORY -> R.string.history
|
||||||
|
BackupSection.CATEGORIES -> R.string.favourites_categories
|
||||||
|
BackupSection.FAVOURITES -> R.string.favourites
|
||||||
|
BackupSection.SETTINGS -> R.string.settings
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||||
|
BackupSection.BOOKMARKS -> R.string.bookmarks
|
||||||
|
BackupSection.SOURCES -> R.string.remote_sources
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is BackupSectionModel && other.section == section
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? {
|
||||||
|
if (previousState !is BackupSectionModel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return if (previousState.isEnabled != isEnabled) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
|
||||||
|
} else if (previousState.isChecked != isChecked) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
||||||
|
} else {
|
||||||
|
super.getChangePayload(previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -25,7 +25,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupEntryModel>,
|
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupSectionModel>,
|
||||||
View.OnClickListener {
|
View.OnClickListener {
|
||||||
|
|
||||||
private val viewModel: RestoreViewModel by viewModels()
|
private val viewModel: RestoreViewModel by viewModels()
|
||||||
@@ -37,7 +37,7 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
|||||||
|
|
||||||
override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) {
|
override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) {
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
val adapter = BackupEntriesAdapter(this)
|
val adapter = BackupSectionsAdapter(this)
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
binding.buttonCancel.setOnClickListener(this)
|
binding.buttonCancel.setOnClickListener(this)
|
||||||
binding.buttonRestore.setOnClickListener(this)
|
binding.buttonRestore.setOnClickListener(this)
|
||||||
@@ -72,11 +72,11 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClick(item: BackupEntryModel, view: View) {
|
override fun onItemClick(item: BackupSectionModel, view: View) {
|
||||||
viewModel.onItemClick(item)
|
viewModel.onItemClick(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onLoadingChanged(value: Triple<Boolean, List<BackupEntryModel>, Date?>) {
|
private fun onLoadingChanged(value: Triple<Boolean, List<BackupSectionModel>, Date?>) {
|
||||||
val (isLoading, entries, backupDate) = value
|
val (isLoading, entries, backupDate) = value
|
||||||
val hasEntries = entries.isNotEmpty()
|
val hasEntries = entries.isNotEmpty()
|
||||||
with(requireViewBinding()) {
|
with(requireViewBinding()) {
|
||||||
@@ -96,7 +96,7 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
|
|||||||
return RestoreService.start(
|
return RestoreService.start(
|
||||||
context ?: return false,
|
context ?: return false,
|
||||||
viewModel.uri ?: return false,
|
viewModel.uri ?: return false,
|
||||||
viewModel.getCheckedEntries(),
|
viewModel.getCheckedSections(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
class RestoreService : BaseBackupRestoreService() {
|
||||||
|
|
||||||
|
override val notificationTag = TAG
|
||||||
|
override val isRestoreService = true
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
val notification = buildNotification(Progress.INDETERMINATE)
|
||||||
|
setForeground(
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
val source = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
||||||
|
val sections =
|
||||||
|
requireNotNull(intent.getSerializableExtraCompat<Array<BackupSection>>(AppRouter.KEY_ENTRIES)?.toSet())
|
||||||
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
launch {
|
||||||
|
progress.collect {
|
||||||
|
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val result = ZipInputStream(contentResolver.openInputStream(source)).use { input ->
|
||||||
|
repository.restoreBackup(input, sections, progress)
|
||||||
|
}
|
||||||
|
progressUpdateJob?.cancelAndJoin()
|
||||||
|
showResultNotification(source, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
||||||
|
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(
|
||||||
|
progress.total.coerceAtLeast(0),
|
||||||
|
progress.progress.coerceAtLeast(0),
|
||||||
|
progress.isIndeterminate,
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
if (progress.isIndeterminate) {
|
||||||
|
getString(R.string.processing_)
|
||||||
|
} else {
|
||||||
|
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "RESTORE"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 39
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun start(context: Context, uri: Uri, sections: Set<BackupSection>): Boolean = try {
|
||||||
|
val intent = Intent(context, RestoreService::class.java)
|
||||||
|
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
||||||
|
intent.putExtra(AppRouter.KEY_ENTRIES, sections.toTypedArray())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.EnumMap
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RestoreViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)?.toUriOrNull()
|
||||||
|
private val contentResolver = context.contentResolver
|
||||||
|
|
||||||
|
val availableEntries = MutableStateFlow<List<BackupSectionModel>>(emptyList())
|
||||||
|
val backupDate = MutableStateFlow<Date?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
loadBackupInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadBackupInfo() {
|
||||||
|
val sections = runInterruptible(Dispatchers.IO) {
|
||||||
|
if (uri == null) throw FileNotFoundException()
|
||||||
|
ZipInputStream(contentResolver.openInputStream(uri)).use { stream ->
|
||||||
|
val result = EnumSet.noneOf(BackupSection::class.java)
|
||||||
|
var entry = stream.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val s = BackupSection.of(entry)
|
||||||
|
if (s != null) {
|
||||||
|
result.add(s)
|
||||||
|
if (s == BackupSection.INDEX) {
|
||||||
|
backupDate.value = stream.readDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.closeEntry()
|
||||||
|
entry = stream.nextEntry
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
availableEntries.value = BackupSection.entries.mapNotNull { entry ->
|
||||||
|
if (entry == BackupSection.INDEX || entry !in sections) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
BackupSectionModel(
|
||||||
|
section = entry,
|
||||||
|
isChecked = true,
|
||||||
|
isEnabled = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onItemClick(item: BackupSectionModel) {
|
||||||
|
val map = availableEntries.value.associateByTo(EnumMap(BackupSection::class.java)) { it.section }
|
||||||
|
map[item.section] = item.copy(isChecked = !item.isChecked)
|
||||||
|
map.validate()
|
||||||
|
availableEntries.value = map.values.sortedBy { it.section.ordinal }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCheckedSections(): Set<BackupSection> = availableEntries.value
|
||||||
|
.mapNotNullTo(EnumSet.noneOf(BackupSection::class.java)) {
|
||||||
|
if (it.isChecked) it.section else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for inconsistent user selection
|
||||||
|
* Favorites cannot be restored without categories
|
||||||
|
*/
|
||||||
|
private fun MutableMap<BackupSection, BackupSectionModel>.validate() {
|
||||||
|
val favorites = this[BackupSection.FAVOURITES] ?: return
|
||||||
|
val categories = this[BackupSection.CATEGORIES]
|
||||||
|
if (categories?.isChecked == true) {
|
||||||
|
if (!favorites.isEnabled) {
|
||||||
|
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (favorites.isEnabled) {
|
||||||
|
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readDate(): Date? = runCatching {
|
||||||
|
val index = Json.decodeFromStream<List<BackupIndex>>(this)
|
||||||
|
Date(index.single().createdAt)
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import androidx.room.Insert
|
|||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@@ -47,4 +50,17 @@ abstract class BookmarksDao {
|
|||||||
|
|
||||||
@Upsert
|
@Upsert
|
||||||
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
abstract suspend fun upsert(bookmarks: Collection<BookmarkEntity>)
|
||||||
|
|
||||||
|
fun dump(): Flow<Pair<MangaWithTags, List<BookmarkEntity>>> = flow {
|
||||||
|
val window = 4
|
||||||
|
var offset = 0
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val list = findAll(offset, window)
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += window
|
||||||
|
list.forEach { emit(it.key to it.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.domain
|
package org.koitharu.kotatsu.bookmarks.domain
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.core.util.MimeTypes
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isImage
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.local.data.hasImageExtension
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -17,9 +18,6 @@ data class Bookmark(
|
|||||||
val percent: Float,
|
val percent: Float,
|
||||||
) : ListModel {
|
) : ListModel {
|
||||||
|
|
||||||
val imageLoadData: Any
|
|
||||||
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
return other is Bookmark &&
|
return other is Bookmark &&
|
||||||
manga.id == other.manga.id &&
|
manga.id == other.manga.id &&
|
||||||
@@ -30,11 +28,9 @@ data class Bookmark(
|
|||||||
fun toMangaPage() = MangaPage(
|
fun toMangaPage() = MangaPage(
|
||||||
id = pageId,
|
id = pageId,
|
||||||
url = imageUrl,
|
url = imageUrl,
|
||||||
preview = null,
|
preview = imageUrl.takeIf {
|
||||||
|
MimeTypes.getMimeTypeFromUrl(it)?.isImage == true
|
||||||
|
},
|
||||||
source = manga.source,
|
source = manga.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun isImageUrlDirect(): Boolean {
|
|
||||||
return hasImageExtension(imageUrl)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.appcompat.view.ActionMode
|
|||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import coil3.ImageLoader
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
@@ -40,6 +39,7 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -50,16 +50,22 @@ class AllBookmarksFragment :
|
|||||||
ListSelectionController.Callback,
|
ListSelectionController.Callback,
|
||||||
FastScroller.FastScrollListener, ListHeaderClickListener {
|
FastScroller.FastScrollListener, ListHeaderClickListener {
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var coil: ImageLoader
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var pageSaveHelperFactory: PageSaveHelper.Factory
|
||||||
|
|
||||||
|
private lateinit var pageSaveHelper: PageSaveHelper
|
||||||
private val viewModel by viewModels<AllBookmarksViewModel>()
|
private val viewModel by viewModels<AllBookmarksViewModel>()
|
||||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||||
private var selectionController: ListSelectionController? = null
|
private var selectionController: ListSelectionController? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
pageSaveHelper = pageSaveHelperFactory.create(this)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateViewBinding(
|
override fun onCreateViewBinding(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
@@ -79,8 +85,6 @@ class AllBookmarksFragment :
|
|||||||
callback = this,
|
callback = this,
|
||||||
)
|
)
|
||||||
bookmarksAdapter = BookmarksAdapter(
|
bookmarksAdapter = BookmarksAdapter(
|
||||||
lifecycleOwner = viewLifecycleOwner,
|
|
||||||
coil = coil,
|
|
||||||
clickListener = this,
|
clickListener = this,
|
||||||
headerClickListener = this,
|
headerClickListener = this,
|
||||||
)
|
)
|
||||||
@@ -129,7 +133,7 @@ class AllBookmarksFragment :
|
|||||||
if (selectionController?.onItemClick(item.pageId) != true) {
|
if (selectionController?.onItemClick(item.pageId) != true) {
|
||||||
val intent = ReaderIntent.Builder(view.context)
|
val intent = ReaderIntent.Builder(view.context)
|
||||||
.bookmark(item)
|
.bookmark(item)
|
||||||
.incognito(true)
|
.incognito()
|
||||||
.build()
|
.build()
|
||||||
router.openReader(intent)
|
router.openReader(intent)
|
||||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||||
@@ -185,6 +189,12 @@ class AllBookmarksFragment :
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.action_save -> {
|
||||||
|
viewModel.savePages(pageSaveHelper, selectionController?.snapshot() ?: return false)
|
||||||
|
mode?.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
|||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -56,6 +57,23 @@ class AllBookmarksViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun savePages(pageSaveHelper: PageSaveHelper, ids: Set<Long>) {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
val tasks = content.value.mapNotNull {
|
||||||
|
if (it !is Bookmark || it.pageId !in ids) return@mapNotNull null
|
||||||
|
PageSaveHelper.Task(
|
||||||
|
manga = it.manga,
|
||||||
|
chapterId = it.chapterId,
|
||||||
|
pageNumber = it.page + 1,
|
||||||
|
page = it.toMangaPage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val dest = pageSaveHelper.save(tasks)
|
||||||
|
val msg = if (dest.size == 1) R.string.page_saved else R.string.pages_saved
|
||||||
|
onActionDone.call(ReversibleAction(msg, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
|
private fun mapList(data: Map<Manga, List<Bookmark>>): List<ListModel> {
|
||||||
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
|
val result = ArrayList<ListModel>(data.values.sumOf { it.size + 1 })
|
||||||
for ((manga, bookmarks) in data) {
|
for ((manga, bookmarks) in data) {
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import coil3.request.allowRgb565
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
import org.koitharu.kotatsu.databinding.ItemBookmarkLargeBinding
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
fun bookmarkLargeAD(
|
fun bookmarkLargeAD(
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
|
||||||
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
|
||||||
@@ -26,14 +15,7 @@ fun bookmarkLargeAD(
|
|||||||
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
|
binding.imageViewThumb.setImageAsync(item)
|
||||||
size(CoverSizeResolver(binding.imageViewThumb))
|
|
||||||
defaultPlaceholders(context)
|
|
||||||
allowRgb565(true)
|
|
||||||
bookmarkExtra(item)
|
|
||||||
decodeRegion(item.scroll)
|
|
||||||
enqueueWith(coil)
|
|
||||||
}
|
|
||||||
binding.progressView.setProgress(item.percent, false)
|
binding.progressView.setProgress(item.percent, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
package org.koitharu.kotatsu.bookmarks.ui.adapter
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import coil3.ImageLoader
|
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
@@ -17,19 +15,17 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
|||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
class BookmarksAdapter(
|
class BookmarksAdapter(
|
||||||
coil: ImageLoader,
|
|
||||||
lifecycleOwner: LifecycleOwner,
|
|
||||||
clickListener: OnListItemClickListener<Bookmark>,
|
clickListener: OnListItemClickListener<Bookmark>,
|
||||||
headerClickListener: ListHeaderClickListener?,
|
headerClickListener: ListHeaderClickListener?,
|
||||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
|
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(clickListener))
|
||||||
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
|
||||||
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
|
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
|
||||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
|
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AdListUpdateService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var updater: AdBlock.Updater
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
updater.updateList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) = Unit
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
import org.koitharu.kotatsu.core.network.proxy.ProxyProvider
|
||||||
|
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||||
@@ -29,6 +30,9 @@ abstract class BaseBrowserActivity : BaseActivity<ActivityBrowserBinding>(), Bro
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var adBlock: AdBlock
|
||||||
|
|
||||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.nav.router
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
@@ -20,7 +24,7 @@ class BrowserActivity : BaseBrowserActivity() {
|
|||||||
|
|
||||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
|
||||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
viewBinding.webView.webViewClient = BrowserClient(this, adBlock)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
proxyProvider.applyWebViewConfig()
|
proxyProvider.applyWebViewConfig()
|
||||||
@@ -65,4 +69,23 @@ class BrowserActivity : BaseBrowserActivity() {
|
|||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Contract : ActivityResultContract<InteractiveActionRequiredException, Unit>() {
|
||||||
|
override fun createIntent(
|
||||||
|
context: Context,
|
||||||
|
input: InteractiveActionRequiredException
|
||||||
|
): Intent = AppRouter.browserIntent(
|
||||||
|
context = context,
|
||||||
|
url = input.url,
|
||||||
|
source = input.source,
|
||||||
|
title = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): Unit = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val TAG = "BrowserActivity"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
package org.koitharu.kotatsu.browser
|
package org.koitharu.kotatsu.browser
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.webkit.WebViewClientCompat
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.annotation.AnyThread
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
open class BrowserClient(
|
open class BrowserClient(
|
||||||
private val callback: BrowserCallback
|
private val callback: BrowserCallback,
|
||||||
) : WebViewClientCompat() {
|
private val adBlock: AdBlock,
|
||||||
|
) : WebViewClient() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/57414530/illegalstateexception-reasonphrase-cant-be-empty-with-android-webview
|
||||||
|
*/
|
||||||
|
|
||||||
override fun onPageFinished(webView: WebView, url: String) {
|
override fun onPageFinished(webView: WebView, url: String) {
|
||||||
super.onPageFinished(webView, url)
|
super.onPageFinished(webView, url)
|
||||||
@@ -27,4 +41,37 @@ open class BrowserClient(
|
|||||||
super.doUpdateVisitedHistory(view, url, isReload)
|
super.doUpdateVisitedHistory(view, url, isReload)
|
||||||
callback.onHistoryChanged()
|
callback.onHistoryChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
|
override fun shouldInterceptRequest(
|
||||||
|
view: WebView?,
|
||||||
|
url: String?
|
||||||
|
): WebResourceResponse? = if (url.isNullOrEmpty() || adBlock.shouldLoadUrl(url, view?.getUrlSafe())) {
|
||||||
|
super.shouldInterceptRequest(view, url)
|
||||||
|
} else {
|
||||||
|
emptyResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
override fun shouldInterceptRequest(
|
||||||
|
view: WebView?,
|
||||||
|
request: WebResourceRequest?
|
||||||
|
): WebResourceResponse? = if (request == null || adBlock.shouldLoadUrl(request.url.toString(), view?.getUrlSafe())) {
|
||||||
|
super.shouldInterceptRequest(view, request)
|
||||||
|
} else {
|
||||||
|
emptyResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emptyResponse(): WebResourceResponse =
|
||||||
|
WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(byteArrayOf()))
|
||||||
|
|
||||||
|
@AnyThread
|
||||||
|
private fun WebView.getUrlSafe(): String? = if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
runBlocking(Dispatchers.Main.immediate) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.browser.cloudflare
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import coil3.EventListener
|
|
||||||
import coil3.Extras
|
|
||||||
import coil3.request.ErrorResult
|
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
|
||||||
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.prefs.SourceSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
|
|
||||||
class CaptchaNotifier(
|
|
||||||
private val context: Context,
|
|
||||||
) : EventListener() {
|
|
||||||
|
|
||||||
fun notify(exception: CloudFlareProtectedException) {
|
|
||||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (exception.source != null && SourceSettings(context, exception.source).isCaptchaNotificationsDisabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val manager = NotificationManagerCompat.from(context)
|
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
|
||||||
.setName(context.getString(R.string.captcha_required))
|
|
||||||
.setShowBadge(true)
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.setSound(null, null)
|
|
||||||
.setLightsEnabled(false)
|
|
||||||
.build()
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
|
|
||||||
val intent = AppRouter.cloudFlareResolveIntent(context, exception)
|
|
||||||
.setData(exception.url.toUri())
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setContentTitle(channel.name)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSmallIcon(R.drawable.ic_bot)
|
|
||||||
.setGroup(GROUP_CAPTCHA)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setVisibility(
|
|
||||||
if (exception.source?.isNsfw() == true) {
|
|
||||||
NotificationCompat.VISIBILITY_SECRET
|
|
||||||
} else {
|
|
||||||
NotificationCompat.VISIBILITY_PUBLIC
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.setContentText(
|
|
||||||
context.getString(
|
|
||||||
R.string.captcha_required_summary,
|
|
||||||
exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val actionIntent = PendingIntentCompat.getActivity(
|
|
||||||
context, SETTINGS_ACTION_CODE,
|
|
||||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
|
||||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
|
||||||
.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID),
|
|
||||||
0, false,
|
|
||||||
)
|
|
||||||
notification.addAction(
|
|
||||||
R.drawable.ic_settings,
|
|
||||||
context.getString(R.string.notifications_settings),
|
|
||||||
actionIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
manager.notify(TAG, exception.source.hashCode(), notification.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismiss(source: MangaSource) {
|
|
||||||
NotificationManagerCompat.from(context).cancel(TAG, source.hashCode())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
|
||||||
super.onError(request, result)
|
|
||||||
val e = result.throwable
|
|
||||||
if (e is CloudFlareProtectedException && request.extras[ignoreCaptchaKey] != true) {
|
|
||||||
notify(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun ImageRequest.Builder.ignoreCaptchaErrors() = apply {
|
|
||||||
extras[ignoreCaptchaKey] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val ignoreCaptchaKey = Extras.Key(false)
|
|
||||||
|
|
||||||
private const val CHANNEL_ID = "captcha"
|
|
||||||
private const val TAG = CHANNEL_ID
|
|
||||||
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
|
||||||
private const val SETTINGS_ACTION_CODE = 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,14 +19,17 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.browser.BaseBrowserActivity
|
import org.koitharu.kotatsu.browser.BaseBrowserActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
|
||||||
import org.koitharu.kotatsu.core.model.MangaSource
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -37,6 +40,9 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var cookieJar: MutableCookieJar
|
lateinit var cookieJar: MutableCookieJar
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var captchaHandler: CaptchaHandler
|
||||||
|
|
||||||
private lateinit var cfClient: CloudFlareClient
|
private lateinit var cfClient: CloudFlareClient
|
||||||
|
|
||||||
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
override fun onCreate2(savedInstanceState: Bundle?, source: MangaSource, repository: ParserMangaRepository?) {
|
||||||
@@ -46,7 +52,7 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
|||||||
finishAfterTransition()
|
finishAfterTransition()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
cfClient = CloudFlareClient(cookieJar, this, adBlock, url)
|
||||||
viewBinding.webView.webViewClient = cfClient
|
viewBinding.webView.webViewClient = cfClient
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -98,11 +104,17 @@ class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback {
|
|||||||
|
|
||||||
override fun onCheckPassed() {
|
override fun onCheckPassed() {
|
||||||
pendingResult = RESULT_OK
|
pendingResult = RESULT_OK
|
||||||
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
|
lifecycleScope.launch {
|
||||||
if (source != null) {
|
val source = intent?.getStringExtra(AppRouter.KEY_SOURCE)
|
||||||
CaptchaNotifier(this).dismiss(MangaSource(source))
|
if (source != null) {
|
||||||
|
runCatchingCancellable {
|
||||||
|
captchaHandler.discard(MangaSource(source))
|
||||||
|
}.onFailure {
|
||||||
|
it.printStackTraceDebug()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishAfterTransition()
|
||||||
}
|
}
|
||||||
finishAfterTransition()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.graphics.Bitmap
|
|||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import org.koitharu.kotatsu.browser.BrowserClient
|
import org.koitharu.kotatsu.browser.BrowserClient
|
||||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||||
|
import org.koitharu.kotatsu.core.network.webview.adblock.AdBlock
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
|
||||||
private const val LOOP_COUNTER = 3
|
private const val LOOP_COUNTER = 3
|
||||||
@@ -11,8 +12,9 @@ private const val LOOP_COUNTER = 3
|
|||||||
class CloudFlareClient(
|
class CloudFlareClient(
|
||||||
private val cookieJar: MutableCookieJar,
|
private val cookieJar: MutableCookieJar,
|
||||||
private val callback: CloudFlareCallback,
|
private val callback: CloudFlareCallback,
|
||||||
|
adBlock: AdBlock,
|
||||||
private val targetUrl: String,
|
private val targetUrl: String,
|
||||||
) : BrowserClient(callback) {
|
) : BrowserClient(callback, adBlock) {
|
||||||
|
|
||||||
private val oldClearance = getClearance()
|
private val oldClearance = getClearance()
|
||||||
private var counter = 0
|
private var counter = 0
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
|
import org.koitharu.kotatsu.backups.domain.BackupObserver
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler
|
||||||
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
import org.koitharu.kotatsu.core.image.AvifImageDecoder
|
||||||
import org.koitharu.kotatsu.core.image.CbzFetcher
|
import org.koitharu.kotatsu.core.image.CbzFetcher
|
||||||
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
|
import org.koitharu.kotatsu.core.image.MangaSourceHeaderInterceptor
|
||||||
@@ -59,7 +60,6 @@ import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
|
|||||||
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
|
||||||
import org.koitharu.kotatsu.settings.backup.BackupObserver
|
|
||||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
import org.koitharu.kotatsu.widget.WidgetUpdater
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
@@ -106,6 +106,7 @@ interface AppModule {
|
|||||||
pageFetcherFactory: MangaPageFetcher.Factory,
|
pageFetcherFactory: MangaPageFetcher.Factory,
|
||||||
coverRestoreInterceptor: CoverRestoreInterceptor,
|
coverRestoreInterceptor: CoverRestoreInterceptor,
|
||||||
networkStateProvider: Provider<NetworkState>,
|
networkStateProvider: Provider<NetworkState>,
|
||||||
|
captchaHandler: CaptchaHandler,
|
||||||
): ImageLoader {
|
): ImageLoader {
|
||||||
val diskCacheFactory = {
|
val diskCacheFactory = {
|
||||||
val rootDir = context.externalCacheDir ?: context.cacheDir
|
val rootDir = context.externalCacheDir ?: context.cacheDir
|
||||||
@@ -121,7 +122,7 @@ interface AppModule {
|
|||||||
.diskCache(diskCacheFactory)
|
.diskCache(diskCacheFactory)
|
||||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||||
.allowRgb565(context.isLowRamDevice())
|
.allowRgb565(context.isLowRamDevice())
|
||||||
.eventListener(CaptchaNotifier(context))
|
.eventListener(captchaHandler)
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(
|
||||||
OkHttpNetworkFetcherFactory(
|
OkHttpNetworkFetcherFactory(
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import android.app.PendingIntent
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.BadParcelableException
|
import android.os.BadParcelableException
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.PendingIntentCompat
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
@@ -17,18 +20,58 @@ class ErrorReporterReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
val e = intent?.getSerializableExtraCompat<Throwable>(AppRouter.KEY_ERROR) ?: return
|
val e = intent?.getSerializableExtraCompat<Throwable>(AppRouter.KEY_ERROR) ?: return
|
||||||
|
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
|
||||||
|
if (notificationId != 0 && context != null) {
|
||||||
|
val notificationTag = intent.getStringExtra(EXTRA_NOTIFICATION_TAG)
|
||||||
|
NotificationManagerCompat.from(context).cancel(notificationTag, notificationId)
|
||||||
|
}
|
||||||
e.report()
|
e.report()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
|
||||||
|
private const val EXTRA_NOTIFICATION_ID = "notify.id"
|
||||||
|
private const val EXTRA_NOTIFICATION_TAG = "notify.tag"
|
||||||
|
|
||||||
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
|
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = getPendingIntentInternal(
|
||||||
|
context = context,
|
||||||
|
e = e,
|
||||||
|
notificationId = 0,
|
||||||
|
notificationTag = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getNotificationAction(
|
||||||
|
context: Context,
|
||||||
|
e: Throwable,
|
||||||
|
notificationId: Int,
|
||||||
|
notificationTag: String?,
|
||||||
|
): NotificationCompat.Action? {
|
||||||
|
val intent = getPendingIntentInternal(
|
||||||
|
context = context,
|
||||||
|
e = e,
|
||||||
|
notificationId = notificationId,
|
||||||
|
notificationTag = notificationTag,
|
||||||
|
) ?: return null
|
||||||
|
return NotificationCompat.Action(
|
||||||
|
R.drawable.ic_alert_outline,
|
||||||
|
context.getString(R.string.report),
|
||||||
|
intent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPendingIntentInternal(
|
||||||
|
context: Context,
|
||||||
|
e: Throwable,
|
||||||
|
notificationId: Int,
|
||||||
|
notificationTag: String?,
|
||||||
|
): PendingIntent? = try {
|
||||||
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
val intent = Intent(context, ErrorReporterReceiver::class.java)
|
||||||
intent.setAction(ACTION_REPORT)
|
intent.setAction(ACTION_REPORT)
|
||||||
intent.setData(Uri.parse("err://${e.hashCode()}"))
|
intent.setData("err://${e.hashCode()}".toUri())
|
||||||
intent.putExtra(AppRouter.KEY_ERROR, e)
|
intent.putExtra(AppRouter.KEY_ERROR, e)
|
||||||
|
intent.putExtra(EXTRA_NOTIFICATION_ID, notificationId)
|
||||||
|
intent.putExtra(EXTRA_NOTIFICATION_TAG, notificationTag)
|
||||||
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
|
||||||
} catch (e: BadParcelableException) {
|
} catch (e: BadParcelableException) {
|
||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONArray
|
|
||||||
|
|
||||||
class BackupEntry(
|
|
||||||
val name: Name,
|
|
||||||
val data: JSONArray
|
|
||||||
) {
|
|
||||||
|
|
||||||
enum class Name(
|
|
||||||
val key: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
INDEX("index"),
|
|
||||||
HISTORY("history"),
|
|
||||||
CATEGORIES("categories"),
|
|
||||||
FAVOURITES("favourites"),
|
|
||||||
SETTINGS("settings"),
|
|
||||||
SETTINGS_READER_GRID("reader_grid"),
|
|
||||||
BOOKMARKS("bookmarks"),
|
|
||||||
SOURCES("sources"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import androidx.room.withTransaction
|
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
|
||||||
import java.util.Date
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private const val PAGE_SIZE = 10
|
|
||||||
|
|
||||||
class BackupRepository @Inject constructor(
|
|
||||||
private val db: MangaDatabase,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
private val tapGridSettings: TapGridSettings,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun dumpHistory(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val history = db.getHistoryDao().findAll(offset = offset, limit = PAGE_SIZE)
|
|
||||||
if (history.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += history.size
|
|
||||||
for (item in history) {
|
|
||||||
val manga = JsonSerializer(item.manga).toJson()
|
|
||||||
val tags = JSONArray()
|
|
||||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
manga.put("tags", tags)
|
|
||||||
val json = JsonSerializer(item.history).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpCategories(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
|
|
||||||
val categories = db.getFavouriteCategoriesDao().findAll()
|
|
||||||
for (item in categories) {
|
|
||||||
entry.data.put(JsonSerializer(item).toJson())
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpFavourites(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val favourites = db.getFavouritesDao().findAllRaw(offset = offset, limit = PAGE_SIZE)
|
|
||||||
if (favourites.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += favourites.size
|
|
||||||
for (item in favourites) {
|
|
||||||
val manga = JsonSerializer(item.manga).toJson()
|
|
||||||
val tags = JSONArray()
|
|
||||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
manga.put("tags", tags)
|
|
||||||
val json = JsonSerializer(item.favourite).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpBookmarks(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val bookmarks = db.getBookmarksDao().findAll(offset = offset, limit = PAGE_SIZE)
|
|
||||||
if (bookmarks.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += bookmarks.size
|
|
||||||
for ((m, b) in bookmarks) {
|
|
||||||
val json = JSONObject()
|
|
||||||
val manga = JsonSerializer(m.manga).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
val tags = JSONArray()
|
|
||||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
json.put("tags", tags)
|
|
||||||
val bookmarks = JSONArray()
|
|
||||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
|
||||||
json.put("bookmarks", bookmarks)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dumpSettings(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
|
|
||||||
val settingsDump = settings.getAllValues().toMutableMap()
|
|
||||||
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
|
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
|
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_LOGIN)
|
|
||||||
settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE)
|
|
||||||
val json = JsonSerializer(settingsDump).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dumpReaderGridSettings(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
|
|
||||||
val settingsDump = tapGridSettings.getAllValues()
|
|
||||||
val json = JsonSerializer(settingsDump).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpSources(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
|
||||||
val all = db.getSourcesDao().findAll()
|
|
||||||
for (source in all) {
|
|
||||||
val json = JsonSerializer(source).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIndex(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
|
|
||||||
val json = JSONObject()
|
|
||||||
json.put("app_id", BuildConfig.APPLICATION_ID)
|
|
||||||
json.put("app_version", BuildConfig.VERSION_CODE)
|
|
||||||
json.put("created_at", System.currentTimeMillis())
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBackupDate(entry: BackupEntry?): Date? {
|
|
||||||
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
|
|
||||||
return if (timestamp == 0L) null else Date(timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
val list = entry.data.asTypedList<JSONObject>()
|
|
||||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
|
||||||
for ((index, item) in list.withIndex()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val history = JsonDeserializer(item).toHistoryEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getHistoryDao().upsert(history)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getFavouriteCategoriesDao().upsert(category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
val list = entry.data.asTypedList<JSONObject>()
|
|
||||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
|
||||||
for ((index, item) in list.withIndex()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getFavouritesDao().upsert(favourite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = item.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
|
|
||||||
JsonDeserializer(it).toBookmarkEntity()
|
|
||||||
}
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getBookmarksDao().upsert(bookmarks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getSourcesDao().upsert(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okhttp3.internal.closeQuietly
|
|
||||||
import okio.Closeable
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
|
||||||
import java.io.File
|
|
||||||
import java.util.EnumSet
|
|
||||||
import java.util.zip.ZipException
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
class BackupZipInput private constructor(val file: File) : Closeable {
|
|
||||||
|
|
||||||
private val zipFile = ZipFile(file)
|
|
||||||
|
|
||||||
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
|
||||||
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
|
|
||||||
val json = zipFile.getInputStream(entry).use {
|
|
||||||
JSONArray(it.bufferedReader().readText())
|
|
||||||
}
|
|
||||||
BackupEntry(name, json)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
|
|
||||||
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
|
|
||||||
BackupEntry.Name.entries.find { it.key == ze.name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
zipFile.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun closeAndDelete() {
|
|
||||||
closeQuietly()
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun from(file: File): BackupZipInput {
|
|
||||||
var res: BackupZipInput? = null
|
|
||||||
return try {
|
|
||||||
res = BackupZipInput(file)
|
|
||||||
if (res.zipFile.getEntry("index") == null) {
|
|
||||||
throw BadBackupFormatException(null)
|
|
||||||
}
|
|
||||||
res
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
res?.closeQuietly()
|
|
||||||
throw if (exception is ZipException) {
|
|
||||||
BadBackupFormatException(exception)
|
|
||||||
} else {
|
|
||||||
exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okio.Closeable
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
|
||||||
import java.io.File
|
|
||||||
import java.text.ParseException
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.Deflater
|
|
||||||
|
|
||||||
class BackupZipOutput(val file: File) : Closeable {
|
|
||||||
|
|
||||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
|
||||||
|
|
||||||
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
|
||||||
output.put(entry.name.key, entry.data.toString(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
|
||||||
output.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
output.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val DIR_BACKUPS = "backups"
|
|
||||||
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
|
||||||
|
|
||||||
fun generateFileName(context: Context) = buildString {
|
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
|
||||||
append('_')
|
|
||||||
append(dateTimeFormat.format(Date()))
|
|
||||||
append(".bk.zip")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseBackupDateTime(fileName: String): Date? = try {
|
|
||||||
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
|
||||||
BackupZipOutput(File(dir, generateFileName(context)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
class CompositeResult {
|
|
||||||
|
|
||||||
private var successCount: Int = 0
|
|
||||||
private val errors = ArrayList<Throwable?>()
|
|
||||||
|
|
||||||
val size: Int
|
|
||||||
get() = successCount + errors.size
|
|
||||||
|
|
||||||
val failures: List<Throwable>
|
|
||||||
get() = errors.filterNotNull()
|
|
||||||
|
|
||||||
val isEmpty: Boolean
|
|
||||||
get() = errors.isEmpty() && successCount == 0
|
|
||||||
|
|
||||||
val isAllSuccess: Boolean
|
|
||||||
get() = errors.none { it != null }
|
|
||||||
|
|
||||||
val isAllFailed: Boolean
|
|
||||||
get() = successCount == 0 && errors.isNotEmpty()
|
|
||||||
|
|
||||||
operator fun plusAssign(result: Result<*>) {
|
|
||||||
when {
|
|
||||||
result.isSuccess -> successCount++
|
|
||||||
result.isFailure -> errors.add(result.exceptionOrNull())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun plusAssign(error: Throwable) {
|
|
||||||
errors.add(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun plusAssign(other: CompositeResult) {
|
|
||||||
this.successCount += other.successCount
|
|
||||||
this.errors += other.errors
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun plus(other: CompositeResult): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
result.successCount = this.successCount + other.successCount
|
|
||||||
result.errors.addAll(this.errors)
|
|
||||||
result.errors.addAll(other.errors)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
|
||||||
|
|
||||||
class JsonDeserializer(private val json: JSONObject) {
|
|
||||||
|
|
||||||
fun toFavouriteEntity() = FavouriteEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
categoryId = json.getLong("category_id"),
|
|
||||||
sortKey = json.getIntOrDefault("sort_key", 0),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMangaEntity() = MangaEntity(
|
|
||||||
id = json.getLong("id"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
altTitles = json.getStringOrNull("alt_title"),
|
|
||||||
url = json.getString("url"),
|
|
||||||
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
|
||||||
rating = json.getDouble("rating").toFloat(),
|
|
||||||
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
|
||||||
contentRating = json.getStringOrNull("content_rating"),
|
|
||||||
coverUrl = json.getString("cover_url"),
|
|
||||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
|
||||||
state = json.getStringOrNull("state"),
|
|
||||||
authors = json.getStringOrNull("author"),
|
|
||||||
source = json.getString("source"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toTagEntity() = TagEntity(
|
|
||||||
id = json.getLong("id"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
key = json.getString("key"),
|
|
||||||
source = json.getString("source"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toHistoryEntity() = HistoryEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
updatedAt = json.getLong("updated_at"),
|
|
||||||
chapterId = json.getLong("chapter_id"),
|
|
||||||
page = json.getInt("page"),
|
|
||||||
scroll = json.getDouble("scroll").toFloat(),
|
|
||||||
percent = json.getFloatOrDefault("percent", -1f),
|
|
||||||
chaptersCount = json.getIntOrDefault("chapters", -1),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
|
|
||||||
categoryId = json.getInt("category_id"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
|
||||||
track = json.getBooleanOrDefault("track", true),
|
|
||||||
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toBookmarkEntity() = BookmarkEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
pageId = json.getLong("page_id"),
|
|
||||||
chapterId = json.getLong("chapter_id"),
|
|
||||||
page = json.getInt("page"),
|
|
||||||
scroll = json.getInt("scroll"),
|
|
||||||
imageUrl = json.getString("image_url"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
percent = json.getDouble("percent").toFloat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMangaSourceEntity() = MangaSourceEntity(
|
|
||||||
source = json.getString("source"),
|
|
||||||
isEnabled = json.getBoolean("enabled"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
addedIn = json.getIntOrDefault("added_in", 0),
|
|
||||||
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
|
||||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
|
||||||
val map = mutableMapOf<String, Any?>()
|
|
||||||
val keys = json.keys()
|
|
||||||
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
val value = json.get(key)
|
|
||||||
map[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
|
|
||||||
class JsonSerializer private constructor(private val json: JSONObject) {
|
|
||||||
|
|
||||||
constructor(e: FavouriteEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("category_id", e.categoryId)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: FavouriteCategoryEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("category_id", e.categoryId)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("title", e.title)
|
|
||||||
put("order", e.order)
|
|
||||||
put("track", e.track)
|
|
||||||
put("show_in_lib", e.isVisibleInLibrary)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: HistoryEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("updated_at", e.updatedAt)
|
|
||||||
put("chapter_id", e.chapterId)
|
|
||||||
put("page", e.page)
|
|
||||||
put("scroll", e.scroll)
|
|
||||||
put("percent", e.percent)
|
|
||||||
put("chapters", e.chaptersCount)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: TagEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("id", e.id)
|
|
||||||
put("title", e.title)
|
|
||||||
put("key", e.key)
|
|
||||||
put("source", e.source)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: MangaEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("id", e.id)
|
|
||||||
put("title", e.title)
|
|
||||||
put("alt_title", e.altTitles)
|
|
||||||
put("url", e.url)
|
|
||||||
put("public_url", e.publicUrl)
|
|
||||||
put("rating", e.rating)
|
|
||||||
put("nsfw", e.isNsfw)
|
|
||||||
put("content_rating", e.contentRating)
|
|
||||||
put("cover_url", e.coverUrl)
|
|
||||||
put("large_cover_url", e.largeCoverUrl)
|
|
||||||
put("state", e.state)
|
|
||||||
put("author", e.authors)
|
|
||||||
put("source", e.source)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: BookmarkEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("page_id", e.pageId)
|
|
||||||
put("chapter_id", e.chapterId)
|
|
||||||
put("page", e.page)
|
|
||||||
put("scroll", e.scroll)
|
|
||||||
put("image_url", e.imageUrl)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("percent", e.percent)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: MangaSourceEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("source", e.source)
|
|
||||||
put("enabled", e.isEnabled)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("added_in", e.addedIn)
|
|
||||||
put("used_at", e.lastUsedAt)
|
|
||||||
put("pinned", e.isPinned)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(m: Map<String, *>) : this(
|
|
||||||
JSONObject(m),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toJson(): JSONObject = json
|
|
||||||
}
|
|
||||||
@@ -41,6 +41,8 @@ import org.koitharu.kotatsu.core.db.migrations.Migration22To23
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
|
import org.koitharu.kotatsu.core.db.migrations.Migration23To24
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
|
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
|
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.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
|
||||||
@@ -68,7 +70,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 25
|
const val DATABASE_VERSION = 27
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -138,6 +140,8 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration23To24(),
|
Migration23To24(),
|
||||||
Migration24To23(),
|
Migration24To23(),
|
||||||
Migration24To25(),
|
Migration24To25(),
|
||||||
|
Migration25To26(),
|
||||||
|
Migration26To27(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ const val TABLE_HISTORY = "history"
|
|||||||
const val TABLE_MANGA_TAGS = "manga_tags"
|
const val TABLE_MANGA_TAGS = "manga_tags"
|
||||||
const val TABLE_SOURCES = "sources"
|
const val TABLE_SOURCES = "sources"
|
||||||
const val TABLE_CHAPTERS = "chapters"
|
const val TABLE_CHAPTERS = "chapters"
|
||||||
|
const val TABLE_PREFERENCES = "preferences"
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import androidx.room.Transaction
|
|||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||||
import androidx.sqlite.db.SupportSQLiteQuery
|
import androidx.sqlite.db.SupportSQLiteQuery
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
import org.koitharu.kotatsu.explore.data.SourcesSortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper.PROTECTION_CAPTCHA
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class MangaSourcesDao {
|
abstract class MangaSourcesDao {
|
||||||
@@ -50,6 +55,9 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
@Query("UPDATE sources SET pinned = :isPinned WHERE source = :source")
|
||||||
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
abstract suspend fun setPinned(source: String, isPinned: Boolean)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET cf_state = :state WHERE source = :source")
|
||||||
|
abstract suspend fun setCfState(source: String, state: Int)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
@Transaction
|
@Transaction
|
||||||
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
abstract suspend fun insertIfAbsent(entries: Collection<MangaSourceEntity>)
|
||||||
@@ -60,6 +68,9 @@ abstract class MangaSourcesDao {
|
|||||||
@Query("SELECT * FROM sources WHERE pinned = 1")
|
@Query("SELECT * FROM sources WHERE pinned = 1")
|
||||||
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
abstract suspend fun findAllPinned(): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE cf_state = $PROTECTION_CAPTCHA")
|
||||||
|
abstract suspend fun findAllCaptchaRequired(): List<MangaSourceEntity>
|
||||||
|
|
||||||
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
|
fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow<List<MangaSourceEntity>> =
|
||||||
observeImpl(getQuery(enabledOnly, order))
|
observeImpl(getQuery(enabledOnly, order))
|
||||||
|
|
||||||
@@ -76,11 +87,25 @@ abstract class MangaSourcesDao {
|
|||||||
addedIn = BuildConfig.VERSION_CODE,
|
addedIn = BuildConfig.VERSION_CODE,
|
||||||
lastUsedAt = 0,
|
lastUsedAt = 0,
|
||||||
isPinned = false,
|
isPinned = false,
|
||||||
|
cfState = CloudFlareHelper.PROTECTION_NOT_DETECTED,
|
||||||
)
|
)
|
||||||
upsert(entity)
|
upsert(entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dumpEnabled(): Flow<MangaSourceEntity> = flow {
|
||||||
|
val window = 10
|
||||||
|
var offset = 0
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val list = findAllEnabled(offset, window)
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset += window
|
||||||
|
list.forEach { emit(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
@Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source")
|
||||||
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int
|
||||||
|
|
||||||
@@ -90,6 +115,9 @@ abstract class MangaSourcesDao {
|
|||||||
@RawQuery
|
@RawQuery
|
||||||
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List<MangaSourceEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY source LIMIT :limit OFFSET :offset")
|
||||||
|
protected abstract suspend fun findAllEnabled(offset: Int, limit: Int): List<MangaSourceEntity>
|
||||||
|
|
||||||
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
|
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
|
||||||
buildString {
|
buildString {
|
||||||
append("SELECT * FROM sources ")
|
append("SELECT * FROM sources ")
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ abstract class PreferencesDao {
|
|||||||
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
|
||||||
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM preferences WHERE title_override IS NOT NULL OR cover_override IS NOT NULL OR content_rating_override IS NOT NULL")
|
||||||
|
abstract suspend fun getOverrides(): List<MangaPrefsEntity>
|
||||||
|
|
||||||
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
|
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
|
||||||
abstract suspend fun resetColorFilters()
|
abstract suspend fun resetColorFilters()
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ fun MangaTag.toEntity() = TagEntity(
|
|||||||
key = key,
|
key = key,
|
||||||
source = source.name,
|
source = source.name,
|
||||||
id = "${key}_${source.name}".longHashCode(),
|
id = "${key}_${source.name}".longHashCode(),
|
||||||
|
isPinned = false, // for future use
|
||||||
)
|
)
|
||||||
|
|
||||||
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
fun Collection<MangaTag>.toEntities() = map(MangaTag::toEntity)
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import androidx.room.ColumnInfo
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "preferences",
|
tableName = TABLE_PREFERENCES,
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
ForeignKey(
|
ForeignKey(
|
||||||
entity = MangaEntity::class,
|
entity = MangaEntity::class,
|
||||||
@@ -25,4 +26,8 @@ data class MangaPrefsEntity(
|
|||||||
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
||||||
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
|
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
|
||||||
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: 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?,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ data class MangaSourceEntity(
|
|||||||
@ColumnInfo(name = "added_in") val addedIn: Int,
|
@ColumnInfo(name = "added_in") val addedIn: Int,
|
||||||
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
@ColumnInfo(name = "used_at") val lastUsedAt: Long,
|
||||||
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
||||||
|
@ColumnInfo(name = "cf_state") val cfState: Int,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ data class TagEntity(
|
|||||||
@ColumnInfo(name = "title") val title: String,
|
@ColumnInfo(name = "title") val title: String,
|
||||||
@ColumnInfo(name = "key") val key: String,
|
@ColumnInfo(name = "key") val key: String,
|
||||||
@ColumnInfo(name = "source") val source: String,
|
@ColumnInfo(name = "source") val source: String,
|
||||||
|
@ColumnInfo(name = "pinned") val isPinned: Boolean,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration25To26 : Migration(25, 26) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE sources ADD COLUMN cf_state INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE preferences ADD COLUMN title_override TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE preferences ADD COLUMN cover_override TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE preferences ADD COLUMN content_rating_override TEXT DEFAULT NULL")
|
||||||
|
db.execSQL("ALTER TABLE favourites ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
|
||||||
|
db.execSQL("ALTER TABLE tags ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
import okio.IOException
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
|
||||||
class CloudFlareBlockedException(
|
class CloudFlareBlockedException(
|
||||||
val url: String,
|
override val url: String,
|
||||||
val source: MangaSource?,
|
source: MangaSource?,
|
||||||
) : IOException("Blocked by CloudFlare")
|
) : CloudFlareException("Blocked by CloudFlare", CloudFlareHelper.PROTECTION_BLOCKED) {
|
||||||
|
|
||||||
|
override val source: MangaSource = source ?: UnknownMangaSource
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
abstract class CloudFlareException(
|
||||||
|
message: String,
|
||||||
|
val state: Int,
|
||||||
|
) : IOException(message) {
|
||||||
|
|
||||||
|
abstract val url: String
|
||||||
|
|
||||||
|
abstract val source: MangaSource
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okio.IOException
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
||||||
|
|
||||||
class CloudFlareProtectedException(
|
class CloudFlareProtectedException(
|
||||||
val url: String,
|
override val url: String,
|
||||||
val source: MangaSource?,
|
source: MangaSource?,
|
||||||
@Transient val headers: Headers,
|
@Transient val headers: Headers,
|
||||||
) : IOException("Protected by CloudFlare")
|
) : CloudFlareException("Protected by CloudFlare", CloudFlareHelper.PROTECTION_CAPTCHA) {
|
||||||
|
|
||||||
|
override val source: MangaSource = source ?: UnknownMangaSource
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import okio.IOException
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class InteractiveActionRequiredException(
|
||||||
|
val source: MangaSource,
|
||||||
|
val url: String,
|
||||||
|
) : IOException("Interactive action is required for ${source.name}")
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions.resolve
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
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.net.toUri
|
||||||
|
import androidx.lifecycle.coroutineScope
|
||||||
|
import coil3.EventListener
|
||||||
|
import coil3.Extras
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.request.ErrorResult
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
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
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.LocalizedAppContext
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.model.MangaSource
|
||||||
|
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.parser.favicon.faviconUri
|
||||||
|
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getNotificationIconSize
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.goAsync
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
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.runCatchingCancellable
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Provider
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class CaptchaHandler @Inject constructor(
|
||||||
|
@LocalizedAppContext private val context: Context,
|
||||||
|
private val databaseProvider: Provider<MangaDatabase>,
|
||||||
|
private val coilProvider: Provider<ImageLoader>,
|
||||||
|
) : EventListener() {
|
||||||
|
|
||||||
|
private val exceptionMap = MutableScatterMap<MangaSource, CloudFlareProtectedException>()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun handle(exception: CloudFlareException): Boolean = handleException(exception.source, exception, true)
|
||||||
|
|
||||||
|
suspend fun discard(source: MangaSource) {
|
||||||
|
handleException(source, null, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||||
|
super.onError(request, result)
|
||||||
|
val e = result.throwable
|
||||||
|
if (e is CloudFlareException && request.extras[ignoreCaptchaKey] != true) {
|
||||||
|
val scope = request.lifecycle?.coroutineScope ?: processLifecycleScope
|
||||||
|
scope.launch {
|
||||||
|
handleException(e.source, e, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleException(
|
||||||
|
source: MangaSource,
|
||||||
|
exception: CloudFlareException?,
|
||||||
|
notify: Boolean
|
||||||
|
): Boolean = withContext(Dispatchers.Default) {
|
||||||
|
if (source == UnknownMangaSource) {
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
mutex.withLock {
|
||||||
|
var removedException: CloudFlareProtectedException? = null
|
||||||
|
if (exception is CloudFlareProtectedException) {
|
||||||
|
exceptionMap[source] = exception
|
||||||
|
} else {
|
||||||
|
removedException = exceptionMap.remove(source)
|
||||||
|
}
|
||||||
|
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)) {
|
||||||
|
if (removedException != null) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(TAG, removedException.source.hashCode())
|
||||||
|
}
|
||||||
|
notify(exceptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
private suspend fun notify(exceptions: List<CloudFlareProtectedException>) {
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
val channel = NotificationChannelCompat.Builder(
|
||||||
|
CHANNEL_ID,
|
||||||
|
NotificationManagerCompat.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
.setName(context.getString(R.string.captcha_required))
|
||||||
|
.setShowBadge(true)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
exceptions.map {
|
||||||
|
async { it to buildNotification(it) }
|
||||||
|
}.awaitAll()
|
||||||
|
}.forEach { (exception, notification) ->
|
||||||
|
manager.notify(TAG, exception.source.hashCode(), notification)
|
||||||
|
}
|
||||||
|
if (exceptions.size > 1) {
|
||||||
|
val groupNotification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setGroupSummary(true)
|
||||||
|
.setContentTitle(context.getString(R.string.captcha_required))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSmallIcon(R.drawable.ic_bot)
|
||||||
|
.setGroup(GROUP_CAPTCHA)
|
||||||
|
.setContentText(
|
||||||
|
context.getString(
|
||||||
|
R.string.captcha_required_summary, context.getString(R.string.app_name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setVisibility(
|
||||||
|
if (exceptions.any { it.source.isNsfw() }) {
|
||||||
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
|
} else {
|
||||||
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
|
},
|
||||||
|
)
|
||||||
|
manager.notify(TAG, GROUP_NOTIFICATION_ID, groupNotification.build())
|
||||||
|
} else {
|
||||||
|
manager.cancel(TAG, GROUP_NOTIFICATION_ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setContentTitle(context.getString(R.string.captcha_required))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSmallIcon(R.drawable.ic_bot)
|
||||||
|
.setGroup(GROUP_CAPTCHA)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setDeleteIntent(PendingIntentCompat.getBroadcast(context, 0, discardIntent, 0, false))
|
||||||
|
.setLargeIcon(getFavicon(exception.source))
|
||||||
|
.setVisibility(
|
||||||
|
if (exception.source.isNsfw()) {
|
||||||
|
NotificationCompat.VISIBILITY_SECRET
|
||||||
|
} else {
|
||||||
|
NotificationCompat.VISIBILITY_PUBLIC
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
context.getString(
|
||||||
|
R.string.captcha_required_summary,
|
||||||
|
exception.source.getTitle(context),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val actionIntent = PendingIntentCompat.getActivity(
|
||||||
|
context, SETTINGS_ACTION_CODE,
|
||||||
|
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
.putExtra(Settings.EXTRA_CHANNEL_ID, CHANNEL_ID),
|
||||||
|
0, false,
|
||||||
|
)
|
||||||
|
notification.addAction(
|
||||||
|
R.drawable.ic_settings,
|
||||||
|
context.getString(R.string.notifications_settings),
|
||||||
|
actionIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toMangaSourceOrNull() = MangaSource(this).takeUnless { it == UnknownMangaSource }
|
||||||
|
|
||||||
|
private suspend fun getFavicon(source: MangaSource) = runCatchingCancellable {
|
||||||
|
coilProvider.get().execute(
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(source.faviconUri())
|
||||||
|
.allowHardware(false)
|
||||||
|
.allowConversionToBitmap(true)
|
||||||
|
.mangaSourceExtra(source)
|
||||||
|
.size(context.resources.getNotificationIconSize())
|
||||||
|
.scale(Scale.FILL)
|
||||||
|
.build(),
|
||||||
|
).toBitmapOrNull()
|
||||||
|
}.onFailure {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
val ignoreCaptchaKey = Extras.Key(false)
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "captcha"
|
||||||
|
private const val TAG = CHANNEL_ID
|
||||||
|
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
|
||||||
|
private const val GROUP_NOTIFICATION_ID = 34
|
||||||
|
private const val SETTINGS_ACTION_CODE = 3
|
||||||
|
private const val ACTION_DISCARD = "org.koitharu.kotatsu.CAPTCHA_DISCARD"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,10 @@ import dagger.assisted.Assisted
|
|||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
@@ -43,6 +45,9 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
) {
|
) {
|
||||||
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()) {
|
private val sourceAuthContract = host.registerForActivityResult(SourceAuthActivity.Contract()) {
|
||||||
handleActivityResult(SourceAuthActivity.TAG, it)
|
handleActivityResult(SourceAuthActivity.TAG, it)
|
||||||
}
|
}
|
||||||
@@ -63,6 +68,8 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||||
|
|
||||||
is ProxyConfigException -> {
|
is ProxyConfigException -> {
|
||||||
host.router()?.openProxySettings()
|
host.router()?.openProxySettings()
|
||||||
false
|
false
|
||||||
@@ -93,6 +100,13 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveBrowserAction(
|
||||||
|
e: InteractiveActionRequiredException
|
||||||
|
): Boolean = suspendCoroutine { cont ->
|
||||||
|
continuations[BrowserActivity.TAG] = cont
|
||||||
|
browserActionContract.launch(e)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
private suspend fun resolveCF(e: CloudFlareProtectedException): Boolean = suspendCoroutine { cont ->
|
||||||
continuations[CloudFlareActivity.TAG] = cont
|
continuations[CloudFlareActivity.TAG] = cont
|
||||||
cloudflareContract.launch(e)
|
cloudflareContract.launch(e)
|
||||||
@@ -171,6 +185,8 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
|
|
||||||
is ProxyConfigException -> R.string.settings
|
is ProxyConfigException -> R.string.settings
|
||||||
|
|
||||||
|
is InteractiveActionRequiredException -> R.string._continue
|
||||||
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
import org.koitharu.kotatsu.core.util.ext.isSerializable
|
||||||
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
|
||||||
|
import org.koitharu.kotatsu.main.ui.owners.BottomSheetOwner
|
||||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||||
|
|
||||||
class SnackbarErrorObserver(
|
class SnackbarErrorObserver(
|
||||||
@@ -24,8 +25,9 @@ class SnackbarErrorObserver(
|
|||||||
|
|
||||||
override suspend fun emit(value: Throwable) {
|
override suspend fun emit(value: Throwable) {
|
||||||
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
||||||
if (activity is BottomNavOwner) {
|
when (activity) {
|
||||||
snackbar.anchorView = activity.bottomNav
|
is BottomNavOwner -> snackbar.anchorView = activity.bottomNav
|
||||||
|
is BottomSheetOwner -> snackbar.anchorView = activity.bottomSheet
|
||||||
}
|
}
|
||||||
if (canResolve(value)) {
|
if (canResolve(value)) {
|
||||||
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
|
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
|
||||||
|
|||||||
@@ -93,12 +93,6 @@ class AppUpdateRepository @Inject constructor(
|
|||||||
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp.getOrNull() == true
|
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp.getOrNull() == true
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getCurrentVersionChangelog(): String? {
|
|
||||||
val currentVersion = VersionId(BuildConfig.VERSION_NAME)
|
|
||||||
val available = getAvailableVersions()
|
|
||||||
return available.find { x -> x.versionId == currentVersion }?.description
|
|
||||||
}
|
|
||||||
|
|
||||||
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
|
private inline fun JSONArray.find(predicate: (JSONObject) -> Boolean): JSONObject? {
|
||||||
val size = length()
|
val size = length()
|
||||||
for (i in 0 until size) {
|
for (i in 0 until size) {
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.request.Disposable
|
||||||
|
import coil3.request.ErrorResult
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.NullRequestData
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import coil3.request.allowRgb565
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import coil3.request.lifecycle
|
||||||
|
import coil3.request.target
|
||||||
|
import coil3.size.Scale
|
||||||
|
import coil3.size.Size
|
||||||
|
import coil3.size.SizeResolver
|
||||||
|
import coil3.size.ViewSizeResolver
|
||||||
|
import coil3.util.CoilUtils
|
||||||
|
import com.google.android.material.imageview.ShapeableImageView
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isNetworkError
|
||||||
|
import java.util.LinkedList
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
open class CoilImageView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = 0,
|
||||||
|
) : ShapeableImageView(context, attrs, defStyleAttr), ImageRequest.Listener {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var networkState: NetworkState
|
||||||
|
|
||||||
|
var allowRgb565: Boolean = false
|
||||||
|
var useExistingDrawable: Boolean = false
|
||||||
|
var decodeRegion: Boolean = false
|
||||||
|
var exactImageSize: Size? = null
|
||||||
|
var crossfadeDurationFactor: Float = 1f
|
||||||
|
|
||||||
|
var placeholderDrawable: Drawable? = null
|
||||||
|
var errorDrawable: Drawable? = null
|
||||||
|
var fallbackDrawable: Drawable? = null
|
||||||
|
|
||||||
|
private var currentRequest: Disposable? = null
|
||||||
|
private var currentImageData: Any = NullRequestData
|
||||||
|
private var networkWaitingJob: Job? = null
|
||||||
|
|
||||||
|
private var listeners: MutableList<ImageRequest.Listener>? = null
|
||||||
|
|
||||||
|
val isFailed: Boolean
|
||||||
|
get() = CoilUtils.result(this) is ErrorResult
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.CoilImageView, defStyleAttr) {
|
||||||
|
allowRgb565 = getBoolean(R.styleable.CoilImageView_allowRgb565, allowRgb565)
|
||||||
|
useExistingDrawable = getBoolean(R.styleable.CoilImageView_useExistingDrawable, useExistingDrawable)
|
||||||
|
decodeRegion = getBoolean(R.styleable.CoilImageView_decodeRegion, decodeRegion)
|
||||||
|
placeholderDrawable = getDrawable(R.styleable.CoilImageView_placeholderDrawable)
|
||||||
|
errorDrawable = getDrawable(R.styleable.CoilImageView_errorDrawable)
|
||||||
|
fallbackDrawable = getDrawable(R.styleable.CoilImageView_fallbackDrawable)
|
||||||
|
crossfadeDurationFactor = if (getBoolean(R.styleable.CoilImageView_crossfadeEnabled, true)) {
|
||||||
|
crossfadeDurationFactor
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(request: ImageRequest) {
|
||||||
|
super.onCancel(request)
|
||||||
|
listeners?.forEach { it.onCancel(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||||
|
super.onError(request, result)
|
||||||
|
listeners?.forEach { it.onError(request, result) }
|
||||||
|
if (result.throwable.isNetworkError()) {
|
||||||
|
waitForNetwork()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart(request: ImageRequest) {
|
||||||
|
super.onStart(request)
|
||||||
|
listeners?.forEach { it.onStart(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
|
||||||
|
super.onSuccess(request, result)
|
||||||
|
listeners?.forEach { it.onSuccess(request, result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addImageRequestListener(listener: ImageRequest.Listener) {
|
||||||
|
val list = listeners ?: LinkedList<ImageRequest.Listener>().also { listeners = it }
|
||||||
|
list.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeImageRequestListener(listener: ImageRequest.Listener) {
|
||||||
|
listeners?.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setImageAsync(@DrawableRes resourceId: Int) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(resourceId)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setImageAsync(url: String?) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(url)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun disposeImage() {
|
||||||
|
networkWaitingJob?.cancel()
|
||||||
|
networkWaitingJob = null
|
||||||
|
CoilUtils.dispose(this)
|
||||||
|
currentRequest = null
|
||||||
|
currentImageData = NullRequestData
|
||||||
|
setImageDrawable(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reload() {
|
||||||
|
CoilUtils.result(this)?.let { result ->
|
||||||
|
enqueueRequest(result.request, force = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun enqueueRequest(request: ImageRequest, force: Boolean = false): Disposable {
|
||||||
|
val previous = currentRequest
|
||||||
|
if (!force && currentImageData == request.data && previous?.job?.isCancelled == false && !isFailed) {
|
||||||
|
return previous
|
||||||
|
}
|
||||||
|
networkWaitingJob?.cancel()
|
||||||
|
networkWaitingJob = null
|
||||||
|
currentImageData = request.data
|
||||||
|
return coil.enqueue(request).also { currentRequest = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun newRequestBuilder() = ImageRequest.Builder(context).apply {
|
||||||
|
lifecycle(findViewTreeLifecycleOwner())
|
||||||
|
val crossfadeDuration = if (context.isAnimationsEnabled) {
|
||||||
|
(context.getAnimationDuration(R.integer.config_defaultAnimTime) * crossfadeDurationFactor).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
crossfade(crossfadeDuration)
|
||||||
|
if (useExistingDrawable) {
|
||||||
|
val previousDrawable = this@CoilImageView.drawable?.asImage()
|
||||||
|
if (previousDrawable != null) {
|
||||||
|
fallback(previousDrawable)
|
||||||
|
placeholder(previousDrawable)
|
||||||
|
error(previousDrawable)
|
||||||
|
} else {
|
||||||
|
setupPlaceholders()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setupPlaceholders()
|
||||||
|
}
|
||||||
|
if (decodeRegion) {
|
||||||
|
decodeRegion(0)
|
||||||
|
}
|
||||||
|
size(
|
||||||
|
exactImageSize?.let {
|
||||||
|
SizeResolver(it)
|
||||||
|
} ?: ViewSizeResolver(this@CoilImageView),
|
||||||
|
)
|
||||||
|
scale(scaleType.toCoilScale())
|
||||||
|
listener(this@CoilImageView)
|
||||||
|
allowRgb565(allowRgb565)
|
||||||
|
target(this@CoilImageView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ImageRequest.Builder.setupPlaceholders() {
|
||||||
|
placeholder(placeholderDrawable?.asImage())
|
||||||
|
error(errorDrawable?.asImage())
|
||||||
|
fallback(fallbackDrawable?.asImage())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ScaleType.toCoilScale(): Scale = if (this == ScaleType.CENTER_CROP) {
|
||||||
|
Scale.FILL
|
||||||
|
} else {
|
||||||
|
Scale.FIT
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForNetwork() {
|
||||||
|
if (networkWaitingJob?.isActive == true || networkState.isOnline()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
networkWaitingJob?.cancel()
|
||||||
|
networkWaitingJob = findViewTreeLifecycleOwner()?.lifecycleScope?.launch {
|
||||||
|
networkState.awaitForConnection()
|
||||||
|
if (isFailed) {
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.image
|
|||||||
import coil3.intercept.Interceptor
|
import coil3.intercept.Interceptor
|
||||||
import coil3.network.httpHeaders
|
import coil3.network.httpHeaders
|
||||||
import coil3.request.ImageResult
|
import coil3.request.ImageResult
|
||||||
|
import org.koitharu.kotatsu.core.model.unwrap
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceKey
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||||
@@ -10,7 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|||||||
class MangaSourceHeaderInterceptor : Interceptor {
|
class MangaSourceHeaderInterceptor : Interceptor {
|
||||||
|
|
||||||
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
|
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 request = chain.request
|
||||||
val newHeaders = request.httpHeaders.newBuilder()
|
val newHeaders = request.httpHeaders.newBuilder()
|
||||||
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
.set(CommonHeaders.MANGA_SOURCE, mangaSource.name)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.core.os.LocaleListCompat
|
|||||||
import androidx.core.text.buildSpannedString
|
import androidx.core.text.buildSpannedString
|
||||||
import androidx.core.text.strikeThrough
|
import androidx.core.text.strikeThrough
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||||
import org.koitharu.kotatsu.core.util.ext.iterator
|
import org.koitharu.kotatsu.core.util.ext.iterator
|
||||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||||
@@ -20,6 +21,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.util.findById
|
import org.koitharu.kotatsu.parsers.util.findById
|
||||||
|
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -166,10 +168,24 @@ fun MangaListFilter.getSummary() = buildSpannedString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
|
||||||
filter.tags.joinTo(this) { it.title }
|
var isFirst = true
|
||||||
if (filter.tagsExclude.isNotEmpty()) {
|
val separator = ", "
|
||||||
|
for (tag in filter.tags) {
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = false
|
||||||
|
} else {
|
||||||
|
append(separator)
|
||||||
|
}
|
||||||
|
append(tag.title)
|
||||||
|
}
|
||||||
|
for (tag in filter.tagsExclude) {
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = false
|
||||||
|
} else {
|
||||||
|
append(separator)
|
||||||
|
}
|
||||||
strikeThrough {
|
strikeThrough {
|
||||||
filter.tagsExclude.joinTo(this) { it.title }
|
append(tag.title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,3 +210,14 @@ fun MangaChapter.getLocalizedTitle(resources: Resources, index: Int = -1): Strin
|
|||||||
else -> resources.getString(R.string.unnamed_chapter)
|
else -> resources.getString(R.string.unnamed_chapter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Manga.withOverride(override: MangaOverride?) = if (override != null) {
|
||||||
|
copy(
|
||||||
|
title = override.title.ifNullOrEmpty { title },
|
||||||
|
coverUrl = override.coverUrl.ifNullOrEmpty { coverUrl },
|
||||||
|
largeCoverUrl = override.coverUrl.ifNullOrEmpty { largeCoverUrl },
|
||||||
|
contentRating = override.contentRating ?: contentRating,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.style.ForegroundColorSpan
|
|
||||||
import android.text.style.ImageSpan
|
import android.text.style.ImageSpan
|
||||||
import android.text.style.RelativeSizeSpan
|
|
||||||
import android.text.style.SuperscriptSpan
|
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
@@ -16,7 +12,6 @@ import androidx.core.text.inSpans
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
import org.koitharu.kotatsu.core.util.ext.getDisplayName
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocale
|
import org.koitharu.kotatsu.core.util.ext.toLocale
|
||||||
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
|
import org.koitharu.kotatsu.core.util.ext.toLocaleOrNull
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||||
@@ -24,7 +19,6 @@ import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import androidx.appcompat.R as appcompatR
|
|
||||||
|
|
||||||
data object LocalMangaSource : MangaSource {
|
data object LocalMangaSource : MangaSource {
|
||||||
override val name = "LOCAL"
|
override val name = "LOCAL"
|
||||||
@@ -102,14 +96,6 @@ fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()
|
|||||||
else -> context.getString(R.string.unknown)
|
else -> context.getString(R.string.unknown)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
|
|
||||||
ForegroundColorSpan(context.getThemeColor(appcompatR.attr.colorError, Color.RED)),
|
|
||||||
RelativeSizeSpan(0.74f),
|
|
||||||
SuperscriptSpan(),
|
|
||||||
) {
|
|
||||||
append(context.getString(R.string.nsfw))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder {
|
fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder {
|
||||||
val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this
|
val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this
|
||||||
icon.setTintList(textView.textColors)
|
icon.setTintList(textView.textColors)
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel(
|
|||||||
icon = iconResId,
|
icon = iconResId,
|
||||||
iconData = getIconData(),
|
iconData = getIconData(),
|
||||||
isChecked = isChecked,
|
isChecked = isChecked,
|
||||||
|
counter = if (this is ListFilterOption.Branch) chaptersCount else 0,
|
||||||
data = this,
|
data = this,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import org.koitharu.kotatsu.BuildConfig
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||||
|
import org.koitharu.kotatsu.backups.ui.backup.BackupDialogFragment
|
||||||
|
import org.koitharu.kotatsu.backups.ui.restore.RestoreDialogFragment
|
||||||
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
@@ -50,6 +52,7 @@ import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
|||||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||||
import org.koitharu.kotatsu.core.util.ext.findActivity
|
import org.koitharu.kotatsu.core.util.ext.findActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
@@ -93,8 +96,7 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
|
|||||||
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
|
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
|
||||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||||
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
|
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
|
||||||
import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
|
import org.koitharu.kotatsu.settings.override.OverrideConfigActivity
|
||||||
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
|
|
||||||
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
|
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
|
||||||
@@ -164,7 +166,11 @@ class AppRouter private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun openReader(intent: ReaderIntent, anchor: View? = null) {
|
fun openReader(intent: ReaderIntent, anchor: View? = null) {
|
||||||
startActivity(intent.intent, anchor?.let { view -> scaleUpActivityOptionsOf(view) })
|
val activityIntent = intent.intent
|
||||||
|
if (settings.isReaderMultiTaskEnabled && activityIntent.data != null) {
|
||||||
|
activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
|
||||||
|
}
|
||||||
|
startActivity(activityIntent, anchor?.let { view -> scaleUpActivityOptionsOf(view) })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openAlternatives(manga: Manga) {
|
fun openAlternatives(manga: Manga) {
|
||||||
@@ -206,12 +212,7 @@ class AppRouter private constructor(
|
|||||||
fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java)
|
fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java)
|
||||||
|
|
||||||
fun openBrowser(url: String, source: MangaSource?, title: String?) {
|
fun openBrowser(url: String, source: MangaSource?, title: String?) {
|
||||||
startActivity(
|
startActivity(browserIntent(contextOrNull() ?: return, url, source, title))
|
||||||
Intent(contextOrNull() ?: return, BrowserActivity::class.java)
|
|
||||||
.setData(url.toUri())
|
|
||||||
.putExtra(KEY_TITLE, title)
|
|
||||||
.putExtra(KEY_SOURCE, source?.name),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
|
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
|
||||||
@@ -249,6 +250,11 @@ class AppRouter private constructor(
|
|||||||
startActivity(mangaUpdatesIntent(contextOrNull() ?: return))
|
startActivity(mangaUpdatesIntent(contextOrNull() ?: return))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openMangaOverrideConfig(manga: Manga) {
|
||||||
|
val intent = overrideEditIntent(contextOrNull() ?: return, manga)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
fun openSettings() = startActivity(SettingsActivity::class.java)
|
fun openSettings() = startActivity(SettingsActivity::class.java)
|
||||||
|
|
||||||
fun openReaderSettings() {
|
fun openReaderSettings() {
|
||||||
@@ -447,8 +453,10 @@ class AppRouter private constructor(
|
|||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showBackupCreateDialog() {
|
fun createBackup(destination: Uri) {
|
||||||
BackupDialogFragment().show()
|
BackupDialogFragment().withArgs(1) {
|
||||||
|
putParcelable(KEY_DATA, destination)
|
||||||
|
}.showDistinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showImportDialog() {
|
fun showImportDialog() {
|
||||||
@@ -607,9 +615,11 @@ class AppRouter private constructor(
|
|||||||
startActivity(Intent(contextOrNull() ?: return, activityClass))
|
startActivity(Intent(contextOrNull() ?: return, activityClass))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFragmentManager(): FragmentManager? {
|
private fun getFragmentManager(): FragmentManager? = runCatching {
|
||||||
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
|
fragment?.childFragmentManager ?: activity?.supportFragmentManager
|
||||||
}
|
}.onFailure { exception ->
|
||||||
|
exception.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
private fun shareLink(link: String, title: String) {
|
private fun shareLink(link: String, title: String) {
|
||||||
val context = contextOrNull() ?: return
|
val context = contextOrNull() ?: return
|
||||||
@@ -668,16 +678,18 @@ class AppRouter private constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun from(view: View): AppRouter? = runCatching {
|
fun from(view: View): AppRouter? = runCatching {
|
||||||
AppRouter(view.findFragment<Fragment>())
|
AppRouter(view.findFragment())
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
(view.context.findActivity() as? FragmentActivity)?.let(::AppRouter)
|
(view.context.findActivity() as? FragmentActivity)?.let(::AppRouter)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java)
|
fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java)
|
||||||
.putExtra(KEY_MANGA, ParcelableManga(manga))
|
.putExtra(KEY_MANGA, ParcelableManga(manga))
|
||||||
|
.setData(shortMangaUrl(manga.id))
|
||||||
|
|
||||||
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
|
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
|
||||||
.putExtra(KEY_ID, mangaId)
|
.putExtra(KEY_ID, mangaId)
|
||||||
|
.setData(shortMangaUrl(mangaId))
|
||||||
|
|
||||||
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent =
|
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent =
|
||||||
Intent(context, MangaListActivity::class.java)
|
Intent(context, MangaListActivity::class.java)
|
||||||
@@ -695,12 +707,22 @@ class AppRouter private constructor(
|
|||||||
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
|
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
|
||||||
Intent(context, CloudFlareActivity::class.java).apply {
|
Intent(context, CloudFlareActivity::class.java).apply {
|
||||||
data = exception.url.toUri()
|
data = exception.url.toUri()
|
||||||
putExtra(KEY_SOURCE, exception.source?.name)
|
putExtra(KEY_SOURCE, exception.source.name)
|
||||||
exception.headers[CommonHeaders.USER_AGENT]?.let {
|
exception.headers[CommonHeaders.USER_AGENT]?.let {
|
||||||
putExtra(KEY_USER_AGENT, it)
|
putExtra(KEY_USER_AGENT, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun browserIntent(
|
||||||
|
context: Context,
|
||||||
|
url: String,
|
||||||
|
source: MangaSource?,
|
||||||
|
title: String?
|
||||||
|
): Intent = Intent(context, BrowserActivity::class.java)
|
||||||
|
.setData(url.toUri())
|
||||||
|
.putExtra(KEY_TITLE, title)
|
||||||
|
.putExtra(KEY_SOURCE, source?.name)
|
||||||
|
|
||||||
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
|
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
|
||||||
|
|
||||||
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
|
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
|
||||||
@@ -719,6 +741,10 @@ class AppRouter private constructor(
|
|||||||
Intent(context, SettingsActivity::class.java)
|
Intent(context, SettingsActivity::class.java)
|
||||||
.setAction(ACTION_TRACKER)
|
.setAction(ACTION_TRACKER)
|
||||||
|
|
||||||
|
fun periodicBackupSettingsIntent(context: Context) =
|
||||||
|
Intent(context, SettingsActivity::class.java)
|
||||||
|
.setAction(ACTION_PERIODIC_BACKUP)
|
||||||
|
|
||||||
fun proxySettingsIntent(context: Context) =
|
fun proxySettingsIntent(context: Context) =
|
||||||
Intent(context, SettingsActivity::class.java)
|
Intent(context, SettingsActivity::class.java)
|
||||||
.setAction(ACTION_PROXY)
|
.setAction(ACTION_PROXY)
|
||||||
@@ -754,12 +780,22 @@ class AppRouter private constructor(
|
|||||||
.putExtra(KEY_SOURCE, source.name)
|
.putExtra(KEY_SOURCE, source.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun overrideEditIntent(context: Context, manga: Manga): Intent =
|
||||||
|
Intent(context, OverrideConfigActivity::class.java)
|
||||||
|
.putExtra(KEY_MANGA, ParcelableManga(manga, withDescription = false))
|
||||||
|
|
||||||
fun isShareSupported(manga: Manga): Boolean = when {
|
fun isShareSupported(manga: Manga): Boolean = when {
|
||||||
manga.isBroken -> false
|
manga.isBroken -> false
|
||||||
manga.isLocal -> manga.url.toUri().toFileOrNull() != null
|
manga.isLocal -> manga.url.toUri().toFileOrNull() != null
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shortMangaUrl(mangaId: Long) = Uri.Builder()
|
||||||
|
.scheme("kotatsu")
|
||||||
|
.path("manga")
|
||||||
|
.appendQueryParameter("id", mangaId.toString())
|
||||||
|
.build()
|
||||||
|
|
||||||
const val KEY_DATA = "data"
|
const val KEY_DATA = "data"
|
||||||
const val KEY_ENTRIES = "entries"
|
const val KEY_ENTRIES = "entries"
|
||||||
const val KEY_ERROR = "error"
|
const val KEY_ERROR = "error"
|
||||||
@@ -793,6 +829,7 @@ class AppRouter private constructor(
|
|||||||
const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
|
const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES"
|
||||||
const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS"
|
||||||
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
|
const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER"
|
||||||
|
const val ACTION_PERIODIC_BACKUP = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PERIODIC_BACKUP"
|
||||||
|
|
||||||
private const val ACCOUNT_KEY = "account"
|
private const val ACCOUNT_KEY = "account"
|
||||||
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.fragment.app.DialogFragment
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isOnScreen
|
||||||
|
|
||||||
inline val FragmentActivity.router: AppRouter
|
inline val FragmentActivity.router: AppRouter
|
||||||
get() = AppRouter(this)
|
get() = AppRouter(this)
|
||||||
@@ -26,14 +27,15 @@ tailrec fun Fragment.dismissParentDialog(): Boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) {
|
fun scaleUpActivityOptionsOf(view: View): Bundle? {
|
||||||
ActivityOptions.makeScaleUpAnimation(
|
if (!view.context.isAnimationsEnabled || !view.isOnScreen()) {
|
||||||
view,
|
return null
|
||||||
0,
|
}
|
||||||
0,
|
return ActivityOptions.makeScaleUpAnimation(
|
||||||
view.width,
|
/* source = */ view,
|
||||||
view.height,
|
/* startX = */ 0,
|
||||||
|
/* startY = */ 0,
|
||||||
|
/* width = */ view.width,
|
||||||
|
/* height = */ view.height,
|
||||||
).toBundle()
|
).toBundle()
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,16 @@ value class ReaderIntent private constructor(
|
|||||||
|
|
||||||
fun manga(manga: Manga) = apply {
|
fun manga(manga: Manga) = apply {
|
||||||
intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga))
|
intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga))
|
||||||
|
intent.setData(AppRouter.shortMangaUrl(manga.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mangaId(mangaId: Long) = apply {
|
fun mangaId(mangaId: Long) = apply {
|
||||||
intent.putExtra(AppRouter.KEY_ID, mangaId)
|
intent.putExtra(AppRouter.KEY_ID, mangaId)
|
||||||
|
intent.setData(AppRouter.shortMangaUrl(mangaId))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun incognito(incognito: Boolean) = apply {
|
fun incognito() = apply {
|
||||||
intent.putExtra(EXTRA_INCOGNITO, incognito)
|
intent.putExtra(EXTRA_INCOGNITO, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun branch(branch: String?) = apply {
|
fun branch(branch: String?) = apply {
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ object CommonHeaders {
|
|||||||
const val CACHE_CONTROL = "Cache-Control"
|
const val CACHE_CONTROL = "Cache-Control"
|
||||||
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
|
const val PROXY_AUTHORIZATION = "Proxy-Authorization"
|
||||||
const val RETRY_AFTER = "Retry-After"
|
const val RETRY_AFTER = "Retry-After"
|
||||||
|
const val LAST_MODIFIED = "Last-Modified"
|
||||||
|
const val IF_MODIFIED_SINCE = "If-Modified-Since"
|
||||||
const val MANGA_SOURCE = "X-Manga-Source"
|
const val MANGA_SOURCE = "X-Manga-Source"
|
||||||
|
|
||||||
val CACHE_CONTROL_NO_STORE: CacheControl
|
val CACHE_CONTROL_NO_STORE: CacheControl
|
||||||
get() = CacheControl.Builder().noStore().build()
|
get() = CacheControl.Builder().noStore().build()
|
||||||
|
|
||||||
|
const val DATE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
package org.koitharu.kotatsu.core.network
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import dagger.Lazy
|
import dagger.Lazy
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
@@ -36,7 +35,8 @@ class CommonHeadersInterceptor @Inject constructor(
|
|||||||
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository
|
||||||
} else {
|
} else {
|
||||||
if (BuildConfig.DEBUG && source == null) {
|
if (BuildConfig.DEBUG && source == null) {
|
||||||
Log.w("Http", "Request without source tag: ${request.url}")
|
IllegalArgumentException("Request without source tag: ${request.url}")
|
||||||
|
.printStackTrace()
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class DoHManager(
|
|||||||
).build()
|
).build()
|
||||||
|
|
||||||
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
|
||||||
.url("https://0ms.dev/dns-query".toHttpUrl())
|
.url("https://v.recipes/dns-query".toHttpUrl())
|
||||||
.resolvePublicAddresses(true)
|
.resolvePublicAddresses(true)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.network
|
|
||||||
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import dagger.Lazy
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
||||||
import okhttp3.internal.canParseAsIpAddress
|
|
||||||
import okhttp3.internal.closeQuietly
|
|
||||||
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
|
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import java.util.EnumMap
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class MirrorSwitchInterceptor @Inject constructor(
|
|
||||||
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) : Interceptor {
|
|
||||||
|
|
||||||
private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
|
|
||||||
private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
|
|
||||||
|
|
||||||
val isEnabled: Boolean
|
|
||||||
get() = settings.isMirrorSwitchingAvailable
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
if (!isEnabled) {
|
|
||||||
return chain.proceed(request)
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
val response = chain.proceed(request)
|
|
||||||
if (response.isFailed) {
|
|
||||||
val responseCopy = response.copy()
|
|
||||||
response.closeQuietly()
|
|
||||||
trySwitchMirror(request, chain)?.also {
|
|
||||||
responseCopy.closeQuietly()
|
|
||||||
} ?: responseCopy
|
|
||||||
} else {
|
|
||||||
response
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
trySwitchMirror(request, chain) ?: throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
|
|
||||||
if (!isEnabled) {
|
|
||||||
return@runInterruptible false
|
|
||||||
}
|
|
||||||
val mirrors = repository.getAvailableMirrors()
|
|
||||||
if (mirrors.size <= 1) {
|
|
||||||
return@runInterruptible false
|
|
||||||
}
|
|
||||||
synchronized(obtainLock(repository.source)) {
|
|
||||||
val currentMirror = repository.domain
|
|
||||||
if (currentMirror !in mirrors) {
|
|
||||||
return@synchronized false
|
|
||||||
}
|
|
||||||
addToBlacklist(repository.source, currentMirror)
|
|
||||||
val newMirror = mirrors.firstOrNull { x ->
|
|
||||||
x != currentMirror && !isBlacklisted(repository.source, x)
|
|
||||||
} ?: return@synchronized false
|
|
||||||
repository.domain = newMirror
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
|
|
||||||
blacklist[repository.source]?.remove(oldMirror)
|
|
||||||
repository.domain = oldMirror
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
|
|
||||||
val source = request.tag(MangaSource::class.java) ?: return null
|
|
||||||
val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null
|
|
||||||
val mirrors = repository.getAvailableMirrors()
|
|
||||||
if (mirrors.isEmpty()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return synchronized(obtainLock(repository.source)) {
|
|
||||||
tryMirrors(repository, mirrors, chain, request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tryMirrors(
|
|
||||||
repository: ParserMangaRepository,
|
|
||||||
mirrors: List<String>,
|
|
||||||
chain: Interceptor.Chain,
|
|
||||||
request: Request,
|
|
||||||
): Response? {
|
|
||||||
val url = request.url
|
|
||||||
val currentDomain = url.topPrivateDomain()
|
|
||||||
if (currentDomain !in mirrors) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val urlBuilder = url.newBuilder()
|
|
||||||
for (mirror in mirrors) {
|
|
||||||
if (mirror == currentDomain || isBlacklisted(repository.source, mirror)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val newHost = hostOf(url.host, mirror) ?: continue
|
|
||||||
val newRequest = request.newBuilder()
|
|
||||||
.url(urlBuilder.host(newHost).build())
|
|
||||||
.build()
|
|
||||||
val response = chain.proceed(newRequest)
|
|
||||||
if (response.isFailed) {
|
|
||||||
addToBlacklist(repository.source, mirror)
|
|
||||||
response.closeQuietly()
|
|
||||||
} else {
|
|
||||||
repository.domain = mirror
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private val Response.isFailed: Boolean
|
|
||||||
get() = code in 400..599
|
|
||||||
|
|
||||||
private fun hostOf(host: String, newDomain: String): String? {
|
|
||||||
if (newDomain.canParseAsIpAddress()) {
|
|
||||||
return newDomain
|
|
||||||
}
|
|
||||||
val domain = PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: return null
|
|
||||||
return host.removeSuffix(domain) + newDomain
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Response.copy(): Response {
|
|
||||||
return newBuilder()
|
|
||||||
.body(body?.copy())
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ResponseBody.copy(): ResponseBody {
|
|
||||||
return source().readByteArray().toResponseBody(contentType())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
|
|
||||||
Any()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
|
|
||||||
return blacklist[source]?.contains(domain) == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addToBlacklist(source: MangaParserSource, domain: String) {
|
|
||||||
blacklist.getOrPut(source) {
|
|
||||||
ArraySet(2)
|
|
||||||
}.add(domain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,11 +93,9 @@ interface NetworkModule {
|
|||||||
fun provideMangaHttpClient(
|
fun provideMangaHttpClient(
|
||||||
@BaseHttpClient baseClient: OkHttpClient,
|
@BaseHttpClient baseClient: OkHttpClient,
|
||||||
commonHeadersInterceptor: CommonHeadersInterceptor,
|
commonHeadersInterceptor: CommonHeadersInterceptor,
|
||||||
mirrorSwitchInterceptor: MirrorSwitchInterceptor,
|
|
||||||
): OkHttpClient = baseClient.newBuilder().apply {
|
): OkHttpClient = baseClient.newBuilder().apply {
|
||||||
addNetworkInterceptor(CacheLimitInterceptor())
|
addNetworkInterceptor(CacheLimitInterceptor())
|
||||||
addInterceptor(commonHeadersInterceptor)
|
addInterceptor(commonHeadersInterceptor)
|
||||||
addInterceptor(mirrorSwitchInterceptor)
|
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ import okhttp3.Request
|
|||||||
class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() {
|
class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() {
|
||||||
|
|
||||||
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
|
||||||
if (url.host == "x.0ms.dev" || url.host == "0ms.dev") {
|
if (url.host == "v.recipes") {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
val newUrl = ("https://x.0ms.dev/q70/$url").toHttpUrl()
|
val newUrl = ("https://v.recipes/i/$url").toHttpUrl()
|
||||||
return request.newBuilder()
|
return request.newBuilder()
|
||||||
.data(newUrl)
|
.data(newUrl)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun onInterceptPageRequest(request: Request): Request {
|
override suspend fun onInterceptPageRequest(request: Request): Request {
|
||||||
val newUrl = ("https://x.0ms.dev/q70/${request.url}").toHttpUrl()
|
val newUrl = ("https://v.recipes/i/${request.url}").toHttpUrl()
|
||||||
return request.newBuilder()
|
return request.newBuilder()
|
||||||
.url(newUrl)
|
.url(newUrl)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import okhttp3.Credentials
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.Route
|
import okhttp3.Route
|
||||||
|
import okio.IOException
|
||||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
@@ -39,7 +40,7 @@ class ProxyProvider @Inject constructor(
|
|||||||
return listOf(getProxy())
|
return listOf(getProxy())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: okio.IOException?) {
|
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
||||||
ioe?.printStackTraceDebug()
|
ioe?.printStackTraceDebug()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,8 +81,8 @@ class ProxyProvider @Inject constructor(
|
|||||||
append(settings.proxyPort)
|
append(settings.proxyPort)
|
||||||
}
|
}
|
||||||
if (settings.proxyType == Proxy.Type.SOCKS) {
|
if (settings.proxyType == Proxy.Type.SOCKS) {
|
||||||
System.setProperty("java.net.socks.username", settings.proxyLogin);
|
System.setProperty("java.net.socks.username", settings.proxyLogin)
|
||||||
System.setProperty("java.net.socks.password", settings.proxyPassword);
|
System.setProperty("java.net.socks.password", settings.proxyPassword)
|
||||||
}
|
}
|
||||||
val proxyConfig = ProxyConfig.Builder()
|
val proxyConfig = ProxyConfig.Builder()
|
||||||
.addProxyRule(url)
|
.addProxyRule(url)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user