Compare commits

...

77 Commits

Author SHA1 Message Date
InfinityDouki56
5d2395b569 Translated using Weblate (Filipino)
Currently translated at 92.4% (392 of 424 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-02-23 19:48:42 +02:00
Koitharu
29114ae8a7 Sync logger 2023-02-22 20:20:57 +02:00
Koitharu
47f80085d1 Temporary disable sync for release builds 2023-02-22 07:56:30 +02:00
Koitharu
73c1d2a616 Show error details for pages 2023-02-21 18:59:52 +02:00
InfinityDouki56
35366ac660 Translated using Weblate (Filipino)
Currently translated at 56.1% (238 of 424 strings)

Translated using Weblate (Filipino)

Currently translated at 0.0% (0 of 8 strings)

Added translation using Weblate (Filipino)

Added translation using Weblate (Filipino)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-02-21 18:32:13 +02:00
J. Lavoie
dc2dd4e3c9 Translated using Weblate (Greek)
Currently translated at 21.6% (92 of 424 strings)

Translated using Weblate (French)

Currently translated at 100.0% (424 of 424 strings)

Translated using Weblate (French)

Currently translated at 98.8% (419 of 424 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-02-21 18:32:13 +02:00
Koitharu
66817ae545 Fix search bar hint font 2023-02-17 20:03:57 +02:00
Koitharu
b6e3cb929b Fix explore buttons color 2023-02-17 19:43:38 +02:00
Koitharu
6f29259395 Add "Grid mode" option to explore options menu 2023-02-17 18:58:37 +02:00
Koitharu
c520699f9f Fix crash with invalid domain 2023-02-17 07:39:59 +02:00
Koitharu
c09b0150ac Update parsers 2023-02-16 19:27:15 +02:00
Koitharu
d7c31f3b3b Translated using Weblate (Russian)
Currently translated at 100.0% (424 of 424 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
gallegonovato
362629bb9a Translated using Weblate (Spanish)
Currently translated at 100.0% (424 of 424 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
Raman
4ec4421f69 Translated using Weblate (Hindi)
Currently translated at 5.6% (24 of 423 strings)

Co-authored-by: Raman <translations.0l5zc@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
Shippo
029815e0d7 Translated using Weblate (Arabic)
Currently translated at 21.9% (93 of 423 strings)

Co-authored-by: Shippo <Shipox@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
Eric
019b41a9f9 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (424 of 424 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (423 of 423 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-02-16 18:01:53 +02:00
Koitharu
a56e977058 Show download started snackbar 2023-02-14 20:43:56 +02:00
Koitharu
f436a49e5f Add support for the predictive back gesture 2023-02-14 20:33:01 +02:00
Koitharu
652351f79a Improve downloads binding 2023-02-14 08:04:17 +02:00
Koitharu
b6bfef6b50 Option to allow updates to unstable app versions 2023-02-13 18:44:22 +02:00
Eric
c119db67e9 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-02-13 18:00:33 +02:00
Макар Разин
08e036f9fb Translated using Weblate (Serbian)
Currently translated at 8.7% (37 of 421 strings)

Translated using Weblate (Arabic)

Currently translated at 21.3% (90 of 421 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Turkish)

Currently translated at 98.3% (414 of 421 strings)

Translated using Weblate (French)

Currently translated at 98.3% (414 of 421 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (421 of 421 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-02-13 18:00:33 +02:00
Hosted Weblate
07519b82f3 Update translation files
Updated by "Remove blank strings" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Grand-Priest0
2644756a01 Translated using Weblate (Hindi)
Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Hindi)

Added translation using Weblate (Hindi)

Co-authored-by: Grand-Priest0 <followtheanime@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hi/
Translation: Kotatsu/plurals
2023-02-12 10:12:39 +02:00
Макар Разин
f6c715c5a7 Translated using Weblate (Arabic)
Currently translated at 20.1% (85 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (8 of 8 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Italian)

Currently translated at 98.0% (413 of 421 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (421 of 421 strings)

Translated using Weblate (Belarusian)

Currently translated at 99.7% (420 of 421 strings)

Translated using Weblate (Russian)

Currently translated at 98.8% (416 of 421 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-02-12 10:12:39 +02:00
Zakhar Timoshenko
81f3a40ba8 Translated using Weblate (Russian)
Currently translated at 99.7% (411 of 412 strings)

Co-authored-by: Zakhar Timoshenko <vp1984tanki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Evgeniy Khramov
736be6249c Translated using Weblate (Russian)
Currently translated at 100.0% (411 of 411 strings)

Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Allan Nordhøy
0add49f32c Translated using Weblate (Norwegian Bokmål)
Currently translated at 81.9% (337 of 411 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Shippo
1e2be37fd6 Translated using Weblate (Arabic)
Currently translated at 19.9% (81 of 406 strings)

Translated using Weblate (Arabic)

Currently translated at 37.5% (3 of 8 strings)

Co-authored-by: Shippo <Shipox@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-02-12 10:12:39 +02:00
Eric
529c6c7a08 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (415 of 415 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (411 of 411 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (406 of 406 strings)

Co-authored-by: Eric <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Koitharu
03251cbf9a Translated using Weblate (Russian)
Currently translated at 100.0% (406 of 406 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Oğuz Ersen
4ab9ace2f2 Translated using Weblate (Turkish)
Currently translated at 100.0% (406 of 406 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
gallegonovato
c55be4efc5 Translated using Weblate (Spanish)
Currently translated at 100.0% (415 of 415 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (411 of 411 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (406 of 406 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
kuzan
48b01d0706 Added translation using Weblate (English (Middle))
Co-authored-by: kuzan <3313631632@qq.com>
2023-02-12 10:12:39 +02:00
J. Lavoie
e2e0d7a53d Translated using Weblate (French)
Currently translated at 100.0% (412 of 412 strings)

Translated using Weblate (French)

Currently translated at 100.0% (411 of 411 strings)

Translated using Weblate (Italian)

Currently translated at 99.2% (408 of 411 strings)

Translated using Weblate (German)

Currently translated at 98.2% (404 of 411 strings)

Translated using Weblate (French)

Currently translated at 100.0% (406 of 406 strings)

Translated using Weblate (Italian)

Currently translated at 99.2% (403 of 406 strings)

Translated using Weblate (German)

Currently translated at 98.2% (399 of 406 strings)

Translated using Weblate (French)

Currently translated at 100.0% (405 of 405 strings)

Translated using Weblate (Italian)

Currently translated at 99.2% (402 of 405 strings)

Translated using Weblate (German)

Currently translated at 97.5% (395 of 405 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2023-02-12 10:12:39 +02:00
Koitharu
e3a67940d0 Add MemoryUsageView 2023-02-12 10:05:05 +02:00
Koitharu
5ce2bc92d6 Store and restore ThemeChooserPreference state 2023-02-12 09:44:53 +02:00
Koitharu
d05e777b2c Reduce safe parcel size #304 2023-02-11 07:49:42 +02:00
Zakhar Timoshenko
206673a417 Add Kanade theme 2023-02-11 00:50:07 +03:00
Zakhar Timoshenko
95e46249c5 Add Mamimi theme and made some theme tweaks 2023-02-11 00:25:07 +03:00
Koitharu
ea9ae2263c Reorganize settings 2023-02-10 21:47:57 +02:00
Zakhar Timoshenko
2acbff487e Update README 2023-02-10 21:47:57 +03:00
Koitharu
26b852365a Scrobblers config activity 2023-02-10 20:36:59 +02:00
Zakhar Timoshenko
c2e56f7ba6 Scrobbler icon adjusting on sheet 2023-02-09 20:33:40 +03:00
Zakhar Timoshenko
68e8876288 MAL final changes 2023-02-09 20:32:56 +03:00
Zakhar Timoshenko
5c44a4dbb3 Merge branch 'devel' into feature/mal 2023-02-09 17:34:32 +03:00
Koitharu
7a7ba802f6 Add more color schemes 2023-02-08 20:50:10 +02:00
Koitharu
c5ae9fb087 Use relative date format 2023-02-08 20:19:01 +02:00
Koitharu
e0f23d2e6d New headers processing approach 2023-02-08 19:57:17 +02:00
Koitharu
e9a972eec9 Merge branch 'master' into devel 2023-02-07 07:49:23 +02:00
Koitharu
fd3c83cb13 Allow to use own UserAgent for each manga source 2023-02-06 19:45:55 +02:00
Zakhar Timoshenko
ec137d2513 Merge branch 'devel' into feature/mal 2023-02-05 18:31:22 +03:00
Zakhar Timoshenko
9da5bdaad4 Update parsers 2023-02-05 18:30:42 +03:00
Zakhar Timoshenko
eec1850712 Change user agent to Chrome 2023-02-05 18:30:28 +03:00
Zakhar Timoshenko
802ab4c6c1 Merge branch 'devel' into feature/mal 2023-02-05 09:45:55 +03:00
Koitharu
85d09dc48c Grid mode option for sources list 2023-02-05 08:19:07 +02:00
Koitharu
1daa02af52 Handle errors properly in scrobbler selector 2023-02-04 08:48:51 +02:00
Koitharu
1729505bfe Replace raster drawables with vectors 2023-02-03 20:11:21 +02:00
Koitharu
00617d5c64 Merge branch 'devel' into feature/mal 2023-02-03 20:01:22 +02:00
Koitharu
35b8003cf9 Color schemes 2023-02-03 19:39:14 +02:00
Zakhar Timoshenko
56ed8a787a MAL update №1 2023-02-03 02:23:15 +03:00
Koitharu
fd26de7619 Improve scrobbling ui 2023-02-01 20:21:20 +02:00
Koitharu
205a2e10a5 Fix scrobbling ui issues 2023-02-01 08:04:38 +02:00
Zakhar Timoshenko
8514cc3da7 Auth, search 2023-02-01 01:05:04 +03:00
Koitharu
8bc8df7625 Merge branch 'master' into devel 2023-01-30 20:27:20 +02:00
Zakhar Timoshenko
80be0e403d Update MAL codebase 2023-01-30 01:27:45 +03:00
Zakhar Timoshenko
ee2538ba7f Merge branch 'devel' into feature/mal
# Conflicts:
#	app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt
#	app/src/main/java/org/koitharu/kotatsu/scrobbling/ScrobblingModule.kt
#	app/src/main/java/org/koitharu/kotatsu/scrobbling/domain/model/ScrobblerService.kt
#	app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
#	app/src/main/res/xml/pref_history.xml
2023-01-30 00:53:13 +03:00
Koitharu
6ca6ec28ac Complete AniList api integration #208 2023-01-28 20:35:22 +02:00
Koitharu
94203785f1 Merge branch 'devel' into feature/anilist 2023-01-27 19:38:00 +02:00
Koitharu
3f538d9b78 Move some OnBackPressed logics to OnBackPressedCallbacks 2023-01-27 19:26:31 +02:00
javlon
e6a0578884 Go back to Shelf screen onBackPressed() 2023-01-27 10:28:57 +02:00
Zakhar Timoshenko
2c71110fa5 Merge branch 'devel' into feature/mal
# Conflicts:
#	app/src/main/java/org/koitharu/kotatsu/settings/HistorySettingsFragment.kt
#	app/src/main/res/values/strings.xml
2023-01-04 18:51:59 +03:00
Koitharu
46786e32a3 Merge branch 'devel' into feature/anilist 2022-11-17 19:20:24 +02:00
Koitharu
eef449af49 Merge branch 'devel' into feature/mal 2022-11-17 19:12:17 +02:00
Koitharu
0c4c7489e9 Unify scrobblers implementation 2022-10-30 11:10:38 +02:00
Koitharu
743098d0b0 Basic AniList api implementation 2022-10-25 15:18:44 +03:00
Zakhar Timoshenko
2b2042807b Initial MyAnimeList implementation 2022-10-17 21:28:14 +03:00
237 changed files with 5392 additions and 1540 deletions

2
.gitignore vendored
View File

@@ -12,9 +12,11 @@
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/kotlinScripting.xml /.idea/kotlinScripting.xml
/.idea/kotlinc.xml
/.idea/deploymentTargetDropDown.xml /.idea/deploymentTargetDropDown.xml
/.idea/androidTestResultsUserPreferences.xml /.idea/androidTestResultsUserPreferences.xml
/.idea/render.experimental.xml /.idea/render.experimental.xml
/.idea/inspectionProfiles/
.DS_Store .DS_Store
/build /build
/captures /captures

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -1,17 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="BooleanLiteralArgument" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="Destructure" enabled="true" level="INFO" enabled_by_default="true" />
<inspection_tool class="FillClass" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="withoutDefaultValues" value="true" />
</inspection_tool>
<inspection_tool class="KeySetIterationMayUseEntrySet" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="KotlinFunctionArgumentsHelper" enabled="true" level="INFORMATION" enabled_by_default="true">
<option name="withoutDefaultValues" value="true" />
</inspection_tool>
<inspection_tool class="ReplaceCollectionCountWithSize" enabled="true" level="WEAK WARNING" enabled_by_default="true" />
<inspection_tool class="TrailingComma" enabled="true" level="INFORMATION" enabled_by_default="true" />
<inspection_tool class="ZeroLengthArrayInitialization" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

9
.idea/kotlinc.xml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Kotlin2JvmCompilerArguments">
<option name="jvmTarget" value="1.8" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.0" />
</component>
</project>

View File

@@ -16,7 +16,7 @@ Download APK directly from GitHub:
### Main Features ### Main Features
* Online manga catalogues * Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
* Search manga by name and genres * Search manga by name and genres
* Reading history and bookmarks * Reading history and bookmarks
* Favourites organized by user-defined categories * Favourites organized by user-defined categories
@@ -24,7 +24,7 @@ Download APK directly from GitHub:
* Tablet-optimized Material You UI * Tablet-optimized Material You UI
* Standard and Webtoon-optimized reader * Standard and Webtoon-optimized reader
* Notifications about new chapters with updates feed * Notifications about new chapters with updates feed
* Shikimori integration (manga tracking) * Integration with manga tracking services: Shikimori, AniList, MyAnimeList
* Password/fingerprint protect access to the app * Password/fingerprint protect access to the app
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices * History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode 514 versionCode 517
versionName '4.3.3' versionName '4.4.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -29,6 +29,9 @@ android {
// define this values in your local.properties file // define this values in your local.properties file
buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_ID', "\"${localProperty('shikimori.clientId')}\""
buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\"" buildConfigField 'String', 'SHIKIMORI_CLIENT_SECRET', "\"${localProperty('shikimori.clientSecret')}\""
buildConfigField 'String', 'ANILIST_CLIENT_ID', "\"${localProperty('anilist.clientId')}\""
buildConfigField 'String', 'ANILIST_CLIENT_SECRET', "\"${localProperty('anilist.clientSecret')}\""
buildConfigField 'String', 'MAL_CLIENT_ID', "\"${localProperty('mal.clientId')}\""
resValue "string", "acra_login", "${localProperty('acra.login')}" resValue "string", "acra_login", "${localProperty('acra.login')}"
resValue "string", "acra_password", "${localProperty('acra.password')}" resValue "string", "acra_password", "${localProperty('acra.password')}"
} }
@@ -84,14 +87,14 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:00abaea324') { implementation('com.github.KotatsuApp:kotatsu-parsers:f4c47b5b84') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation "androidx.appcompat:appcompat:1.6.0" implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.6.1' implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.5' implementation 'androidx.fragment:fragment-ktx:1.5.5'
@@ -104,7 +107,7 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.preference:preference-ktx:1.2.0' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1' implementation 'androidx.work:work-runtime-ktx:2.8.0'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.8.0' implementation 'com.google.android.material:material:1.8.0'
//noinspection LifecycleAnnotationProcessorWithJava8 //noinspection LifecycleAnnotationProcessorWithJava8

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.Response
import okio.Buffer
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
class CurlLoggingInterceptor(
private val curlOptions: String? = null
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var isCompressed = false
val curlCmd = StringBuilder()
curlCmd.append("curl")
if (curlOptions != null) {
curlCmd.append(' ').append(curlOptions)
}
curlCmd.append(" -X ").append(request.method)
for ((name, value) in request.headers) {
if (name.equals(ACCEPT_ENCODING, ignoreCase = true) && value.equals("gzip", ignoreCase = true)) {
isCompressed = true
}
curlCmd.append(" -H \"").append(name).append(": ").append(value.escape()).append('\"')
}
val body = request.body
if (body != null) {
val buffer = Buffer()
body.writeTo(buffer)
val charset = body.contentType()?.charset() ?: Charsets.UTF_8
curlCmd.append(" --data-raw '")
.append(buffer.readString(charset).replace("\n", "\\n"))
.append("'")
}
if (isCompressed) {
curlCmd.append(" --compressed")
}
curlCmd.append(" \"").append(request.url).append('"')
log("---cURL (" + request.url + ")")
log(curlCmd.toString())
return chain.proceed(request)
}
private fun String.escape() = replace("\"", "\\\"")
private fun log(msg: String) {
Log.d("CURL", msg)
}
}

View File

@@ -1,15 +1,20 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import java.util.*
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.* import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
/** /**
* This parser is just for parser development, it should not be used in releases * This parser is just for parser development, it should not be used in releases
*/ */
class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("", null) get() = ConfigKey.Domain("", null)
@@ -37,4 +42,4 @@ class DummyParser(override val context: MangaLoaderContext) : MangaParser(MangaS
override suspend fun getTags(): Set<MangaTag> { override suspend fun getTags(): Set<MangaTag> {
TODO("Not yet implemented") TODO("Not yet implemented")
} }
} }

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool> <bool name="leak_canary_add_launcher_icon" tools:node="replace">false</bool>
</resources> <bool name="is_sync_enabled">true</bool>
</resources>

View File

@@ -24,6 +24,7 @@
android:allowBackup="true" android:allowBackup="true"
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent" android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules" android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_content" android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true" android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -85,16 +86,7 @@
<activity <activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity" android:name="org.koitharu.kotatsu.settings.SettingsActivity"
android:exported="true" android:exported="true"
android:label="@string/settings"> android:label="@string/settings" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
<activity <activity
android:name="org.koitharu.kotatsu.browser.BrowserActivity" android:name="org.koitharu.kotatsu.browser.BrowserActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
@@ -140,6 +132,22 @@
<activity <activity
android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity" android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity"
android:label="@string/settings" /> android:label="@string/settings" />
<activity
android:name="org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity"
android:exported="true"
android:label="@string/settings"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kotatsu" />
</intent-filter>
</activity>
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"

View File

@@ -4,11 +4,6 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.util.Size import android.util.Size
import androidx.room.withTransaction import androidx.room.withTransaction
import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -17,7 +12,11 @@ import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.* import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
@@ -27,6 +26,11 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.io.InputStream
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.math.roundToInt
private const val MIN_WEBTOON_RATIO = 2 private const val MIN_WEBTOON_RATIO = 2
@@ -121,7 +125,7 @@ class MangaDataRepository @Inject constructor(
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.get() .get()
.header(CommonHeaders.REFERER, page.referer) .tag(MangaSource::class.java, page.source)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.build() .build()
okHttpClient.newCall(request).await().use { okHttpClient.newCall(request).await().use {

View File

@@ -5,6 +5,7 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -39,6 +40,8 @@ abstract class AlertDialogFragment<B : ViewBinding> : DialogFragment() {
open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder open fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder = builder
open fun onDialogCreated(dialog: AlertDialog) = Unit
protected fun bindingOrNull(): B? = viewBinding protected fun bindingOrNull(): B? = viewBinding
protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B protected abstract fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): B

View File

@@ -12,6 +12,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.ActionBarContextView import androidx.appcompat.widget.ActionBarContextView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
@@ -51,12 +52,9 @@ abstract class BaseActivity<B : ViewBinding> :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this) EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this)
val isAmoled = settings.isAmoledTheme setTheme(settings.colorScheme.styleResId)
val isDynamic = settings.isDynamicTheme if (settings.isAmoledTheme) {
when { setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled)
isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled)
isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet)
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -84,14 +82,14 @@ abstract class BaseActivity<B : ViewBinding> :
} }
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) { override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
@Suppress("DEPRECATION")
onBackPressed() onBackPressed()
true true
} else super.onOptionsItemSelected(item) } else super.onOptionsItemSelected(item)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { // TODO remove if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
// ActivityCompat.recreate(this) ActivityCompat.recreate(this)
TODO("Test error")
return true return true
} }
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
@@ -133,7 +131,8 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = getThemeColor(android.R.attr.statusBarColor) window.statusBarColor = getThemeColor(android.R.attr.statusBarColor)
} }
@Suppress("OVERRIDE_DEPRECATION", "DEPRECATION") @Suppress("DEPRECATION", "DeprecatedCallableAddReplaceWith")
@Deprecated("Should not be used")
override fun onBackPressed() { override fun onBackPressed() {
if ( // https://issuetracker.google.com/issues/139738913 if ( // https://issuetracker.google.com/issues/139738913
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q &&

View File

@@ -7,15 +7,16 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import androidx.activity.OnBackPressedDispatcher
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.R as materialR
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog import org.koitharu.kotatsu.base.ui.dialog.AppBottomSheetDialog
import org.koitharu.kotatsu.utils.ext.displayCompat import org.koitharu.kotatsu.utils.ext.displayCompat
import com.google.android.material.R as materialR
abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() { abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
@@ -27,6 +28,12 @@ abstract class BaseBottomSheet<B : ViewBinding> : BottomSheetDialogFragment() {
protected val behavior: BottomSheetBehavior<*>? protected val behavior: BottomSheetBehavior<*>?
get() = (dialog as? BottomSheetDialog)?.behavior get() = (dialog as? BottomSheetDialog)?.behavior
val isExpanded: Boolean
get() = behavior?.state == BottomSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher
get() = (requireDialog() as AppBottomSheetDialog).onBackPressedDispatcher
final override fun onCreateView( final override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.base.ui.util
import android.view.MenuItem
import android.view.MenuItem.OnActionExpandListener
import androidx.activity.OnBackPressedCallback
class CollapseActionViewCallback(
private val menuItem: MenuItem
) : OnBackPressedCallback(menuItem.isActionViewExpanded), OnActionExpandListener {
override fun handleOnBackPressed() {
menuItem.collapseActionView()
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
isEnabled = true
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
isEnabled = false
return true
}
}

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.base.ui.util
import android.view.View
import androidx.annotation.Px
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.parsers.util.toIntUp
import kotlin.math.abs
class SpanSizeResolver(
private val recyclerView: RecyclerView,
@Px private val minItemWidth: Int,
) : View.OnLayoutChangeListener {
fun attach() {
recyclerView.addOnLayoutChangeListener(this)
}
fun detach() {
recyclerView.removeOnLayoutChangeListener(this)
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
invalidateInternal(abs(right - left))
}
fun invalidate() {
invalidateInternal(recyclerView.width)
}
private fun invalidateInternal(width: Int) {
if (width <= 0) {
return
}
val lm = recyclerView.layoutManager as? GridLayoutManager ?: return
val estimatedCount = (width / minItemWidth.toFloat()).toIntUp()
if (lm.spanCount != estimatedCount) {
lm.spanCount = estimatedCount
lm.spanSizeLookup?.run {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
}
}

View File

@@ -4,10 +4,12 @@ import android.animation.LayoutTransition
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
@@ -15,16 +17,16 @@ import androidx.core.content.withStyledAttributes
import androidx.core.view.* import androidx.core.view.*
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import java.util.*
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderBinding import org.koitharu.kotatsu.databinding.LayoutSheetHeaderBinding
import org.koitharu.kotatsu.utils.ext.getAnimationDuration import org.koitharu.kotatsu.utils.ext.getAnimationDuration
import org.koitharu.kotatsu.utils.ext.getThemeDrawable import org.koitharu.kotatsu.utils.ext.getThemeDrawable
import org.koitharu.kotatsu.utils.ext.parents import org.koitharu.kotatsu.utils.ext.parents
import java.util.*
import com.google.android.material.R as materialR
private const val THROTTLE_DELAY = 200L private const val THROTTLE_DELAY = 200L
@@ -53,6 +55,9 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
val toolbar: MaterialToolbar val toolbar: MaterialToolbar
get() = binding.toolbar get() = binding.toolbar
val menu: Menu
get() = binding.toolbar.menu
var title: CharSequence? var title: CharSequence?
get() = binding.toolbar.title get() = binding.toolbar.title
set(value) { set(value) {
@@ -140,6 +145,10 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
binding.toolbar.invalidateMenu() binding.toolbar.invalidateMenu()
} }
fun inflateMenu(@MenuRes resId: Int) {
binding.toolbar.inflateMenu(resId)
}
fun setNavigationOnClickListener(onClickListener: OnClickListener) { fun setNavigationOnClickListener(onClickListener: OnClickListener) {
binding.toolbar.setNavigationOnClickListener(onClickListener) binding.toolbar.setNavigationOnClickListener(onClickListener)
} }
@@ -258,6 +267,7 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
} }
lp lp
} }
else -> Toolbar.LayoutParams(params) else -> Toolbar.LayoutParams(params)
} }
} }
@@ -282,7 +292,7 @@ class BottomSheetHeaderBar @JvmOverloads constructor(
suppressLayoutCompat(false) suppressLayoutCompat(false)
} }
private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), View.OnClickListener { private inner class Callback : BottomSheetBehavior.BottomSheetCallback(), OnClickListener {
override fun onStateChanged(bottomSheet: View, newState: Int) { override fun onStateChanged(bottomSheet: View, newState: Int) {
onBottomSheetStateChanged(newState) onBottomSheetStateChanged(newState)

View File

@@ -1,119 +0,0 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.annotation.AttrRes
import androidx.annotation.IdRes
import androidx.core.view.children
import com.google.android.material.R as materialR
import com.google.android.material.button.MaterialButton
import com.google.android.material.shape.ShapeAppearanceModel
class CheckableButtonGroup @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = materialR.attr.materialButtonToggleGroupStyle,
) : LinearLayout(context, attrs, defStyleAttr, materialR.style.Widget_MaterialComponents_MaterialButtonToggleGroup),
View.OnClickListener {
private val originalCornerData = ArrayList<CornerData>()
var onCheckedChangeListener: OnCheckedChangeListener? = null
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if (child is MaterialButton) {
setupButton(child)
}
super.addView(child, index, params)
}
override fun onFinishInflate() {
super.onFinishInflate()
updateChildShapes()
}
override fun onClick(v: View) {
setCheckedId(v.id)
}
fun setCheckedId(@IdRes viewRes: Int) {
children.forEach {
(it as? MaterialButton)?.isChecked = it.id == viewRes
}
onCheckedChangeListener?.onCheckedChanged(this, viewRes)
}
private fun updateChildShapes() {
val childCount = childCount
val firstVisibleChildIndex = 0
val lastVisibleChildIndex = childCount - 1
for (i in 0 until childCount) {
val button: MaterialButton = getChildAt(i) as? MaterialButton ?: continue
if (button.visibility == GONE) {
continue
}
val builder = button.shapeAppearanceModel.toBuilder()
val newCornerData: CornerData? =
getNewCornerData(i, firstVisibleChildIndex, lastVisibleChildIndex)
updateBuilderWithCornerData(builder, newCornerData)
button.shapeAppearanceModel = builder.build()
}
}
private fun setupButton(button: MaterialButton) {
button.setOnClickListener(this)
button.isElegantTextHeight = false
// Saves original corner data
val shapeAppearanceModel: ShapeAppearanceModel = button.shapeAppearanceModel
originalCornerData.add(
CornerData(
shapeAppearanceModel.topLeftCornerSize,
shapeAppearanceModel.bottomLeftCornerSize,
shapeAppearanceModel.topRightCornerSize,
shapeAppearanceModel.bottomRightCornerSize,
),
)
}
private fun getNewCornerData(
index: Int,
firstVisibleChildIndex: Int,
lastVisibleChildIndex: Int,
): CornerData? {
val cornerData: CornerData = originalCornerData.get(index)
// If only one (visible) child exists, use its original corners
if (firstVisibleChildIndex == lastVisibleChildIndex) {
return cornerData
}
val isHorizontal = orientation == HORIZONTAL
if (index == firstVisibleChildIndex) {
return if (isHorizontal) cornerData.start(this) else cornerData.top()
}
return if (index == lastVisibleChildIndex) {
if (isHorizontal) cornerData.end(this) else cornerData.bottom()
} else null
}
private fun updateBuilderWithCornerData(
shapeAppearanceModelBuilder: ShapeAppearanceModel.Builder,
cornerData: CornerData?,
) {
if (cornerData == null) {
shapeAppearanceModelBuilder.setAllCornerSizes(0f)
return
}
shapeAppearanceModelBuilder
.setTopLeftCornerSize(cornerData.topLeft)
.setBottomLeftCornerSize(cornerData.bottomLeft)
.setTopRightCornerSize(cornerData.topRight)
.setBottomRightCornerSize(cornerData.bottomRight)
}
fun interface OnCheckedChangeListener {
fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int)
}
}

View File

@@ -1,47 +0,0 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.view.View
import androidx.core.view.ViewCompat
import com.google.android.material.shape.AbsoluteCornerSize
import com.google.android.material.shape.CornerSize
class CornerData(
var topLeft: CornerSize,
var bottomLeft: CornerSize,
var topRight: CornerSize,
var bottomRight: CornerSize,
) {
fun start(view: View): CornerData {
return if (isLayoutRtl(view)) right() else left()
}
fun end(view: View): CornerData {
return if (isLayoutRtl(view)) left() else right()
}
fun left(): CornerData {
return CornerData(topLeft, bottomLeft, noCorner, noCorner)
}
fun right(): CornerData {
return CornerData(noCorner, noCorner, topRight, bottomRight)
}
fun top(): CornerData {
return CornerData(topLeft, noCorner, topRight, noCorner)
}
fun bottom(): CornerData {
return CornerData(noCorner, bottomLeft, noCorner, bottomRight)
}
private companion object {
val noCorner: CornerSize = AbsoluteCornerSize(0f)
fun isLayoutRtl(view: View): Boolean {
return ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL
}
}
}

View File

@@ -0,0 +1,94 @@
package org.koitharu.kotatsu.base.ui.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.withClip
import com.google.android.material.drawable.DrawableUtils
import org.koitharu.kotatsu.R
class ShapeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val corners = FloatArray(8)
private val outlinePath = Path()
private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
context.withStyledAttributes(attrs, R.styleable.ShapeView, defStyleAttr) {
val cornerSize = getDimension(R.styleable.ShapeView_cornerSize, 0f)
corners[0] = getDimension(R.styleable.ShapeView_cornerSizeTopLeft, cornerSize)
corners[1] = corners[0]
corners[2] = getDimension(R.styleable.ShapeView_cornerSizeTopRight, cornerSize)
corners[3] = corners[2]
corners[4] = getDimension(R.styleable.ShapeView_cornerSizeBottomRight, cornerSize)
corners[5] = corners[4]
corners[6] = getDimension(R.styleable.ShapeView_cornerSizeBottomLeft, cornerSize)
corners[7] = corners[6]
strokePaint.color = getColor(R.styleable.ShapeView_strokeColor, Color.TRANSPARENT)
strokePaint.strokeWidth = getDimension(R.styleable.ShapeView_strokeWidth, 0f)
strokePaint.style = Paint.Style.STROKE
}
outlineProvider = OutlineProvider()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (w != oldw || h != oldh) {
rebuildPath()
}
}
override fun draw(canvas: Canvas) {
canvas.withClip(outlinePath) {
super.draw(canvas)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (strokePaint.strokeWidth > 0f) {
canvas.drawPath(outlinePath, strokePaint)
}
}
private fun rebuildPath() {
outlinePath.reset()
val w = width
val h = height
if (w > 0 && h > 0) {
outlinePath.addRoundRect(0f, 0f, w.toFloat(), h.toFloat(), corners, Path.Direction.CW)
}
}
private inner class OutlineProvider : ViewOutlineProvider() {
@SuppressLint("RestrictedApi")
override fun getOutline(view: View?, outline: Outline) {
val corner = corners[0]
var isRoundRect = true
for (item in corners) {
if (item != corner) {
isRoundRect = false
break
}
}
if (isRoundRect) {
outline.setRoundRect(0, 0, width, height, corner)
} else {
DrawableUtils.setOutlineToPath(outline, outlinePath)
}
}
}
}

View File

@@ -11,7 +11,6 @@ import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
fun bookmarkListAD( fun bookmarkListAD(
coil: ImageLoader, coil: ImageLoader,
@@ -27,7 +26,6 @@ fun bookmarkListAD(
bind { bind {
binding.imageViewThumb.newImageRequest(item.imageUrl, item.manga.source)?.run { binding.imageViewThumb.newImageRequest(item.imageUrl, item.manga.source)?.run {
referer(item.manga.publicUrl)
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)

View File

@@ -18,7 +18,6 @@ import org.koitharu.kotatsu.utils.ext.clearItemDecorations
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
fun bookmarksGroupAD( fun bookmarksGroupAD(
coil: ImageLoader, coil: ImageLoader,
@@ -50,7 +49,6 @@ fun bookmarksGroupAD(
selectionController.attachToRecyclerView(item.manga, binding.recyclerView) selectionController.attachToRecyclerView(item.manga, binding.recyclerView)
} }
binding.imageViewCover.newImageRequest(item.manga.coverUrl, item.manga.source)?.run { binding.imageViewCover.newImageRequest(item.manga.coverUrl, item.manga.source)?.run {
referer(item.manga.publicUrl)
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)

View File

@@ -8,20 +8,20 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.ViewGroup
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.core.network.UserAgentInterceptor import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import com.google.android.material.R as materialR
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback { class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityBrowserBinding.inflate(layoutInflater)) setContentView(ActivityBrowserBinding.inflate(layoutInflater))
@@ -31,10 +31,12 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
with(binding.webView.settings) { with(binding.webView.settings) {
javaScriptEnabled = true javaScriptEnabled = true
userAgentString = UserAgentInterceptor.userAgentChrome userAgentString = CommonHeadersInterceptor.userAgentChrome
} }
binding.webView.webViewClient = BrowserClient(this) binding.webView.webViewClient = BrowserClient(this)
binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar) binding.webView.webChromeClient = ProgressChromeClient(binding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(binding.webView)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
if (savedInstanceState != null) { if (savedInstanceState != null) {
return return
} }
@@ -72,6 +74,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
finishAfterTransition() finishAfterTransition()
true true
} }
R.id.action_browser -> { R.id.action_browser -> {
val intent = Intent(Intent.ACTION_VIEW) val intent = Intent(Intent.ACTION_VIEW)
intent.data = Uri.parse(binding.webView.url) intent.data = Uri.parse(binding.webView.url)
@@ -81,15 +84,8 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
} }
true true
} }
else -> super.onOptionsItemSelected(item)
}
override fun onBackPressed() { else -> super.onOptionsItemSelected(item)
if (binding.webView.canGoBack()) {
binding.webView.goBack()
} else {
super.onBackPressed()
}
} }
override fun onPause() { override fun onPause() {
@@ -116,6 +112,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
supportActionBar?.subtitle = subtitle supportActionBar?.subtitle = subtitle
} }
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
binding.appbar.updatePadding( binding.appbar.updatePadding(
top = insets.top, top = insets.top,

View File

@@ -1,8 +1,8 @@
package org.koitharu.kotatsu.browser package org.koitharu.kotatsu.browser
interface BrowserCallback { interface BrowserCallback : OnHistoryChangedListener {
fun onLoadingStateChanged(isLoading: Boolean) fun onLoadingStateChanged(isLoading: Boolean)
fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) fun onTitleChanged(title: CharSequence, subtitle: CharSequence?)
} }

View File

@@ -4,7 +4,7 @@ import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
class BrowserClient(private val callback: BrowserCallback) : WebViewClient() { open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
override fun onPageFinished(webView: WebView, url: String) { override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(webView, url) super.onPageFinished(webView, url)
@@ -20,4 +20,9 @@ class BrowserClient(private val callback: BrowserCallback) : WebViewClient() {
super.onPageCommitVisible(view, url) super.onPageCommitVisible(view, url)
callback.onTitleChanged(view.title.orEmpty(), url) callback.onTitleChanged(view.title.orEmpty(), url)
} }
}
override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
callback.onHistoryChanged()
}
}

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.browser
fun interface OnHistoryChangedListener {
fun onHistoryChanged()
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.browser
import android.webkit.WebView
import androidx.activity.OnBackPressedCallback
class WebViewBackPressedCallback(
private val webView: WebView,
) : OnBackPressedCallback(false), OnHistoryChangedListener {
init {
onHistoryChanged()
}
override fun handleOnBackPressed() {
webView.goBack()
}
override fun onHistoryChanged() {
isEnabled = webView.canGoBack()
}
}

View File

@@ -1,8 +1,14 @@
package org.koitharu.kotatsu.browser.cloudflare package org.koitharu.kotatsu.browser.cloudflare
interface CloudFlareCallback { import org.koitharu.kotatsu.browser.BrowserCallback
interface CloudFlareCallback : BrowserCallback {
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit
fun onPageLoaded() fun onPageLoaded()
fun onCheckPassed() fun onCheckPassed()
} }

View File

@@ -2,8 +2,8 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.koitharu.kotatsu.browser.BrowserClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
private const val CF_CLEARANCE = "cf_clearance" private const val CF_CLEARANCE = "cf_clearance"
@@ -12,22 +12,22 @@ class CloudFlareClient(
private val cookieJar: MutableCookieJar, private val cookieJar: MutableCookieJar,
private val callback: CloudFlareCallback, private val callback: CloudFlareCallback,
private val targetUrl: String, private val targetUrl: String,
) : WebViewClient() { ) : BrowserClient(callback) {
private val oldClearance = getClearance() private val oldClearance = getClearance()
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon) super.onPageStarted(view, url, favicon)
checkClearance() checkClearance()
} }
override fun onPageCommitVisible(view: WebView?, url: String?) { override fun onPageCommitVisible(view: WebView, url: String?) {
super.onPageCommitVisible(view, url) super.onPageCommitVisible(view, url)
callback.onPageLoaded() callback.onPageLoaded()
} }
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(webView: WebView, url: String) {
super.onPageFinished(view, url) super.onPageFinished(webView, url)
callback.onPageLoaded() callback.onPageLoaded()
} }

View File

@@ -8,14 +8,16 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebSettings import android.webkit.WebSettings
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import okhttp3.Headers import okhttp3.Headers
import org.koitharu.kotatsu.base.ui.AlertDialogFragment import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.browser.WebViewBackPressedCallback
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.UserAgentInterceptor import org.koitharu.kotatsu.core.network.CommonHeadersInterceptor
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding import org.koitharu.kotatsu.databinding.FragmentCloudflareBinding
import org.koitharu.kotatsu.utils.ext.stringArgument import org.koitharu.kotatsu.utils.ext.stringArgument
@@ -31,6 +33,8 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
@Inject @Inject
lateinit var cookieJar: MutableCookieJar lateinit var cookieJar: MutableCookieJar
private var onBackPressedCallback: WebViewBackPressedCallback? = null
override fun onInflateView( override fun onInflateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
@@ -44,7 +48,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
cacheMode = WebSettings.LOAD_DEFAULT cacheMode = WebSettings.LOAD_DEFAULT
domStorageEnabled = true domStorageEnabled = true
databaseEnabled = true databaseEnabled = true
userAgentString = arguments?.getString(ARG_UA) ?: UserAgentInterceptor.userAgentChrome userAgentString = arguments?.getString(ARG_UA) ?: CommonHeadersInterceptor.userAgentChrome
} }
binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url.orEmpty()) binding.webView.webViewClient = CloudFlareClient(cookieJar, this, url.orEmpty())
CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(binding.webView, true)
@@ -58,6 +62,7 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
override fun onDestroyView() { override fun onDestroyView() {
binding.webView.stopLoading() binding.webView.stopLoading()
binding.webView.destroy() binding.webView.destroy()
onBackPressedCallback = null
super.onDestroyView() super.onDestroyView()
} }
@@ -65,6 +70,13 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null) return super.onBuildDialog(builder).setNegativeButton(android.R.string.cancel, null)
} }
override fun onDialogCreated(dialog: AlertDialog) {
super.onDialogCreated(dialog)
onBackPressedCallback = WebViewBackPressedCallback(binding.webView).also {
dialog.onBackPressedDispatcher.addCallback(it)
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
binding.webView.onResume() binding.webView.onResume()
@@ -89,6 +101,10 @@ class CloudFlareDialog : AlertDialogFragment<FragmentCloudflareBinding>(), Cloud
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
override fun onHistoryChanged() {
onBackPressedCallback?.onHistoryChanged()
}
companion object { companion object {
const val TAG = "CloudFlareDialog" const val TAG = "CloudFlareDialog"

View File

@@ -85,7 +85,7 @@ interface AppModule {
@Singleton @Singleton
fun provideOkHttpClient( fun provideOkHttpClient(
localStorageManager: LocalStorageManager, localStorageManager: LocalStorageManager,
userAgentInterceptor: UserAgentInterceptor, commonHeadersInterceptor: CommonHeadersInterceptor,
cookieJar: CookieJar, cookieJar: CookieJar,
settings: AppSettings, settings: AppSettings,
): OkHttpClient { ): OkHttpClient {
@@ -98,8 +98,11 @@ interface AppModule {
dns(DoHManager(cache, settings)) dns(DoHManager(cache, settings))
cache(cache) cache(cache)
addInterceptor(GZipInterceptor()) addInterceptor(GZipInterceptor())
addInterceptor(userAgentInterceptor) addInterceptor(commonHeadersInterceptor)
addInterceptor(CloudFlareInterceptor()) addInterceptor(CloudFlareInterceptor())
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build() }.build()
} }

View File

@@ -19,15 +19,28 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.migrations.* import org.koitharu.kotatsu.core.db.migrations.Migration10To11
import org.koitharu.kotatsu.core.db.migrations.Migration11To12
import org.koitharu.kotatsu.core.db.migrations.Migration12To13
import org.koitharu.kotatsu.core.db.migrations.Migration13To14
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
import org.koitharu.kotatsu.core.db.migrations.Migration5To6
import org.koitharu.kotatsu.core.db.migrations.Migration6To7
import org.koitharu.kotatsu.core.db.migrations.Migration7To8
import org.koitharu.kotatsu.core.db.migrations.Migration8To9
import org.koitharu.kotatsu.core.db.migrations.Migration9To10
import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao import org.koitharu.kotatsu.favourites.data.FavouriteCategoriesDao
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.scrobbling.data.ScrobblingDao import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.data.ScrobblingEntity import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackEntity

View File

@@ -1,11 +1,15 @@
package org.koitharu.kotatsu.core.exceptions.resolve package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.collection.ArrayMap import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Headers import okhttp3.Headers
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -17,6 +21,7 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.TaggedActivityResult import org.koitharu.kotatsu.utils.TaggedActivityResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.isSuccess import org.koitharu.kotatsu.utils.isSuccess
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -95,5 +100,21 @@ class ExceptionResolver private constructor(
} }
fun canResolve(e: Throwable) = getResolveStringId(e) != 0 fun canResolve(e: Throwable) = getResolveStringId(e) != 0
fun showDetails(context: Context, e: Throwable) {
val stackTrace = e.stackTraceToString()
val dialog = MaterialAlertDialogBuilder(context)
.setTitle(e.getDisplayMessage(context.resources))
.setMessage(stackTrace)
.setPositiveButton(androidx.preference.R.string.copy) { _, _ ->
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(context.getString(R.string.error), stackTrace),
)
}
.setNegativeButton(R.string.close, null)
.create()
dialog.show()
}
} }
} }

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
@@ -31,6 +32,7 @@ private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5
@Singleton @Singleton
class AppUpdateRepository @Inject constructor( class AppUpdateRepository @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val settings: AppSettings,
private val okHttp: OkHttpClient, private val okHttp: OkHttpClient,
) { ) {
@@ -64,7 +66,7 @@ class AppUpdateRepository @Inject constructor(
val currentVersion = VersionId(BuildConfig.VERSION_NAME) val currentVersion = VersionId(BuildConfig.VERSION_NAME)
val available = getAvailableVersions().asArrayList() val available = getAvailableVersions().asArrayList()
available.sortBy { it.versionId } available.sortBy { it.versionId }
if (currentVersion.isStable) { if (currentVersion.isStable && !settings.isUnstableUpdatesAllowed) {
available.retainAll { it.versionId.isStable } available.retainAll { it.versionId.isStable }
} }
available.maxByOrNull { it.versionId } available.maxByOrNull { it.versionId }

View File

@@ -68,6 +68,12 @@ class FileLogger(
postFlush() postFlush()
} }
inline fun log(messageProducer: () -> String) {
if (isEnabled) {
log(messageProducer())
}
}
suspend fun flush() { suspend fun flush() {
if (!isEnabled) { if (!isEnabled) {
return return

View File

@@ -5,3 +5,7 @@ import javax.inject.Qualifier
@Qualifier @Qualifier
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class TrackerLogger annotation class TrackerLogger
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncLogger

View File

@@ -21,11 +21,20 @@ object LoggersModule {
settings: AppSettings, settings: AppSettings,
) = FileLogger(context, settings, "tracker") ) = FileLogger(context, settings, "tracker")
@Provides
@SyncLogger
fun provideSyncLogger(
@ApplicationContext context: Context,
settings: AppSettings,
) = FileLogger(context, settings, "sync")
@Provides @Provides
@ElementsIntoSet @ElementsIntoSet
fun provideAllLoggers( fun provideAllLoggers(
@TrackerLogger trackerLogger: FileLogger, @TrackerLogger trackerLogger: FileLogger,
@SyncLogger syncLogger: FileLogger,
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf( ): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
trackerLogger, trackerLogger,
syncLogger,
) )
} }

View File

@@ -5,8 +5,8 @@ import android.os.Parcelable
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException // Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 512 // Assume that 512 kb is safe parcel size private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 32 // this is 100% safe private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
class ParcelableManga( class ParcelableManga(
val manga: Manga, val manga: Manga,

View File

@@ -7,9 +7,11 @@ object CommonHeaders {
const val REFERER = "Referer" const val REFERER = "Referer"
const val USER_AGENT = "User-Agent" const val USER_AGENT = "User-Agent"
const val ACCEPT = "Accept" const val ACCEPT = "Accept"
const val CONTENT_TYPE = "Content-Type"
const val CONTENT_DISPOSITION = "Content-Disposition" const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie" const val COOKIE = "Cookie"
const val CONTENT_ENCODING = "Content-Encoding" const val CONTENT_ENCODING = "Content-Encoding"
const val ACCEPT_ENCODING = "Accept-Encoding"
const val AUTHORIZATION = "Authorization" const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl val CACHE_CONTROL_DISABLED: CacheControl

View File

@@ -0,0 +1,85 @@
package org.koitharu.kotatsu.core.network
import android.os.Build
import android.util.Log
import dagger.Lazy
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CommonHeadersInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val source = request.tag(MangaSource::class.java)
val repository = if (source != null) {
mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
} else {
if (BuildConfig.DEBUG) {
Log.w("Http", "Request without source tag: ${request.url}")
}
null
}
val headersBuilder = request.headers.newBuilder()
repository?.headers?.let {
headersBuilder.mergeWith(it, replaceExisting = false)
}
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
headersBuilder[CommonHeaders.USER_AGENT] = userAgentFallback
}
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
headersBuilder.trySet(CommonHeaders.REFERER, "https://${repository.domain}/")
}
val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
}
private fun Headers.Builder.trySet(name: String, value: String) = try {
set(name, value)
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
}
private class ProxyChain(
private val delegate: Interceptor.Chain,
private val request: Request,
) : Interceptor.Chain by delegate {
override fun request(): Request = request
}
companion object {
val userAgentFallback
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
Build.MODEL,
Build.BRAND,
Build.DEVICE,
Locale.getDefault().language,
)
val userAgentChrome
get() = (
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/100.0.4896.127 Mobile Safari/537.36"
).format(
Build.VERSION.RELEASE,
Build.MODEL,
)
}
}

View File

@@ -1,59 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.os.Build
import dagger.Lazy
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UserAgentInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
return chain.proceed(
if (request.header(CommonHeaders.USER_AGENT) == null) {
request.newBuilder()
.addHeader(CommonHeaders.USER_AGENT, getUserAgent(request))
.build()
} else request,
)
}
private fun getUserAgent(request: Request): String {
val source = request.tag(MangaSource::class.java) ?: return userAgent
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository
return repository?.userAgent ?: userAgent
}
companion object {
val userAgent
get() = "Kotatsu/%s (Android %s; %s; %s %s; %s)".format(
BuildConfig.VERSION_NAME,
Build.VERSION.RELEASE,
Build.MODEL,
Build.BRAND,
Build.DEVICE,
Locale.getDefault().language,
) // TODO Decide what to do with this afterwards
val userAgentChrome
get() = (
"Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/100.0.4896.127 Mobile Safari/537.36"
).format(
Build.VERSION.RELEASE,
Build.MODEL,
)
}
}

View File

@@ -6,9 +6,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher import kotlinx.coroutines.MainCoroutineDispatcher
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
@@ -20,13 +22,14 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.utils.ext.processLifecycleScope import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val cache: ContentCache, private val cache: ContentCache,
) : MangaRepository { ) : MangaRepository, Interceptor {
override val source: MangaSource override val source: MangaSource
get() = parser.source get() = parser.source
@@ -40,8 +43,19 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value getConfig().defaultSortOrder = value
} }
val userAgent: String? val domain: String
get() = parser.headers?.get(CommonHeaders.USER_AGENT) get() = parser.domain
val headers: Headers?
get() = parser.headers
override fun intercept(chain: Interceptor.Chain): Response {
return if (parser is Interceptor) {
parser.intercept(chain)
} else {
chain.proceed(chain.request())
}
}
override suspend fun getList(offset: Int, query: String): List<Manga> { override suspend fun getList(offset: Int, query: String): List<Manga> {
return parser.getList(offset, query) return parser.getList(offset, query)

View File

@@ -20,7 +20,6 @@ import okhttp3.Response
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
@@ -53,7 +52,7 @@ class FaviconFetcher(
options.size.height.pxOrElse { FALLBACK_SIZE }, options.size.height.pxOrElse { FALLBACK_SIZE },
) )
val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" } val icon = checkNotNull(favicons.find(sizePx)) { "No favicons found" }
val response = loadIcon(icon.url, repo.userAgent, favicons.referer) val response = loadIcon(icon.url, mangaSource)
val responseBody = response.requireBody() val responseBody = response.requireBody()
val source = writeToDiskCache(responseBody)?.toImageSource() ?: responseBody.toImageSource() val source = writeToDiskCache(responseBody)?.toImageSource() ?: responseBody.toImageSource()
return SourceResult( return SourceResult(
@@ -63,14 +62,11 @@ class FaviconFetcher(
) )
} }
private suspend fun loadIcon(url: String, userAgent: String?, referer: String): Response { private suspend fun loadIcon(url: String, source: MangaSource): Response {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.get() .get()
.header(CommonHeaders.REFERER, referer) .tag(MangaSource::class.java, source)
if (userAgent != null) {
request.header(CommonHeaders.USER_AGENT, userAgent)
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) } options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
val response = okHttpClient.newCall(request.build()).await() val response = okHttpClient.newCall(request.build()).await()

View File

@@ -9,7 +9,6 @@ import androidx.collection.arraySetOf
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
@@ -18,13 +17,12 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.filterToSet
import org.koitharu.kotatsu.utils.ext.getEnumValue import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue import org.koitharu.kotatsu.utils.ext.putEnumValue
import org.koitharu.kotatsu.utils.ext.toUriOrNull import org.koitharu.kotatsu.utils.ext.toUriOrNull
import java.io.File import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
import java.util.Locale import java.util.Locale
@@ -70,8 +68,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val theme: Int val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val isDynamicTheme: Boolean val colorScheme: ColorScheme
get() = DynamicColors.isDynamicColorAvailable() && prefs.getBoolean(KEY_DYNAMIC_THEME, false) get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default)
val isAmoledTheme: Boolean val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false) get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
@@ -172,6 +170,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDynamicShortcutsEnabled: Boolean val isDynamicShortcutsEnabled: Boolean
get() = prefs.getBoolean(KEY_SHORTCUTS, true) get() = prefs.getBoolean(KEY_SHORTCUTS, true)
val isUnstableUpdatesAllowed: Boolean
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
fun isContentPrefetchEnabled(): Boolean { fun isContentPrefetchEnabled(): Boolean {
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER) val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
@@ -186,7 +187,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
var hiddenSources: Set<String> var hiddenSources: Set<String>
get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null) ?: emptySet() get() = prefs.getStringSet(KEY_SOURCES_HIDDEN, null)?.filterToSet { name ->
remoteSources.any { it.name == name }
}.orEmpty()
set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) } set(value) = prefs.edit { putStringSet(KEY_SOURCES_HIDDEN, value) }
val isSourcesSelected: Boolean val isSourcesSelected: Boolean
@@ -206,6 +209,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct() sourcesOrder = (sourcesOrder + sources.map { it.name }).distinct()
} }
var isSourcesGridMode: Boolean
get() = prefs.getBoolean(KEY_SOURCES_GRID, false)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
@@ -260,12 +267,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
fun getDateFormat(format: String = prefs.getString(KEY_DATE_FORMAT, "").orEmpty()): DateFormat =
when (format) {
"" -> DateFormat.getDateInstance(DateFormat.SHORT)
else -> SimpleDateFormat(format, Locale.getDefault())
}
fun getSuggestionsTagsBlacklistRegex(): Regex? { fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) { if (string.isNullOrEmpty()) {
@@ -312,9 +313,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LIST_MODE = "list_mode_2" const val KEY_LIST_MODE = "list_mode_2"
const val KEY_THEME = "theme" const val KEY_THEME = "theme"
const val KEY_DYNAMIC_THEME = "dynamic_theme" const val KEY_COLOR_THEME = "color_theme"
const val KEY_THEME_AMOLED = "amoled_theme" const val KEY_THEME_AMOLED = "amoled_theme"
const val KEY_DATE_FORMAT = "date_format"
const val KEY_SOURCES_ORDER = "sources_order_2" const val KEY_SOURCES_ORDER = "sources_order_2"
const val KEY_SOURCES_HIDDEN = "sources_hidden" const val KEY_SOURCES_HIDDEN = "sources_hidden"
const val KEY_TRAFFIC_WARNING = "traffic_warning" const val KEY_TRAFFIC_WARNING = "traffic_warning"
@@ -358,6 +358,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism" const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown" const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
@@ -376,6 +378,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_APP_LOCALE = "app_locale" const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging" const val KEY_LOGGING_ENABLED = "logging"
const val KEY_LOGS_SHARE = "logs_share" const val KEY_LOGS_SHARE = "logs_share"
const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

View File

@@ -1,8 +1,13 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform
import kotlin.coroutines.CoroutineContext
fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow { fun <T> AppSettings.observeAsFlow(key: String, valueProducer: AppSettings.() -> T) = flow {
var lastValue: T = valueProducer() var lastValue: T = valueProducer()
@@ -33,3 +38,13 @@ fun <T> AppSettings.observeAsLiveData(
} }
} }
} }
fun <T> AppSettings.observeAsStateFlow(
key: String,
scope: CoroutineScope,
valueProducer: AppSettings.() -> T,
): StateFlow<T> = observe().transform {
if (it == key) {
emit(valueProducer())
}
}.stateIn(scope, SharingStarted.Eagerly, valueProducer())

View File

@@ -0,0 +1,45 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import com.google.android.material.color.DynamicColors
import org.koitharu.kotatsu.R
enum class ColorScheme(
@StyleRes val styleResId: Int,
@StringRes val titleResId: Int,
) {
DEFAULT(R.style.Theme_Kotatsu, R.string.system_default),
MONET(R.style.Theme_Kotatsu_Monet, R.string.theme_name_dynamic),
MIKU(R.style.Theme_Kotatsu_Miku, R.string.theme_name_miku),
RENA(R.style.Theme_Kotatsu_Asuka, R.string.theme_name_asuka),
FROG(R.style.Theme_Kotatsu_Mion, R.string.theme_name_mion),
BLUEBERRY(R.style.Theme_Kotatsu_Rikka, R.string.theme_name_rikka),
NAME2(R.style.Theme_Kotatsu_Sakura, R.string.theme_name_sakura),
MAMIMI(R.style.Theme_Kotatsu_Mamimi, R.string.theme_name_mamimi),
KANADE(R.style.Theme_Kotatsu_Kanade, R.string.theme_name_kanade)
;
companion object {
val default: ColorScheme
get() = if (DynamicColors.isDynamicColorAvailable()) {
MONET
} else {
DEFAULT
}
fun getAvailableList(): List<ColorScheme> {
val list = enumValues<ColorScheme>().toMutableList()
if (!DynamicColors.isDynamicColorAvailable()) {
list.remove(MONET)
}
return list
}
fun safeValueOf(name: String): ColorScheme? {
return enumValues<ColorScheme>().find { it.name == name }
}
}
}

View File

@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.daysDiff import org.koitharu.kotatsu.utils.ext.daysDiff
import org.koitharu.kotatsu.utils.ext.format import org.koitharu.kotatsu.utils.ext.format
import java.util.* import java.util.Date
sealed class DateTimeAgo : ListModel { sealed class DateTimeAgo : ListModel {
@@ -17,6 +17,8 @@ sealed class DateTimeAgo : ListModel {
} }
override fun toString() = "just_now" override fun toString() = "just_now"
override fun equals(other: Any?): Boolean = other === JustNow
} }
class MinutesAgo(val minutes: Int) : DateTimeAgo() { class MinutesAgo(val minutes: Int) : DateTimeAgo() {
@@ -60,6 +62,8 @@ sealed class DateTimeAgo : ListModel {
} }
override fun toString() = "today" override fun toString() = "today"
override fun equals(other: Any?): Boolean = other === Today
} }
object Yesterday : DateTimeAgo() { object Yesterday : DateTimeAgo() {
@@ -68,6 +72,8 @@ sealed class DateTimeAgo : ListModel {
} }
override fun toString() = "yesterday" override fun toString() = "yesterday"
override fun equals(other: Any?): Boolean = other === Yesterday
} }
class DaysAgo(val days: Int) : DateTimeAgo() { class DaysAgo(val days: Int) : DateTimeAgo() {
@@ -119,5 +125,7 @@ sealed class DateTimeAgo : ListModel {
} }
override fun toString() = "long_ago" override fun toString() = "long_ago"
override fun equals(other: Any?): Boolean = other === LongAgo
} }
} }

View File

@@ -96,7 +96,7 @@ class ChaptersFragment :
return when (item.itemId) { return when (item.itemId) {
R.id.action_save -> { R.id.action_save -> {
DownloadService.start( DownloadService.start(
context ?: return false, binding.recyclerViewChapters,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false, viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionController?.snapshot(), selectionController?.snapshot(),
) )

View File

@@ -277,7 +277,7 @@ class DetailsActivity :
) )
} }
setNeutralButton(R.string.download) { _, _ -> setNeutralButton(R.string.download) { _, _ ->
DownloadService.start(this@DetailsActivity, remoteManga, setOf(chapterId)) DownloadService.start(binding.appbar, remoteManga, setOf(chapterId))
} }
setCancelable(true) setCancelable(true)
}.show() }.show()

View File

@@ -40,7 +40,7 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.utils.FileSize import org.koitharu.kotatsu.utils.FileSize
@@ -50,7 +50,6 @@ import org.koitharu.kotatsu.utils.ext.drawableTop
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import org.koitharu.kotatsu.utils.ext.measureHeight import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.resolveDp import org.koitharu.kotatsu.utils.ext.resolveDp
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
@@ -343,7 +342,6 @@ class DetailsFragment :
.data(imageUrl) .data(imageUrl)
.tag(manga.source) .tag(manga.source)
.crossfade(context) .crossfade(context)
.referer(manga.publicUrl)
.lifecycle(viewLifecycleOwner) .lifecycle(viewLifecycleOwner)
.placeholderMemoryCacheKey(manga.coverUrl) .placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable val previousDrawable = lastResult?.drawable

View File

@@ -21,7 +21,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesB
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper import org.koitharu.kotatsu.utils.ShareHelper
@@ -42,7 +42,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity) menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
menu.findItem(R.id.action_shiki_track).isVisible = viewModel.isScrobblingAvailable menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_favourite).setIcon( menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline, if (viewModel.favouriteCategories.value == true) R.drawable.ic_heart else R.drawable.ic_heart_outline,
) )
@@ -60,11 +60,13 @@ class DetailsMenuProvider(
} }
} }
} }
R.id.action_favourite -> { R.id.action_favourite -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
FavouriteCategoriesBottomSheet.show(activity.supportFragmentManager, it) FavouriteCategoriesBottomSheet.show(activity.supportFragmentManager, it)
} }
} }
R.id.action_delete -> { R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty() val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(activity) MaterialAlertDialogBuilder(activity)
@@ -76,6 +78,7 @@ class DetailsMenuProvider(
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
R.id.action_save -> { R.id.action_save -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0 val chaptersCount = it.chapters?.size ?: 0
@@ -83,25 +86,29 @@ class DetailsMenuProvider(
if (chaptersCount > 5 || branches.size > 1) { if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches) showSaveConfirmation(it, chaptersCount, branches)
} else { } else {
DownloadService.start(activity, it) DownloadService.start(snackbarHost, it)
} }
} }
} }
R.id.action_browser -> { R.id.action_browser -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.title)) activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.title))
} }
} }
R.id.action_related -> { R.id.action_related -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title)) activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))
} }
} }
R.id.action_shiki_track -> {
R.id.action_scrobbling -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it) ScrobblingSelectorBottomSheet.show(activity.supportFragmentManager, it, null)
} }
} }
R.id.action_shortcut -> { R.id.action_shortcut -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
activity.lifecycleScope.launch { activity.lifecycleScope.launch {
@@ -112,6 +119,7 @@ class DetailsMenuProvider(
} }
} }
} }
else -> return false else -> return false
} }
return true return true
@@ -132,7 +140,7 @@ class DetailsMenuProvider(
val chaptersIds = manga.chapters?.mapNotNullToSet { c -> val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null if (c.branch in selectedBranches) c.id else null
} }
DownloadService.start(activity, manga, chaptersIds) DownloadService.start(snackbarHost, manga, chaptersIds)
} }
} else { } else {
dialogBuilder.setMessage( dialogBuilder.setMessage(
@@ -141,7 +149,7 @@ class DetailsMenuProvider(
activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount), activity.resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount),
), ),
).setPositiveButton(R.string.save) { _, _ -> ).setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(activity, manga) DownloadService.start(snackbarHost, manga)
} }
} }
dialogBuilder.show() dialogBuilder.show()

View File

@@ -45,9 +45,9 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
@@ -72,7 +72,6 @@ class DetailsViewModel @AssistedInject constructor(
private val delegate = MangaDetailsDelegate( private val delegate = MangaDetailsDelegate(
intent = intent, intent = intent,
settings = settings,
mangaDataRepository = mangaDataRepository, mangaDataRepository = mangaDataRepository,
historyRepository = historyRepository, historyRepository = historyRepository,
localMangaRepository = localMangaRepository, localMangaRepository = localMangaRepository,
@@ -256,28 +255,24 @@ class DetailsViewModel @AssistedInject constructor(
} }
} }
fun updateScrobbling(rating: Float, status: ScrobblingStatus?) { fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) {
for (scrobbler in scrobblers) { val scrobbler = getScrobbler(index) ?: return
if (!scrobbler.isAvailable) continue launchJob(Dispatchers.Default) {
launchJob(Dispatchers.Default) { scrobbler.updateScrobblingInfo(
scrobbler.updateScrobblingInfo( mangaId = delegate.mangaId,
mangaId = delegate.mangaId, rating = rating,
rating = rating, status = status,
status = status, comment = null,
comment = null, )
)
}
} }
} }
fun unregisterScrobbling() { fun unregisterScrobbling(index: Int) {
for (scrobbler in scrobblers) { val scrobbler = getScrobbler(index) ?: return
if (!scrobbler.isAvailable) continue launchJob(Dispatchers.Default) {
launchJob(Dispatchers.Default) { scrobbler.unregisterScrobbling(
scrobbler.unregisterScrobbling( mangaId = delegate.mangaId,
mangaId = delegate.mangaId, )
)
}
} }
} }
@@ -314,6 +309,19 @@ class DetailsViewModel @AssistedInject constructor(
return spannable.trim() return spannable.trim()
} }
private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value?.getOrNull(index)
val scrobbler = if (info != null) {
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable }
} else {
null
}
if (scrobbler == null) {
errorEvent.call(IllegalStateException("Scrobbler [$index] is not available"))
}
return scrobbler
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {

View File

@@ -7,7 +7,6 @@ import org.koitharu.kotatsu.base.domain.MangaIntent
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
@@ -21,7 +20,6 @@ import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
class MangaDetailsDelegate( class MangaDetailsDelegate(
private val intent: MangaIntent, private val intent: MangaIntent,
private val settings: AppSettings,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
@@ -82,7 +80,6 @@ class MangaDetailsDelegate(
branch: String?, branch: String?,
): List<ChapterListItem> { ): List<ChapterListItem> {
val result = ArrayList<ChapterListItem>(chapters.size) val result = ArrayList<ChapterListItem>(chapters.size)
val dateFormat = settings.getDateFormat()
val currentIndex = chapters.indexOfFirst { it.id == currentId } val currentIndex = chapters.indexOfFirst { it.id == currentId }
val firstNewIndex = chapters.size - newCount val firstNewIndex = chapters.size - newCount
val downloadedIds = downloadedChapters?.mapTo(HashSet(downloadedChapters.size)) { it.id } val downloadedIds = downloadedChapters?.mapTo(HashSet(downloadedChapters.size)) { it.id }
@@ -97,7 +94,6 @@ class MangaDetailsDelegate(
isNew = i >= firstNewIndex, isNew = i >= firstNewIndex,
isMissing = false, isMissing = false,
isDownloaded = downloadedIds?.contains(chapter.id) == true, isDownloaded = downloadedIds?.contains(chapter.id) == true,
dateFormat = dateFormat,
) )
} }
if (result.size < chapters.size / 2) { if (result.size < chapters.size / 2) {
@@ -117,7 +113,6 @@ class MangaDetailsDelegate(
val result = ArrayList<ChapterListItem>(sourceChapters.size) val result = ArrayList<ChapterListItem>(sourceChapters.size)
val currentIndex = sourceChapters.indexOfFirst { it.id == currentId } val currentIndex = sourceChapters.indexOfFirst { it.id == currentId }
val firstNewIndex = sourceChapters.size - newCount val firstNewIndex = sourceChapters.size - newCount
val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) { for (i in sourceChapters.indices) {
val chapter = sourceChapters[i] val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id) val localChapter = chaptersMap.remove(chapter.id)
@@ -130,14 +125,12 @@ class MangaDetailsDelegate(
isNew = i >= firstNewIndex, isNew = i >= firstNewIndex,
isMissing = false, isMissing = false,
isDownloaded = false, isDownloaded = false,
dateFormat = dateFormat,
) ?: chapter.toListItem( ) ?: chapter.toListItem(
isCurrent = i == currentIndex, isCurrent = i == currentIndex,
isUnread = i > currentIndex, isUnread = i > currentIndex,
isNew = i >= firstNewIndex, isNew = i >= firstNewIndex,
isMissing = true, isMissing = true,
isDownloaded = false, isDownloaded = false,
dateFormat = dateFormat,
) )
} }
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
@@ -150,7 +143,6 @@ class MangaDetailsDelegate(
isNew = false, isNew = false,
isMissing = false, isMissing = false,
isDownloaded = false, isDownloaded = false,
dateFormat = dateFormat,
) )
} else { } else {
null null

View File

@@ -1,21 +1,24 @@
package org.koitharu.kotatsu.details.ui.model package org.koitharu.kotatsu.details.ui.model
import java.text.DateFormat import android.text.format.DateUtils
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
class ChapterListItem( class ChapterListItem(
val chapter: MangaChapter, val chapter: MangaChapter,
val flags: Int, val flags: Int,
private val uploadDateMs: Long, private val uploadDateMs: Long,
private val dateFormat: DateFormat,
) { ) {
var uploadDate: String? = null var uploadDate: CharSequence? = null
private set private set
get() { get() {
if (field != null) return field if (field != null) return field
if (uploadDateMs == 0L) return null if (uploadDateMs == 0L) return null
field = dateFormat.format(uploadDateMs) field = DateUtils.getRelativeTimeSpanString(
uploadDateMs,
System.currentTimeMillis(),
DateUtils.DAY_IN_MILLIS,
)
return field return field
} }
@@ -44,7 +47,6 @@ class ChapterListItem(
if (chapter != other.chapter) return false if (chapter != other.chapter) return false
if (flags != other.flags) return false if (flags != other.flags) return false
if (uploadDateMs != other.uploadDateMs) return false if (uploadDateMs != other.uploadDateMs) return false
if (dateFormat != other.dateFormat) return false
return true return true
} }
@@ -53,7 +55,6 @@ class ChapterListItem(
var result = chapter.hashCode() var result = chapter.hashCode()
result = 31 * result + flags result = 31 * result + flags
result = 31 * result + uploadDateMs.hashCode() result = 31 * result + uploadDateMs.hashCode()
result = 31 * result + dateFormat.hashCode()
return result return result
} }

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.details.ui.model package org.koitharu.kotatsu.details.ui.model
import java.text.DateFormat
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_MISSING
@@ -14,7 +13,6 @@ fun MangaChapter.toListItem(
isNew: Boolean, isNew: Boolean,
isMissing: Boolean, isMissing: Boolean,
isDownloaded: Boolean, isDownloaded: Boolean,
dateFormat: DateFormat,
): ChapterListItem { ): ChapterListItem {
var flags = 0 var flags = 0
if (isCurrent) flags = flags or FLAG_CURRENT if (isCurrent) flags = flags or FLAG_CURRENT
@@ -26,6 +24,5 @@ fun MangaChapter.toListItem(
chapter = this, chapter = this,
flags = flags, flags = flags,
uploadDateMs = uploadDate, uploadDateMs = uploadDate,
dateFormat = dateFormat,
) )
} }

View File

@@ -6,7 +6,7 @@ import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest

View File

@@ -15,18 +15,21 @@ import androidx.core.net.toUri
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import coil.ImageLoader import coil.ImageLoader
import coil.request.ImageRequest
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingStatus import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.ui.selector.ScrobblingSelectorBottomSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorBottomSheet
import org.koitharu.kotatsu.utils.ext.* import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ScrobblingInfoBottomSheet : class ScrobblingInfoBottomSheet :
@@ -41,6 +44,7 @@ class ScrobblingInfoBottomSheet :
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
private var menu: PopupMenu? = null private var menu: PopupMenu? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -78,6 +82,7 @@ class ScrobblingInfoBottomSheet :
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
viewModel.updateScrobbling( viewModel.updateScrobbling(
index = scrobblerIndex,
rating = binding.ratingBar.rating / binding.ratingBar.numStars, rating = binding.ratingBar.rating / binding.ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(position), status = enumValues<ScrobblingStatus>().getOrNull(position),
) )
@@ -88,6 +93,7 @@ class ScrobblingInfoBottomSheet :
override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) { override fun onRatingChanged(ratingBar: RatingBar, rating: Float, fromUser: Boolean) {
if (fromUser) { if (fromUser) {
viewModel.updateScrobbling( viewModel.updateScrobbling(
index = scrobblerIndex,
rating = rating / ratingBar.numStars, rating = rating / ratingBar.numStars,
status = enumValues<ScrobblingStatus>().getOrNull(binding.spinnerStatus.selectedItemPosition), status = enumValues<ScrobblingStatus>().getOrNull(binding.spinnerStatus.selectedItemPosition),
) )
@@ -115,6 +121,8 @@ class ScrobblingInfoBottomSheet :
binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars binding.ratingBar.rating = scrobbling.rating * binding.ratingBar.numStars
binding.textViewDescription.text = scrobbling.description binding.textViewDescription.text = scrobbling.description
binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1) binding.spinnerStatus.setSelection(scrobbling.status?.ordinal ?: -1)
binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
binding.imageViewCover.newImageRequest(scrobbling.coverUrl)?.apply { binding.imageViewCover.newImageRequest(scrobbling.coverUrl)?.apply {
lifecycle(viewLifecycleOwner) lifecycle(viewLifecycleOwner)
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
@@ -133,13 +141,16 @@ class ScrobblingInfoBottomSheet :
Intent.createChooser(intent, getString(R.string.open_in_browser)), Intent.createChooser(intent, getString(R.string.open_in_browser)),
) )
} }
R.id.action_unregister -> { R.id.action_unregister -> {
viewModel.unregisterScrobbling() viewModel.unregisterScrobbling(scrobblerIndex)
dismiss() dismiss()
} }
R.id.action_edit -> { R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false val manga = viewModel.manga.value ?: return false
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga) val scrobblerService = viewModel.scrobblingInfo.value?.getOrNull(scrobblerIndex)?.scrobbler
ScrobblingSelectorBottomSheet.show(parentFragmentManager, manga, scrobblerService)
dismiss() dismiss()
} }
} }

View File

@@ -5,7 +5,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.scrobbling.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
class ScrollingInfoAdapter( class ScrollingInfoAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
@@ -27,7 +27,7 @@ class ScrollingInfoAdapter(
return oldItem == newItem return oldItem == newItem
} }
override fun getChangePayload(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Any? { override fun getChangePayload(oldItem: ScrobblingInfo, newItem: ScrobblingInfo): Any {
return Unit return Unit
} }
} }

View File

@@ -38,7 +38,6 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.copyToSuspending import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.progress.PausingProgressJob import org.koitharu.kotatsu.utils.progress.PausingProgressJob
import java.io.File import java.io.File
@@ -220,7 +219,7 @@ class DownloadManager @AssistedInject constructor(
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.header(CommonHeaders.REFERER, referer) .header(CommonHeaders.REFERER, referer)
.tag(source) .tag(MangaSource::class.java, source)
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.get() .get()
.build() .build()
@@ -250,7 +249,6 @@ class DownloadManager @AssistedInject constructor(
imageLoader.execute( imageLoader.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(manga.coverUrl) .data(manga.coverUrl)
.referer(manga.publicUrl)
.tag(manga.source) .tag(manga.source)
.size(coverWidth, coverHeight) .size(coverWidth, coverHeight)
.scale(Scale.FILL) .scale(Scale.FILL)

View File

@@ -9,6 +9,13 @@ sealed interface DownloadState {
val manga: Manga val manga: Manga
val cover: Drawable? val cover: Drawable?
override fun equals(other: Any?): Boolean
override fun hashCode(): Int
val isTerminal: Boolean
get() = this is Done || this is Cancelled || (this is Error && !canRetry)
class Queued( class Queued(
override val startId: Int, override val startId: Int,
override val manga: Manga, override val manga: Manga,

View File

@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.onFirst import org.koitharu.kotatsu.utils.ext.onFirst
import org.koitharu.kotatsu.utils.ext.referer
fun downloadItemAD( fun downloadItemAD(
scope: CoroutineScope, scope: CoroutineScope,
@@ -45,7 +44,6 @@ fun downloadItemAD(
job?.cancel() job?.cancel()
job = item.progressAsFlow().onFirst { state -> job = item.progressAsFlow().onFirst { state ->
binding.imageViewCover.newImageRequest(state.manga.coverUrl, state.manga.source)?.run { binding.imageViewCover.newImageRequest(state.manga.coverUrl, state.manga.source)?.run {
referer(state.manga.publicUrl)
placeholder(state.cover) placeholder(state.cover)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)

View File

@@ -1,27 +1,19 @@
package org.koitharu.kotatsu.download.ui package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
import org.koitharu.kotatsu.download.ui.service.DownloadService import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() { class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@@ -29,6 +21,8 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
private lateinit var serviceConnection: DownloadsConnection
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) setContentView(ActivityDownloadsBinding.inflate(layoutInflater))
@@ -38,9 +32,12 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing)) binding.recyclerView.addItemDecoration(SpacingItemDecoration(spacing))
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val connection = DownloadServiceConnection(adapter) serviceConnection = DownloadsConnection(this, this)
bindService(Intent(this, DownloadService::class.java), connection, 0) serviceConnection.items.observe(this) { items ->
lifecycle.addObserver(connection) adapter.items = items
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
serviceConnection.bind()
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
@@ -55,46 +52,6 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>() {
) )
} }
private inner class DownloadServiceConnection(
private val adapter: DownloadsAdapter,
) : ServiceConnection, DefaultLifecycleObserver {
private var collectJob: Job? = null
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleScope.launch {
binder.downloads.collect {
setItems(it)
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
setItems(null)
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
collectJob?.cancel()
collectJob = null
owner.lifecycle.removeObserver(this)
unbindService(this)
}
private fun setItems(items: Collection<DownloadItem>?) {
adapter.items = items?.toList().orEmpty()
binding.textViewHolder.isVisible = items.isNullOrEmpty()
}
}
companion object { companion object {
fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java) fun newIntent(context: Context) = Intent(context, DownloadsActivity::class.java)

View File

@@ -0,0 +1,76 @@
package org.koitharu.kotatsu.download.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
class DownloadsConnection(
private val context: Context,
private val lifecycleOwner: LifecycleOwner,
) : ServiceConnection {
private var bindingObserver: BindingLifecycleObserver? = null
private var collectJob: Job? = null
private val itemsFlow = MutableStateFlow<List<PausingProgressJob<DownloadState>>>(emptyList())
val items
get() = itemsFlow.asFlowLiveData()
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
collectJob?.cancel()
val binder = (service as? DownloadService.DownloadBinder)
collectJob = if (binder == null) {
null
} else {
lifecycleOwner.lifecycleScope.launch {
binder.downloads.collect {
itemsFlow.value = it
}
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
collectJob?.cancel()
collectJob = null
itemsFlow.value = itemsFlow.value.filter { it.progressValue.isTerminal }
}
fun bind() {
if (bindingObserver != null) {
return
}
bindingObserver = BindingLifecycleObserver().also {
lifecycleOwner.lifecycle.addObserver(it)
}
context.bindService(Intent(context, DownloadService::class.java), this, 0)
}
fun unbind() {
bindingObserver?.let {
lifecycleOwner.lifecycle.removeObserver(it)
}
bindingObserver = null
context.unbindService(this)
}
private inner class BindingLifecycleObserver : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
unbind()
}
}
}

View File

@@ -7,7 +7,7 @@ import android.content.IntentFilter
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import android.widget.Toast import android.view.View
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -15,6 +15,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,6 +30,7 @@ import org.koitharu.kotatsu.base.ui.BaseService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.domain.DownloadManager import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.throttle import org.koitharu.kotatsu.utils.ext.throttle
@@ -66,7 +68,7 @@ class DownloadService : BaseService() {
val intentFilter = IntentFilter() val intentFilter = IntentFilter()
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL) intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
intentFilter.addAction(ACTION_DOWNLOAD_RESUME) intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
registerReceiver(controlReceiver, intentFilter) ContextCompat.registerReceiver(this, controlReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -155,9 +157,6 @@ class DownloadService : BaseService() {
!state.isTerminal !state.isTerminal
} }
private val DownloadState.isTerminal: Boolean
get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry)
@MainThread @MainThread
private fun stopSelfIfIdle() { private fun stopSelfIfIdle() {
if (jobs.any { (_, job) -> job.isActive }) { if (jobs.any { (_, job) -> job.isActive }) {
@@ -221,37 +220,38 @@ class DownloadService : BaseService() {
private const val EXTRA_CHAPTERS_IDS = "chapters_ids" private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
private const val EXTRA_CANCEL_ID = "cancel_id" private const val EXTRA_CANCEL_ID = "cancel_id"
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>? = null) { fun start(view: View, manga: Manga, chaptersIds: Collection<Long>? = null) {
if (chaptersIds?.isEmpty() == true) { if (chaptersIds?.isEmpty() == true) {
return return
} }
val intent = Intent(context, DownloadService::class.java) val intent = Intent(view.context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false)) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
if (chaptersIds != null) { if (chaptersIds != null) {
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray()) intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
} }
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(view.context, intent)
Toast.makeText(context, R.string.manga_downloading_, Toast.LENGTH_SHORT).show() showStartedSnackbar(view)
} }
fun start(context: Context, manga: Collection<Manga>) { fun start(view: View, manga: Collection<Manga>) {
if (manga.isEmpty()) { if (manga.isEmpty()) {
return return
} }
for (item in manga) { for (item in manga) {
val intent = Intent(context, DownloadService::class.java) val intent = Intent(view.context, DownloadService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false)) intent.putExtra(EXTRA_MANGA, ParcelableManga(item, withChapters = false))
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(view.context, intent)
} }
showStartedSnackbar(view)
} }
fun confirmAndStart(context: Context, items: Set<Manga>) { fun confirmAndStart(view: View, items: Set<Manga>) {
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(view.context)
.setTitle(R.string.save_manga) .setTitle(R.string.save_manga)
.setMessage(R.string.batch_manga_save_confirm) .setMessage(R.string.batch_manga_save_confirm)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ -> .setPositiveButton(R.string.save) { _, _ ->
start(context, items) start(view, items)
}.show() }.show()
} }
@@ -267,5 +267,12 @@ class DownloadService : BaseService() {
} }
return null return null
} }
private fun showStartedSnackbar(view: View) {
Snackbar.make(view, R.string.download_started, Snackbar.LENGTH_LONG)
.setAction(R.string.details) {
it.context.startActivity(DownloadsActivity.newIntent(it.context))
}.show()
}
} }
} }

View File

@@ -9,6 +9,8 @@ import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@@ -19,6 +21,7 @@ import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.base.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -33,6 +36,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject import javax.inject.Inject
@@ -63,15 +67,18 @@ class ExploreFragment :
with(binding.recyclerView) { with(binding.recyclerView) {
adapter = exploreAdapter adapter = exploreAdapter
setHasFixedSize(true) setHasFixedSize(true)
SpanSizeResolver(this, resources.getDimensionPixelSize(R.dimen.explore_grid_width)).attach()
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing) val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
paddingHorizontal = spacing paddingHorizontal = spacing
} }
addMenuProvider(ExploreMenuProvider(view.context, viewModel))
viewModel.content.observe(viewLifecycleOwner) { viewModel.content.observe(viewLifecycleOwner) {
exploreAdapter?.items = it exploreAdapter?.items = it
} }
viewModel.onError.observe(viewLifecycleOwner, ::onError) viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -149,6 +156,17 @@ class ExploreFragment :
snackbar.show() snackbar.show()
} }
private fun onGridModeChanged(isGrid: Boolean) {
binding.recyclerView.layoutManager = if (isGrid) {
GridLayoutManager(requireContext(), 4).also { lm ->
lm.spanSizeLookup = ExploreGridSpanSizeLookup(checkNotNull(exploreAdapter), lm)
}
} else {
LinearLayoutManager(requireContext())
}
activity?.invalidateOptionsMenu()
}
private inner class SourceMenuListener( private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source, private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener { ) : PopupMenu.OnMenuItemClickListener {

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.explore.ui
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
class ExploreGridSpanSizeLookup(
private val adapter: ExploreAdapter,
private val layoutManager: GridLayoutManager,
) : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val itemType = adapter.getItemViewType(position)
return if (itemType == ExploreAdapter.ITEM_TYPE_SOURCE_GRID) 1 else layoutManager.spanCount
}
}

View File

@@ -0,0 +1,34 @@
package org.koitharu.kotatsu.explore.ui
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
class ExploreMenuProvider(
private val context: Context,
private val viewModel: ExploreViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_explore, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_grid -> {
viewModel.setGridMode(!menuItem.isChecked)
true
}
else -> false
}
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_grid)?.isChecked = viewModel.isGrid.value == true
}
}

View File

@@ -5,22 +5,26 @@ import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject import javax.inject.Inject
@@ -30,8 +34,15 @@ class ExploreViewModel @Inject constructor(
private val exploreRepository: ExploreRepository, private val exploreRepository: ExploreRepository,
) : BaseViewModel() { ) : BaseViewModel() {
private val gridMode = settings.observeAsStateFlow(
key = AppSettings.KEY_SOURCES_GRID,
scope = viewModelScope + Dispatchers.IO,
valueProducer = { isSourcesGridMode },
)
val onOpenManga = SingleLiveEvent<Manga>() val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = SingleLiveEvent<ReversibleAction>()
val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading -> val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
if (loading) { if (loading) {
@@ -58,6 +69,10 @@ class ExploreViewModel @Inject constructor(
} }
} }
fun setGridMode(value: Boolean) {
settings.isSourcesGridMode = value
}
private fun createContentFlow() = settings.observe() private fun createContentFlow() = settings.observe()
.filter { .filter {
it == AppSettings.KEY_SOURCES_HIDDEN || it == AppSettings.KEY_SOURCES_HIDDEN ||
@@ -67,16 +82,16 @@ class ExploreViewModel @Inject constructor(
.onStart { emit("") } .onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) } .map { settings.getMangaSources(includeHidden = false) }
.distinctUntilChanged() .distinctUntilChanged()
.map { buildList(it) } .combine(gridMode) { content, grid -> buildList(content, grid) }
private fun buildList(sources: List<MangaSource>): List<ExploreItem> { private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> {
val result = ArrayList<ExploreItem>(sources.size + 3) val result = ArrayList<ExploreItem>(sources.size + 3)
result += ExploreItem.Buttons( result += ExploreItem.Buttons(
isSuggestionsEnabled = settings.isSuggestionsEnabled, isSuggestionsEnabled = settings.isSuggestionsEnabled,
) )
result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty()) result += ExploreItem.Header(R.string.remote_sources, sources.isNotEmpty())
if (sources.isNotEmpty()) { if (sources.isNotEmpty()) {
sources.mapTo(result) { ExploreItem.Source(it) } sources.mapTo(result) { ExploreItem.Source(it, isGrid) }
} else { } else {
result += ExploreItem.EmptyHint( result += ExploreItem.EmptyHint(
icon = R.drawable.ic_empty_common, icon = R.drawable.ic_empty_common,

View File

@@ -11,11 +11,25 @@ class ExploreAdapter(
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
listener: ExploreListEventListener, listener: ExploreListEventListener,
clickListener: OnListItemClickListener<ExploreItem.Source>, clickListener: OnListItemClickListener<ExploreItem.Source>,
) : AsyncListDifferDelegationAdapter<ExploreItem>( ) : AsyncListDifferDelegationAdapter<ExploreItem>(ExploreDiffCallback()) {
ExploreDiffCallback(),
exploreButtonsAD(listener), init {
exploreSourcesHeaderAD(listener), delegatesManager
exploreSourceItemAD(coil, clickListener, lifecycleOwner), .addDelegate(ITEM_TYPE_BUTTONS, exploreButtonsAD(listener))
exploreEmptyHintListAD(listener), .addDelegate(ITEM_TYPE_HEADER, exploreSourcesHeaderAD(listener))
exploreLoadingAD(), .addDelegate(ITEM_TYPE_SOURCE_LIST, exploreSourceListItemAD(coil, clickListener, lifecycleOwner))
) .addDelegate(ITEM_TYPE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
.addDelegate(ITEM_TYPE_HINT, exploreEmptyHintListAD(listener))
.addDelegate(ITEM_TYPE_LOADING, exploreLoadingAD())
}
companion object {
const val ITEM_TYPE_BUTTONS = 0
const val ITEM_TYPE_HEADER = 1
const val ITEM_TYPE_SOURCE_LIST = 2
const val ITEM_TYPE_SOURCE_GRID = 3
const val ITEM_TYPE_HINT = 4
const val ITEM_TYPE_LOADING = 5
}
}

View File

@@ -12,7 +12,8 @@ import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceBinding import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding
import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding import org.koitharu.kotatsu.databinding.ItemHeaderButtonBinding
import org.koitharu.kotatsu.explore.ui.model.ExploreItem import org.koitharu.kotatsu.explore.ui.model.ExploreItem
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -25,7 +26,7 @@ import org.koitharu.kotatsu.utils.image.FaviconFallbackDrawable
fun exploreButtonsAD( fun exploreButtonsAD(
clickListener: View.OnClickListener, clickListener: View.OnClickListener,
) = adapterDelegateViewBinding<ExploreItem.Buttons, ExploreItem, ItemExploreButtonsBinding>( ) = adapterDelegateViewBinding<ExploreItem.Buttons, ExploreItem, ItemExploreButtonsBinding>(
{ layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) } { layoutInflater, parent -> ItemExploreButtonsBinding.inflate(layoutInflater, parent, false) },
) { ) {
binding.buttonBookmarks.setOnClickListener(clickListener) binding.buttonBookmarks.setOnClickListener(clickListener)
@@ -43,7 +44,7 @@ fun exploreButtonsAD(
fun exploreSourcesHeaderAD( fun exploreSourcesHeaderAD(
listener: ExploreListEventListener, listener: ExploreListEventListener,
) = adapterDelegateViewBinding<ExploreItem.Header, ExploreItem, ItemHeaderButtonBinding>( ) = adapterDelegateViewBinding<ExploreItem.Header, ExploreItem, ItemHeaderButtonBinding>(
{ layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) } { layoutInflater, parent -> ItemHeaderButtonBinding.inflate(layoutInflater, parent, false) },
) { ) {
val listenerAdapter = View.OnClickListener { val listenerAdapter = View.OnClickListener {
@@ -58,13 +59,44 @@ fun exploreSourcesHeaderAD(
} }
} }
fun exploreSourceItemAD( fun exploreSourceListItemAD(
coil: ImageLoader, coil: ImageLoader,
listener: OnListItemClickListener<ExploreItem.Source>, listener: OnListItemClickListener<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceBinding>( ) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceListBinding>(
{ layoutInflater, parent -> ItemExploreSourceBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source } on = { item, _, _ -> item is ExploreItem.Source && !item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener)
bind {
binding.textViewTitle.text = item.source.title
val fallbackIcon = FaviconFallbackDrawable(context, item.source.name)
binding.imageViewIcon.newImageRequest(item.source.faviconUri(), item.source)?.run {
fallback(fallbackIcon)
placeholder(fallbackIcon)
error(fallbackIcon)
lifecycle(lifecycleOwner)
enqueueWith(coil)
}
}
onViewRecycled {
binding.imageViewIcon.disposeImageRequest()
}
}
fun exploreSourceGridItemAD(
coil: ImageLoader,
listener: OnListItemClickListener<ExploreItem.Source>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<ExploreItem.Source, ExploreItem, ItemExploreSourceGridBinding>(
{ layoutInflater, parent -> ItemExploreSourceGridBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is ExploreItem.Source && item.isGrid },
) { ) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener) val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
@@ -92,7 +124,7 @@ fun exploreSourceItemAD(
fun exploreEmptyHintListAD( fun exploreEmptyHintListAD(
listener: ListStateHolderListener, listener: ListStateHolderListener,
) = adapterDelegateViewBinding<ExploreItem.EmptyHint, ExploreItem, ItemEmptyCardBinding>( ) = adapterDelegateViewBinding<ExploreItem.EmptyHint, ExploreItem, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) } { inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
) { ) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() } binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }

View File

@@ -12,11 +12,13 @@ class ExploreDiffCallback : DiffUtil.ItemCallback<ExploreItem>() {
oldItem is ExploreItem.Loading && newItem is ExploreItem.Loading -> true oldItem is ExploreItem.Loading && newItem is ExploreItem.Loading -> true
oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true oldItem is ExploreItem.EmptyHint && newItem is ExploreItem.EmptyHint -> true
oldItem is ExploreItem.Source && newItem is ExploreItem.Source -> { oldItem is ExploreItem.Source && newItem is ExploreItem.Source -> {
oldItem.source == newItem.source oldItem.source == newItem.source && oldItem.isGrid == newItem.isGrid
} }
oldItem is ExploreItem.Header && newItem is ExploreItem.Header -> { oldItem is ExploreItem.Header && newItem is ExploreItem.Header -> {
oldItem.titleResId == newItem.titleResId oldItem.titleResId == newItem.titleResId
} }
else -> false else -> false
} }
} }
@@ -24,4 +26,4 @@ class ExploreDiffCallback : DiffUtil.ItemCallback<ExploreItem>() {
override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean { override fun areContentsTheSame(oldItem: ExploreItem, newItem: ExploreItem): Boolean {
return oldItem == newItem return oldItem == newItem
} }
} }

View File

@@ -54,6 +54,7 @@ sealed interface ExploreItem : ListModel {
class Source( class Source(
val source: MangaSource, val source: MangaSource,
val isGrid: Boolean,
) : ExploreItem { ) : ExploreItem {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -63,12 +64,15 @@ sealed interface ExploreItem : ListModel {
other as Source other as Source
if (source != other.source) return false if (source != other.source) return false
if (isGrid != other.isGrid) return false
return true return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return source.hashCode() var result = source.hashCode()
result = 31 * result + isGrid.hashCode()
return result
} }
} }
@@ -80,5 +84,8 @@ sealed interface ExploreItem : ListModel {
@StringRes actionStringRes: Int, @StringRes actionStringRes: Int,
) : EmptyState(icon, textPrimary, textSecondary, actionStringRes), ExploreItem ) : EmptyState(icon, textPrimary, textSecondary, actionStringRes), ExploreItem
object Loading : ExploreItem object Loading : ExploreItem {
override fun equals(other: Any?): Boolean = other === Loading
}
} }

View File

@@ -9,6 +9,7 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isVisible import androidx.core.view.isVisible
@@ -19,7 +20,6 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
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 javax.inject.Inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.list.ListSelectionController import org.koitharu.kotatsu.base.ui.list.ListSelectionController
@@ -32,8 +32,8 @@ import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class FavouriteCategoriesActivity : class FavouriteCategoriesActivity :
@@ -47,6 +47,7 @@ class FavouriteCategoriesActivity :
private val viewModel by viewModels<FavouritesCategoriesViewModel>() private val viewModel by viewModels<FavouritesCategoriesViewModel>()
private lateinit var exitReorderModeCallback: ExitReorderModeCallback
private lateinit var adapter: CategoriesAdapter private lateinit var adapter: CategoriesAdapter
private lateinit var selectionController: ListSelectionController private lateinit var selectionController: ListSelectionController
private var reorderHelper: ItemTouchHelper? = null private var reorderHelper: ItemTouchHelper? = null
@@ -55,6 +56,7 @@ class FavouriteCategoriesActivity :
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) setContentView(ActivityCategoriesBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
exitReorderModeCallback = ExitReorderModeCallback(viewModel)
adapter = CategoriesAdapter(coil, this, this, this) adapter = CategoriesAdapter(coil, this, this, this)
selectionController = ListSelectionController( selectionController = ListSelectionController(
activity = this, activity = this,
@@ -67,6 +69,7 @@ class FavouriteCategoriesActivity :
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.fabAdd.setOnClickListener(this) binding.fabAdd.setOnClickListener(this)
onBackPressedDispatcher.addCallback(exitReorderModeCallback)
viewModel.detalizedCategories.observe(this, ::onCategoriesChanged) viewModel.detalizedCategories.observe(this, ::onCategoriesChanged)
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
@@ -90,15 +93,8 @@ class FavouriteCategoriesActivity :
viewModel.setReorderMode(true) viewModel.setReorderMode(true)
true true
} }
else -> super.onOptionsItemSelected(item)
}
}
override fun onBackPressed() { else -> super.onOptionsItemSelected(item)
if (viewModel.isInReorderMode()) {
viewModel.setReorderMode(false)
} else {
super.onBackPressed()
} }
} }
@@ -138,7 +134,7 @@ class FavouriteCategoriesActivity :
} }
binding.root.updatePadding( binding.root.updatePadding(
left = insets.left, left = insets.left,
right = insets.right right = insets.right,
) )
binding.recyclerView.updatePadding( binding.recyclerView.updatePadding(
bottom = insets.bottom, bottom = insets.bottom,
@@ -174,6 +170,7 @@ class FavouriteCategoriesActivity :
binding.recyclerView.isNestedScrollingEnabled = !isReorderMode binding.recyclerView.isNestedScrollingEnabled = !isReorderMode
invalidateOptionsMenu() invalidateOptionsMenu()
binding.buttonDone.isVisible = isReorderMode binding.buttonDone.isVisible = isReorderMode
exitReorderModeCallback.isEnabled = isReorderMode
} }
private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback( private inner class ReorderHelperCallback : ItemTouchHelper.SimpleCallback(
@@ -211,6 +208,15 @@ class FavouriteCategoriesActivity :
override fun isLongPressDragEnabled(): Boolean = false override fun isLongPressDragEnabled(): Boolean = false
} }
private class ExitReorderModeCallback(
private val viewModel: FavouritesCategoriesViewModel,
) : OnBackPressedCallback(viewModel.isInReorderMode()) {
override fun handleOnBackPressed() {
viewModel.setReorderMode(false)
}
}
companion object { companion object {
val SORT_ORDERS = arrayOf( val SORT_ORDERS = arrayOf(

View File

@@ -22,8 +22,8 @@ import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.toMangaHistory import org.koitharu.kotatsu.history.data.toMangaHistory
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.domain.tryScrobble import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject import javax.inject.Inject

View File

@@ -327,7 +327,7 @@ abstract class MangaListFragment :
} }
R.id.action_save -> { R.id.action_save -> {
DownloadService.confirmAndStart(requireContext(), selectedItems) DownloadService.confirmAndStart(binding.recyclerView, selectedItems)
mode.finish() mode.finish()
true true
} }

View File

@@ -12,17 +12,22 @@ import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyStateListAD( fun emptyStateListAD(
coil: ImageLoader, coil: ImageLoader,
listener: ListStateHolderListener, listener: ListStateHolderListener?,
) = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>( ) = adapterDelegateViewBinding<EmptyState, ListModel, ItemEmptyStateBinding>(
{ inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) }, { inflater, parent -> ItemEmptyStateBinding.inflate(inflater, parent, false) },
) { ) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
if (listener != null) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
}
bind { bind {
binding.icon.newImageRequest(item.icon)?.enqueueWith(coil) binding.icon.newImageRequest(item.icon)?.enqueueWith(coil)
binding.textPrimary.setText(item.textPrimary) binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary) binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes) if (listener != null) {
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
}
} }
onViewRecycled { onViewRecycled {

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.image.CoverSizeResolver import org.koitharu.kotatsu.utils.image.CoverSizeResolver
fun mangaGridItemAD( fun mangaGridItemAD(
@@ -40,7 +39,6 @@ fun mangaGridItemAD(
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run { binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
referer(item.manga.publicUrl)
size(CoverSizeResolver(binding.imageViewCover)) size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)

View File

@@ -17,7 +17,6 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
import org.koitharu.kotatsu.utils.image.CoverSizeResolver import org.koitharu.kotatsu.utils.image.CoverSizeResolver
@@ -53,7 +52,6 @@ fun mangaListDetailedItemAD(
binding.textViewSubtitle.textAndVisible = item.subtitle binding.textViewSubtitle.textAndVisible = item.subtitle
binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads) binding.progressView.setPercent(item.progress, MangaListAdapter.PAYLOAD_PROGRESS in payloads)
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run { binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
referer(item.manga.publicUrl)
size(CoverSizeResolver(binding.imageViewCover)) size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)

View File

@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.disposeImageRequest import org.koitharu.kotatsu.utils.ext.disposeImageRequest
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.newImageRequest import org.koitharu.kotatsu.utils.ext.newImageRequest
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.textAndVisible import org.koitharu.kotatsu.utils.ext.textAndVisible
fun mangaListItemAD( fun mangaListItemAD(
@@ -36,7 +35,6 @@ fun mangaListItemAD(
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title
binding.textViewSubtitle.textAndVisible = item.subtitle binding.textViewSubtitle.textAndVisible = item.subtitle
binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run { binding.imageViewCover.newImageRequest(item.coverUrl, item.source)?.run {
referer(item.manga.publicUrl)
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) error(R.drawable.ic_error_placeholder)

View File

@@ -1,34 +1,29 @@
package org.koitharu.kotatsu.list.ui.filter package org.koitharu.kotatsu.list.ui.filter
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.* import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.util.CollapseActionViewCallback
import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import org.koitharu.kotatsu.utils.ext.isScrolledToTop
import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels import org.koitharu.kotatsu.utils.ext.parentFragmentViewModels
class FilterBottomSheet : class FilterBottomSheet :
BaseBottomSheet<SheetFilterBinding>(), BaseBottomSheet<SheetFilterBinding>(),
MenuItem.OnActionExpandListener, MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener, SearchView.OnQueryTextListener,
DialogInterface.OnKeyListener,
AsyncListDiffer.ListListener<FilterItem> { AsyncListDiffer.ListListener<FilterItem> {
private val viewModel by parentFragmentViewModels<RemoteListViewModel>() private val viewModel by parentFragmentViewModels<RemoteListViewModel>()
private var collapsibleActionViewCallback: CollapseActionViewCallback? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return super.onCreateDialog(savedInstanceState).also {
it.setOnKeyListener(this)
}
}
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding { override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
return SheetFilterBinding.inflate(inflater, container, false) return SheetFilterBinding.inflate(inflater, container, false)
@@ -42,8 +37,14 @@ class FilterBottomSheet :
initOptionsMenu() initOptionsMenu()
} }
override fun onDestroyView() {
super.onDestroyView()
collapsibleActionViewCallback = null
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
setExpanded(isExpanded = true, isLocked = true) setExpanded(isExpanded = true, isLocked = true)
collapsibleActionViewCallback?.onMenuItemActionExpand(item)
return true return true
} }
@@ -51,6 +52,7 @@ class FilterBottomSheet :
val searchView = (item.actionView as? SearchView) ?: return false val searchView = (item.actionView as? SearchView) ?: return false
searchView.setQuery("", false) searchView.setQuery("", false)
searchView.post { setExpanded(isExpanded = false, isLocked = false) } searchView.post { setExpanded(isExpanded = false, isLocked = false) }
collapsibleActionViewCallback?.onMenuItemActionCollapse(item)
return true return true
} }
@@ -61,19 +63,6 @@ class FilterBottomSheet :
return true return true
} }
override fun onKey(dialog: DialogInterface?, keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) {
val menuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search) ?: return false
if (menuItem.isActionViewExpanded) {
if (event?.action == KeyEvent.ACTION_UP) {
menuItem.collapseActionView()
}
return true
}
}
return false
}
override fun onCurrentListChanged(previousList: MutableList<FilterItem>, currentList: MutableList<FilterItem>) { override fun onCurrentListChanged(previousList: MutableList<FilterItem>, currentList: MutableList<FilterItem>) {
if (currentList.size > previousList.size && view != null) { if (currentList.size > previousList.size && view != null) {
(binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0) (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
@@ -81,13 +70,16 @@ class FilterBottomSheet :
} }
private fun initOptionsMenu() { private fun initOptionsMenu() {
binding.headerBar.toolbar.inflateMenu(R.menu.opt_filter) binding.headerBar.inflateMenu(R.menu.opt_filter)
val searchMenuItem = binding.headerBar.toolbar.menu.findItem(R.id.action_search) val searchMenuItem = binding.headerBar.menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this) searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this) searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false) searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title searchView.queryHint = searchMenuItem.title
collapsibleActionViewCallback = CollapseActionViewCallback(searchMenuItem).also {
onBackPressedDispatcher.addCallback(it)
}
} }
companion object { companion object {

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
interface ListModel interface ListModel {
override fun equals(other: Any?): Boolean
}

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
object LoadingFooter : ListModel object LoadingFooter : ListModel {
override fun equals(other: Any?): Boolean = other === LoadingFooter
}

View File

@@ -1,3 +1,6 @@
package org.koitharu.kotatsu.list.ui.model package org.koitharu.kotatsu.list.ui.model
object LoadingState : ListModel object LoadingState : ListModel {
override fun equals(other: Any?): Boolean = other === LoadingState
}

View File

@@ -26,7 +26,6 @@ import org.koitharu.kotatsu.utils.PendingIntentCompat
import org.koitharu.kotatsu.utils.ext.asArrayList import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.report import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import javax.inject.Inject import javax.inject.Inject
@@ -102,7 +101,6 @@ class ImportService : CoroutineIntentService() {
ImageRequest.Builder(applicationContext) ImageRequest.Builder(applicationContext)
.data(manga.coverUrl) .data(manga.coverUrl)
.tag(manga.source) .tag(manga.source)
.referer(manga.publicUrl)
.build(), .build(),
).toBitmapOrNull(), ).toBitmapOrNull(),
) )

View File

@@ -7,6 +7,7 @@ import android.os.Bundle
import android.util.SparseIntArray import android.util.SparseIntArray
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
@@ -84,6 +85,7 @@ class MainActivity :
private val viewModel by viewModels<MainViewModel>() private val viewModel by viewModels<MainViewModel>()
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>() private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback()) private val voiceInputLauncher = registerForActivityResult(VoiceInputContract(), VoiceInputCallback())
private val closeSearchCallback = CloseSearchCallback()
private lateinit var navigationDelegate: MainNavigationDelegate private lateinit var navigationDelegate: MainNavigationDelegate
override val appBar: AppBarLayout override val appBar: AppBarLayout
@@ -117,11 +119,14 @@ class MainActivity :
binding.navRail?.headerView?.setOnClickListener(this) binding.navRail?.headerView?.setOnClickListener(this)
binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null binding.searchView.isVoiceSearchEnabled = voiceInputLauncher.resolve(this, null) != null
onBackPressedDispatcher.addCallback(ExitCallback(this, binding.container))
navigationDelegate = MainNavigationDelegate(checkNotNull(bottomNav ?: binding.navRail), supportFragmentManager) navigationDelegate = MainNavigationDelegate(checkNotNull(bottomNav ?: binding.navRail), supportFragmentManager)
navigationDelegate.addOnFragmentChangedListener(this) navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(savedInstanceState) navigationDelegate.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(ExitCallback(this, binding.container))
onBackPressedDispatcher.addCallback(navigationDelegate)
onBackPressedDispatcher.addCallback(closeSearchCallback)
if (savedInstanceState == null) { if (savedInstanceState == null) {
onFirstStart() onFirstStart()
} }
@@ -140,21 +145,6 @@ class MainActivity :
adjustSearchUI(isSearchOpened(), animate = false) adjustSearchUI(isSearchOpened(), animate = false)
} }
override fun onBackPressed() {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
binding.searchView.clearFocus()
when {
fragment != null -> supportFragmentManager.commit {
setReorderingAllowed(true)
remove(fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchClosed() }
}
else -> super.onBackPressed()
}
}
override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
adjustFabVisibility(topFragment = fragment) adjustFabVisibility(topFragment = fragment)
if (fromUser) { if (fromUser) {
@@ -298,11 +288,13 @@ class MainActivity :
private fun onSearchOpened() { private fun onSearchOpened() {
adjustSearchUI(isOpened = true, animate = true) adjustSearchUI(isOpened = true, animate = true)
closeSearchCallback.isEnabled = true
} }
private fun onSearchClosed() { private fun onSearchClosed() {
binding.searchView.hideKeyboard() binding.searchView.hideKeyboard()
adjustSearchUI(isOpened = false, animate = true) adjustSearchUI(isOpened = false, animate = true)
closeSearchCallback.isEnabled = false
} }
private fun showNav(visible: Boolean) { private fun showNav(visible: Boolean) {
@@ -335,8 +327,10 @@ class MainActivity :
TrackWorker.setup(applicationContext) TrackWorker.setup(applicationContext)
SuggestionsWorker.setup(applicationContext) SuggestionsWorker.setup(applicationContext)
} }
MangaPrefetchService.prefetchLast(this@MainActivity) whenResumed {
requestNotificationsPermission() MangaPrefetchService.prefetchLast(this@MainActivity)
requestNotificationsPermission()
}
} }
} }
@@ -404,4 +398,23 @@ class MainActivity :
} }
} }
} }
private inner class CloseSearchCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val fragment = supportFragmentManager.findFragmentByTag(TAG_SEARCH)
binding.searchView.clearFocus()
if (fragment == null) {
// this should not happen but who knows
isEnabled = false
return
}
supportFragmentManager.commit {
setReorderingAllowed(true)
remove(fragment)
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
runOnCommit { onSearchClosed() }
}
}
}
} }

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.main.ui
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.core.view.iterator import androidx.core.view.iterator
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@@ -21,7 +22,7 @@ private const val TAG_PRIMARY = "primary"
class MainNavigationDelegate( class MainNavigationDelegate(
private val navBar: NavigationBarView, private val navBar: NavigationBarView,
private val fragmentManager: FragmentManager, private val fragmentManager: FragmentManager,
) : NavigationBarView.OnItemSelectedListener, NavigationBarView.OnItemReselectedListener { ) : OnBackPressedCallback(false), NavigationBarView.OnItemSelectedListener, NavigationBarView.OnItemReselectedListener {
private val listeners = LinkedList<OnFragmentChangedListener>() private val listeners = LinkedList<OnFragmentChangedListener>()
@@ -46,6 +47,10 @@ class MainNavigationDelegate(
recyclerView.smoothScrollToPosition(0) recyclerView.smoothScrollToPosition(0)
} }
override fun handleOnBackPressed() {
navBar.selectedItemId = R.id.nav_shelf
}
fun onCreate(savedInstanceState: Bundle?) { fun onCreate(savedInstanceState: Bundle?) {
primaryFragment?.let { primaryFragment?.let {
onFragmentChanged(it, fromUser = false) onFragmentChanged(it, fromUser = false)
@@ -117,6 +122,7 @@ class MainNavigationDelegate(
} }
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
isEnabled = fragment !is ShelfFragment
listeners.forEach { it.onFragmentChanged(fragment, fromUser) } listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
} }

View File

@@ -191,7 +191,7 @@ class PageLoader @Inject constructor(
.header(CommonHeaders.REFERER, page.referer) .header(CommonHeaders.REFERER, page.referer)
.header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8") .header(CommonHeaders.ACCEPT, "image/webp,image/png;q=0.9,image/jpeg,*/*;q=0.8")
.cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED) .cacheControl(CommonHeaders.CACHE_CONTROL_DISABLED)
.tag(page.source) .tag(MangaSource::class.java, page.source)
.build() .build()
okHttp.newCall(request).await().use { response -> okHttp.newCall(request).await().use { response ->
check(response.isSuccessful) { check(response.isSuccessful) {

View File

@@ -6,8 +6,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlin.math.roundToInt
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
@@ -21,6 +19,8 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback import org.koitharu.kotatsu.utils.RecyclerViewScrollCallback
import org.koitharu.kotatsu.utils.ext.getParcelableCompat import org.koitharu.kotatsu.utils.ext.getParcelableCompat
import org.koitharu.kotatsu.utils.ext.withArgs import org.koitharu.kotatsu.utils.ext.withArgs
import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> { class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
@@ -41,7 +41,6 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemC
} }
val currentId = requireArguments().getLong(ARG_CURRENT_ID, 0L) val currentId = requireArguments().getLong(ARG_CURRENT_ID, 0L)
val currentPosition = chapters.indexOfFirst { it.id == currentId } val currentPosition = chapters.indexOfFirst { it.id == currentId }
val dateFormat = settings.getDateFormat()
val items = chapters.mapIndexed { index, chapter -> val items = chapters.mapIndexed { index, chapter ->
chapter.toListItem( chapter.toListItem(
isCurrent = index == currentPosition, isCurrent = index == currentPosition,
@@ -49,7 +48,6 @@ class ChaptersBottomSheet : BaseBottomSheet<SheetChaptersBinding>(), OnListItemC
isNew = false, isNew = false,
isMissing = false, isMissing = false,
isDownloaded = false, isDownloaded = false,
dateFormat = dateFormat,
) )
} }
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter -> binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->

View File

@@ -29,7 +29,6 @@ import org.koitharu.kotatsu.utils.ext.assistedViewModels
import org.koitharu.kotatsu.utils.ext.decodeRegion import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.enqueueWith import org.koitharu.kotatsu.utils.ext.enqueueWith
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.setValueRounded import org.koitharu.kotatsu.utils.ext.setValueRounded
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -117,7 +116,6 @@ class ColorFilterConfigActivity :
if (preview == null) return if (preview == null) return
ImageRequest.Builder(this@ColorFilterConfigActivity) ImageRequest.Builder(this@ColorFilterConfigActivity)
.data(preview.url) .data(preview.url)
.referer(preview.referer)
.scale(Scale.FILL) .scale(Scale.FILL)
.decodeRegion() .decodeRegion()
.tag(preview.source) .tag(preview.source)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui.pager package org.koitharu.kotatsu.reader.ui.pager
import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
@@ -59,6 +60,11 @@ class PageHolderDelegate(
} }
} }
fun showErrorDetails(context: Context) {
val e = error ?: return
ExceptionResolver.showDetails(context, e)
}
fun onAttachedToWindow() { fun onAttachedToWindow() {
readerSettings.observeForever(this) readerSettings.observeForever(this)
} }
@@ -87,6 +93,7 @@ class PageHolderDelegate(
} }
override fun onImageLoadError(e: Throwable) { override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug()
val file = this.file val file = this.file
error = e error = e
if (state == State.LOADED && e is IOException && file != null && file.exists()) { if (state == State.LOADED && e is IOException && file != null && file.exists()) {

View File

@@ -33,8 +33,8 @@ open class PageHolder(
binding.ssiv.bindToLifecycle(owner) binding.ssiv.bindToLifecycle(owner)
binding.ssiv.isEagerLoadingEnabled = !isLowRamDevice(context) binding.ssiv.isEagerLoadingEnabled = !isLowRamDevice(context)
binding.ssiv.addOnImageEventListener(delegate) binding.ssiv.addOnImageEventListener(delegate)
@Suppress("LeakingThis")
bindingInfo.buttonRetry.setOnClickListener(this) bindingInfo.buttonRetry.setOnClickListener(this)
bindingInfo.buttonErrorDetails.setOnClickListener(this)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
} }
@@ -115,6 +115,7 @@ open class PageHolder(
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return) R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return)
R.id.button_error_details -> delegate.showErrorDetails(v.context)
} }
} }

View File

@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hideCompat import org.koitharu.kotatsu.utils.ext.hideCompat
import org.koitharu.kotatsu.utils.ext.ifZero import org.koitharu.kotatsu.utils.ext.ifZero
import org.koitharu.kotatsu.utils.ext.setProgressCompat
import org.koitharu.kotatsu.utils.ext.showCompat import org.koitharu.kotatsu.utils.ext.showCompat
class WebtoonHolder( class WebtoonHolder(
@@ -40,6 +39,7 @@ class WebtoonHolder(
binding.ssiv.regionDecoderFactory = SkiaPooledImageRegionDecoder.Factory() binding.ssiv.regionDecoderFactory = SkiaPooledImageRegionDecoder.Factory()
binding.ssiv.addOnImageEventListener(delegate) binding.ssiv.addOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this) bindingInfo.buttonRetry.setOnClickListener(this)
bindingInfo.buttonErrorDetails.setOnClickListener(this)
} }
override fun onBind(data: ReaderPage) { override fun onBind(data: ReaderPage) {
@@ -104,6 +104,7 @@ class WebtoonHolder(
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return) R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return)
R.id.button_error_details -> delegate.showErrorDetails(v.context)
} }
} }

View File

@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.utils.ext.decodeRegion import org.koitharu.kotatsu.utils.ext.decodeRegion
import org.koitharu.kotatsu.utils.ext.isLowRamDevice import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.setTextColorAttr import org.koitharu.kotatsu.utils.ext.setTextColorAttr
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -44,7 +43,6 @@ fun pageThumbnailAD(
coil.execute( coil.execute(
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(url) .data(url)
.referer(item.page.referer)
.tag(item.page.source) .tag(item.page.source)
.size(thumbSize) .size(thumbSize)
.scale(Scale.FILL) .scale(Scale.FILL)

View File

@@ -1,19 +1,33 @@
package org.koitharu.kotatsu.scrobbling package org.koitharu.kotatsu.scrobbling
import android.content.Context
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet import dagger.multibindings.ElementsIntoSet
import javax.inject.Singleton
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.scrobbling.domain.Scrobbler import org.koitharu.kotatsu.core.network.CurlLoggingInterceptor
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListAuthenticator
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListInterceptor
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.anilist.domain.AniListScrobbler
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType
import org.koitharu.kotatsu.scrobbling.mal.data.MALAuthenticator
import org.koitharu.kotatsu.scrobbling.mal.data.MALInterceptor
import org.koitharu.kotatsu.scrobbling.mal.data.MALRepository
import org.koitharu.kotatsu.scrobbling.mal.domain.MALScrobbler
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriAuthenticator
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriInterceptor
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriStorage
import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler import org.koitharu.kotatsu.scrobbling.shikimori.domain.ShikimoriScrobbler
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -22,20 +36,80 @@ object ScrobblingModule {
@Provides @Provides
@Singleton @Singleton
fun provideShikimoriRepository( fun provideShikimoriRepository(
storage: ShikimoriStorage, @ScrobblerType(ScrobblerService.SHIKIMORI) storage: ScrobblerStorage,
database: MangaDatabase, database: MangaDatabase,
authenticator: ShikimoriAuthenticator, authenticator: ShikimoriAuthenticator,
): ShikimoriRepository { ): ShikimoriRepository {
val okHttp = OkHttpClient.Builder().apply { val okHttp = OkHttpClient.Builder().apply {
authenticator(authenticator) authenticator(authenticator)
addInterceptor(ShikimoriInterceptor(storage)) addInterceptor(ShikimoriInterceptor(storage))
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build() }.build()
return ShikimoriRepository(okHttp, storage, database) return ShikimoriRepository(okHttp, storage, database)
} }
@Provides
@Singleton
fun provideMALRepository(
@ScrobblerType(ScrobblerService.MAL) storage: ScrobblerStorage,
database: MangaDatabase,
authenticator: MALAuthenticator,
): MALRepository {
val okHttp = OkHttpClient.Builder().apply {
authenticator(authenticator)
addInterceptor(MALInterceptor(storage))
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build()
return MALRepository(okHttp, storage, database)
}
@Provides
@Singleton
fun provideAniListRepository(
@ScrobblerType(ScrobblerService.ANILIST) storage: ScrobblerStorage,
database: MangaDatabase,
authenticator: AniListAuthenticator,
): AniListRepository {
val okHttp = OkHttpClient.Builder().apply {
authenticator(authenticator)
addInterceptor(AniListInterceptor(storage))
if (BuildConfig.DEBUG) {
addInterceptor(CurlLoggingInterceptor())
}
}.build()
return AniListRepository(okHttp, storage, database)
}
@Provides
@Singleton
@ScrobblerType(ScrobblerService.ANILIST)
fun provideAniListStorage(
@ApplicationContext context: Context,
): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.ANILIST)
@Provides
@Singleton
@ScrobblerType(ScrobblerService.SHIKIMORI)
fun provideShikimoriStorage(
@ApplicationContext context: Context,
): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.SHIKIMORI)
@Provides
@Singleton
@ScrobblerType(ScrobblerService.MAL)
fun provideMALStorage(
@ApplicationContext context: Context,
): ScrobblerStorage = ScrobblerStorage(context, ScrobblerService.MAL)
@Provides @Provides
@ElementsIntoSet @ElementsIntoSet
fun provideScrobblers( fun provideScrobblers(
shikimoriScrobbler: ShikimoriScrobbler, shikimoriScrobbler: ShikimoriScrobbler,
): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler) aniListScrobbler: AniListScrobbler,
malScrobbler: MALScrobbler,
): Set<@JvmSuppressWildcards Scrobbler> = setOf(shikimoriScrobbler, aniListScrobbler, malScrobbler)
} }

View File

@@ -0,0 +1,56 @@
package org.koitharu.kotatsu.scrobbling.anilist.data
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerType
import javax.inject.Inject
import javax.inject.Provider
class AniListAuthenticator @Inject constructor(
@ScrobblerType(ScrobblerService.ANILIST) private val storage: ScrobblerStorage,
private val repositoryProvider: Provider<AniListRepository>,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val accessToken = storage.accessToken ?: return null
if (!isRequestWithAccessToken(response)) {
return null
}
synchronized(this) {
val newAccessToken = storage.accessToken ?: return null
if (accessToken != newAccessToken) {
return newRequestWithAccessToken(response.request, newAccessToken)
}
val updatedAccessToken = refreshAccessToken() ?: return null
return newRequestWithAccessToken(response.request, updatedAccessToken)
}
}
private fun isRequestWithAccessToken(response: Response): Boolean {
val header = response.request.header(CommonHeaders.AUTHORIZATION)
return header?.startsWith("Bearer") == true
}
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
return request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $accessToken")
.build()
}
private fun refreshAccessToken(): String? = runCatching {
val repository = repositoryProvider.get()
runBlocking { repository.authorize(null) }
return storage.accessToken
}.onFailure {
if (BuildConfig.DEBUG) {
it.printStackTrace()
}
}.getOrNull()
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.scrobbling.anilist.data
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
private const val JSON = "application/json"
class AniListInterceptor(private val storage: ScrobblerStorage) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val sourceRequest = chain.request()
val request = sourceRequest.newBuilder()
request.header(CommonHeaders.CONTENT_TYPE, JSON)
request.header(CommonHeaders.ACCEPT, JSON)
if (!sourceRequest.url.pathSegments.contains("oauth")) {
storage.accessToken?.let {
request.header(CommonHeaders.AUTHORIZATION, "Bearer $it")
}
}
return chain.proceed(request.build())
}
}

View File

@@ -0,0 +1,262 @@
package org.koitharu.kotatsu.scrobbling.anilist.data
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.parsers.exception.GraphQLException
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.toIntUp
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import kotlin.math.roundToInt
private const val REDIRECT_URI = "kotatsu://anilist-auth"
private const val BASE_URL = "https://anilist.co/api/v2/"
private const val ENDPOINT = "https://graphql.anilist.co"
private const val MANGA_PAGE_SIZE = 10
private const val REQUEST_QUERY = "query"
private const val REQUEST_MUTATION = "mutation"
private const val KEY_SCORE_FORMAT = "score_format"
class AniListRepository(
private val okHttp: OkHttpClient,
private val storage: ScrobblerStorage,
private val db: MangaDatabase,
) : org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository {
override val oauthUrl: String
get() = "${BASE_URL}oauth/authorize?client_id=${BuildConfig.ANILIST_CLIENT_ID}&" +
"redirect_uri=${REDIRECT_URI}&response_type=code"
override val isAuthorized: Boolean
get() = storage.accessToken != null
private val shrinkRegex = Regex("\\t+")
override suspend fun authorize(code: String?) {
val body = FormBody.Builder()
body.add("client_id", BuildConfig.ANILIST_CLIENT_ID)
body.add("client_secret", BuildConfig.ANILIST_CLIENT_SECRET)
if (code != null) {
body.add("grant_type", "authorization_code")
body.add("redirect_uri", REDIRECT_URI)
body.add("code", code)
} else {
body.add("grant_type", "refresh_token")
body.add("refresh_token", checkNotNull(storage.refreshToken))
}
val request = Request.Builder()
.post(body.build())
.url("${BASE_URL}oauth/token")
val response = okHttp.newCall(request.build()).await().parseJson()
storage.accessToken = response.getString("access_token")
storage.refreshToken = response.getString("refresh_token")
}
override suspend fun loadUser(): ScrobblerUser {
val response = doRequest(
REQUEST_QUERY,
"""
AniChartUser {
user {
id
name
avatar {
medium
}
mediaListOptions {
scoreFormat
}
}
}
""",
)
val jo = response.getJSONObject("data").getJSONObject("AniChartUser").getJSONObject("user")
storage[KEY_SCORE_FORMAT] = jo.getJSONObject("mediaListOptions").getString("scoreFormat")
return AniListUser(jo).also { storage.user = it }
}
override val cachedUser: ScrobblerUser?
get() {
return storage.user
}
override suspend fun unregister(mangaId: Long) {
return db.scrobblingDao.delete(ScrobblerService.ANILIST.id, mangaId)
}
override fun logout() {
storage.clear()
}
override suspend fun findManga(query: String, offset: Int): List<ScrobblerManga> {
val page = (offset / MANGA_PAGE_SIZE.toFloat()).toIntUp() + 1
val response = doRequest(
REQUEST_QUERY,
"""
Page(page: $page, perPage: ${MANGA_PAGE_SIZE}) {
media(type: MANGA, sort: SEARCH_MATCH, search: ${JSONObject.quote(query)}) {
id
title {
userPreferred
native
}
coverImage {
medium
}
siteUrl
}
}
""",
)
val data = response.getJSONObject("data").getJSONObject("Page").getJSONArray("media")
return data.mapJSON { ScrobblerManga(it) }
}
override suspend fun createRate(mangaId: Long, scrobblerMangaId: Long) {
val response = doRequest(
REQUEST_MUTATION,
"""
SaveMediaListEntry(mediaId: $scrobblerMangaId) {
id
mediaId
status
notes
score
progress
}
""",
)
saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter) {
val response = doRequest(
REQUEST_MUTATION,
"""
SaveMediaListEntry(id: $rateId, progress: ${chapter.number}) {
id
mediaId
status
notes
score
progress
}
""",
)
saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
}
override suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?) {
val scoreRaw = (rating * 100f).roundToInt()
val statusString = status?.let { ", status: $it" }.orEmpty()
val notesString = comment?.let { ", notes: ${JSONObject.quote(it)}" }.orEmpty()
val response = doRequest(
REQUEST_MUTATION,
"""
SaveMediaListEntry(id: $rateId, scoreRaw: $scoreRaw$statusString$notesString) {
id
mediaId
status
notes
score
progress
}
""",
)
saveRate(response.getJSONObject("data").getJSONObject("SaveMediaListEntry"), mangaId)
}
override suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo {
val response = doRequest(
REQUEST_QUERY,
"""
Media(id: $id) {
id
title {
userPreferred
}
coverImage {
large
}
description
siteUrl
}
""",
)
return ScrobblerMangaInfo(response.getJSONObject("data").getJSONObject("Media"))
}
private suspend fun saveRate(json: JSONObject, mangaId: Long) {
val scoreFormat = ScoreFormat.of(storage[KEY_SCORE_FORMAT])
val entity = ScrobblingEntity(
scrobbler = ScrobblerService.ANILIST.id,
id = json.getInt("id"),
mangaId = mangaId,
targetId = json.getLong("mediaId"),
status = json.getString("status"),
chapter = json.getInt("progress"),
comment = json.getString("notes"),
rating = scoreFormat.normalize(json.getDouble("score").toFloat()),
)
db.scrobblingDao.upsert(entity)
}
private fun ScrobblerManga(json: JSONObject): ScrobblerManga {
val title = json.getJSONObject("title")
return ScrobblerManga(
id = json.getLong("id"),
name = title.getString("userPreferred"),
altName = title.getStringOrNull("native"),
cover = json.getJSONObject("coverImage").getString("medium"),
url = json.getString("siteUrl"),
)
}
private fun ScrobblerMangaInfo(json: JSONObject) = ScrobblerMangaInfo(
id = json.getLong("id"),
name = json.getJSONObject("title").getString("userPreferred"),
cover = json.getJSONObject("coverImage").getString("large"),
url = json.getString("siteUrl"),
descriptionHtml = json.getString("description"),
)
private fun AniListUser(json: JSONObject) = ScrobblerUser(
id = json.getLong("id"),
nickname = json.getString("name"),
avatar = json.getJSONObject("avatar").getString("medium"),
service = ScrobblerService.ANILIST,
)
private suspend fun doRequest(type: String, payload: String): JSONObject {
val body = JSONObject()
body.put("query", "$type { ${payload.shrink()} }")
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = body.toString().toRequestBody(mediaType)
val request = Request.Builder()
.post(requestBody)
.url(ENDPOINT)
val json = okHttp.newCall(request.build()).await().parseJson()
json.optJSONArray("errors")?.let {
if (it.length() != 0) {
throw GraphQLException(it)
}
}
return json
}
private fun String.shrink() = replace(shrinkRegex, " ")
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.scrobbling.anilist.data
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
enum class ScoreFormat {
POINT_100, POINT_10_DECIMAL, POINT_10, POINT_5, POINT_3;
fun normalize(score: Float): Float = when (this) {
POINT_100 -> score / 100f
POINT_10_DECIMAL,
POINT_10 -> score / 10f
POINT_5 -> score / 5f
POINT_3 -> score / 3f
}
companion object {
fun of(rawValue: String?): ScoreFormat {
rawValue ?: return POINT_10_DECIMAL
return runCatching { valueOf(rawValue) }
.onFailure { it.printStackTraceDebug() }
.getOrDefault(POINT_10_DECIMAL)
}
}
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.scrobbling.anilist.domain
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AniListScrobbler @Inject constructor(
private val repository: AniListRepository,
db: MangaDatabase,
) : Scrobbler(db, ScrobblerService.ANILIST, repository) {
init {
statuses[ScrobblingStatus.PLANNED] = "PLANNING"
statuses[ScrobblingStatus.READING] = "CURRENT"
statuses[ScrobblingStatus.RE_READING] = "REPEATING"
statuses[ScrobblingStatus.COMPLETED] = "COMPLETED"
statuses[ScrobblingStatus.ON_HOLD] = "PAUSED"
statuses[ScrobblingStatus.DROPPED] = "DROPPED"
}
override suspend fun updateScrobblingInfo(
mangaId: Long,
rating: Float,
status: ScrobblingStatus?,
comment: String?,
) {
val entity = db.scrobblingDao.find(scrobblerService.id, mangaId)
requireNotNull(entity) { "Scrobbling info for manga $mangaId not found" }
repository.updateRate(
rateId = entity.id,
mangaId = entity.mangaId,
rating = rating,
status = statuses[status],
comment = comment,
)
}
}

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.scrobbling.common.data
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
interface ScrobblerRepository {
val oauthUrl: String
val isAuthorized: Boolean
val cachedUser: ScrobblerUser?
suspend fun authorize(code: String?)
suspend fun loadUser(): ScrobblerUser
fun logout()
suspend fun unregister(mangaId: Long)
suspend fun findManga(query: String, offset: Int): List<ScrobblerManga>
suspend fun getMangaInfo(id: Long): ScrobblerMangaInfo
suspend fun createRate(mangaId: Long, scrobblerMangaId: Long)
suspend fun updateRate(rateId: Int, mangaId: Long, chapter: MangaChapter)
suspend fun updateRate(rateId: Int, mangaId: Long, rating: Float, status: String?, comment: String?)
}

Some files were not shown because too many files have changed in this diff Show More