Compare commits

...

81 Commits

Author SHA1 Message Date
Koitharu
0d8820bcab Dynamic sources support 2024-06-29 07:56:18 +03:00
Koitharu
77bb5c2fcd Support UNKNOWN manga source 2024-06-22 13:19:21 +03:00
Milo Ivir
475a4904a9 Translated using Weblate (Croatian)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-06-22 12:56:50 +03:00
lukapiplica
cf43b8ebda Translated using Weblate (Croatian)
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: lukapiplica <github163007297@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-06-22 12:56:50 +03:00
Duh051
f34096af98 Translated using Weblate (Arabic)
Currently translated at 60.1% (390 of 648 strings)

Co-authored-by: Duh051 <duhduh272@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-06-22 12:56:50 +03:00
Bela K
d60ff2a052 Translated using Weblate (German)
Currently translated at 95.2% (617 of 648 strings)

Co-authored-by: Bela K <na0341-dev@posteo.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-06-22 12:56:50 +03:00
Anon
59d4953554 Translated using Weblate (Serbian)
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-06-22 12:56:50 +03:00
acetknan
f76052b1d6 Translated using Weblate (Arabic)
Currently translated at 55.4% (359 of 648 strings)

Translated using Weblate (Arabic)

Currently translated at 88.8% (8 of 9 strings)

Co-authored-by: acetknan <acetknan18@gmail.com>
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
2024-06-22 12:56:50 +03:00
Koitharu
26e59b0953 Fix opening reader with specific branch 2024-06-22 12:55:53 +03:00
Koitharu
9ee1164f08 Update parsers 2024-06-22 12:13:52 +03:00
Koitharu
cfc3823593 Improve double-tap zoom in reader 2024-06-22 10:20:48 +03:00
Koitharu
8407a414c5 Fix crashes 2024-06-15 14:03:38 +03:00
Koitharu
a379604974 Transfer scrobbling information within migration #930 2024-06-15 13:49:57 +03:00
Koitharu
c01d80f7da Update dependencies 2024-06-15 10:51:42 +03:00
lukapiplica
7533dce0d2 Translated using Weblate (Croatian)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Croatian)

Currently translated at 100.0% (9 of 9 strings)

Added translation using Weblate (Croatian)

Added translation using Weblate (Croatian)

Co-authored-by: lukapiplica <github163007297@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/hr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-06-15 10:44:09 +03:00
Fikri Akbar
9f1e97fd54 Translated using Weblate (Indonesian)
Currently translated at 99.0% (642 of 648 strings)

Co-authored-by: Fikri Akbar <akbarfikri1221@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-06-15 10:44:09 +03:00
Johan
382a73310c Translated using Weblate (Czech)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Czech)

Currently translated at 98.7% (640 of 648 strings)

Co-authored-by: Johan <jakub.bilku@outlook.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2024-06-15 10:44:09 +03:00
Infy's Tagalog Translations
5eeab7fd08 Translated using Weblate (Filipino)
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-06-15 10:44:09 +03:00
gekka
bc54e7cfba Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-06-15 10:44:09 +03:00
Koitharu
4502ffb6d2 Update parsers 2024-06-07 09:06:59 +03:00
maryush
b6f9ce824e Translated using Weblate (Polish)
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
gallegonovato
d33081c1c7 Translated using Weblate (Spanish)
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
Eduardo Malaspina
76c08535d6 Translated using Weblate (Spanish)
Currently translated at 100.0% (646 of 646 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
Макар Разин
b55fef67e1 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (646 of 646 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
Scrambled777
56798677d5 Translated using Weblate (Hindi)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (646 of 646 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
gekka
ff30b9c225 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (646 of 646 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
Oğuz Ersen
5c3293ec44 Translated using Weblate (Turkish)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (646 of 646 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
Anon
1b17605e0e Translated using Weblate (Serbian)
Currently translated at 99.8% (645 of 646 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (645 of 645 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
Naga
ba4e4dcf56 Translated using Weblate (French)
Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (French)

Currently translated at 99.6% (643 of 645 strings)

Co-authored-by: Naga <yz2000.pro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
Infy's Tagalog Translations
b35d5d4779 Translated using Weblate (Filipino)
Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (645 of 645 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-06-07 08:27:24 +03:00
Koitharu
124f31ebe1 Global screenshot policy #920 #138 2024-06-06 11:09:44 +03:00
Koitharu
173087ee19 Sources catalog improvements 2024-06-06 11:09:44 +03:00
Koitharu
8d7bad97de Merge pull request #917 from galpt/devel 2024-06-03 17:27:12 +03:00
nya~
188fbfbb95 0ms DNS Large variant
To prevent users from getting rate limited while still receiving the benefits of OISD Big and other security filters, using the "Large" variant is highly recommended for mission-critical applications or big networks.

Recommended to always use DNS-over-HTTPS since Plain is not safe.
2024-06-02 12:15:07 +07:00
nya~
3498a54bdf Change 0ms DNS to Large variant
To prevent users from getting rate limited while still receiving the benefits of OISD Big and other security filters, using the "Large" variant is highly recommended for mission-critical applications or big networks.
2024-06-02 12:11:40 +07:00
Koitharu
18169c2355 Update sources catalog and repository 2024-06-01 17:13:27 +03:00
Koitharu
87beb9442f Respect rounded corners in reader bar #900 2024-06-01 13:09:45 +03:00
Koitharu
e642d54929 Reapply "Update sources catalog ui"
This reverts commit 8d5bde6e60.
2024-06-01 11:55:52 +03:00
Koitharu
59ce5d5e67 Skip hidden files on local storage #910 2024-06-01 08:55:21 +03:00
Koitharu
58d5237692 Update dependencies 2024-06-01 08:50:56 +03:00
Koitharu
8d5bde6e60 Revert "Update sources catalog ui"
This reverts commit 597ad01e8f.
2024-05-27 17:29:31 +03:00
Koitharu
bf740ddc93 Merge branch 'master' into devel 2024-05-27 17:25:57 +03:00
Koitharu
fddbf35e8c Fix up navigation button behavior 2024-05-27 15:54:34 +03:00
Koitharu
a47fea02d1 Update issue templates 2024-05-27 14:07:37 +03:00
Koitharu
250136cfdc Update parsers 2024-05-27 13:55:38 +03:00
Koitharu
597ad01e8f Update sources catalog ui 2024-05-27 13:39:34 +03:00
Koitharu
f7b44f2b0f Update parsers 2024-05-25 17:27:33 +03:00
Koitharu
5aab43ac93 Update settings activity ui 2024-05-25 17:27:32 +03:00
Koitharu
2d278159ea Fix crashes and improve predictive back support 2024-05-25 17:27:32 +03:00
Koitharu
da61462d79 Update parsers 2024-05-25 17:17:30 +03:00
Макар Разин
2ab0912880 Translated using Weblate (Polish)
Currently translated at 100.0% (645 of 645 strings)

Translated using Weblate (Serbian)

Currently translated at 99.3% (641 of 645 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (645 of 645 strings)

Translated using Weblate (Polish)

Currently translated at 99.8% (644 of 645 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (645 of 645 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (645 of 645 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
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/uk/
Translation: Kotatsu/Strings
2024-05-25 16:15:06 +03:00
Scrambled777
3914616222 Translated using Weblate (Hindi)
Currently translated at 100.0% (645 of 645 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (643 of 643 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-05-25 16:15:06 +03:00
maryush
a73b2703be Translated using Weblate (Polish)
Currently translated at 100.0% (643 of 643 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-05-25 16:15:06 +03:00
gekka
49590f6d02 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (645 of 645 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (643 of 643 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-05-25 16:15:06 +03:00
Oğuz Ersen
f4a0fcf5ba Translated using Weblate (Turkish)
Currently translated at 100.0% (645 of 645 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (643 of 643 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-05-25 16:15:06 +03:00
gallegonovato
6ab803e682 Translated using Weblate (Spanish)
Currently translated at 100.0% (645 of 645 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (643 of 643 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-05-25 16:15:06 +03:00
Koitharu
0faa97b08c Update settings activity ui 2024-05-25 16:11:21 +03:00
Koitharu
2ae488544b Fix crashes and improve predictive back support 2024-05-25 10:32:02 +03:00
Koitharu
a7e2cfc878 Udpate parsers 2024-05-24 08:30:24 +03:00
Koitharu
da6db9c1b4 Refactor descrambling bitmap 2024-05-23 16:55:41 +03:00
AwkwardPeak7
88b3e5cf34 implement basic methods for descrambling images 2024-05-23 16:28:42 +03:00
Koitharu
7347f0ba10 Pagination in history and favorites 2024-05-23 12:44:10 +03:00
Koitharu
4c55682552 Move tracker debug activity to common code 2024-05-22 16:42:24 +03:00
Koitharu
324031aa2a Update untranslatable strings 2024-05-22 14:13:14 +03:00
Koitharu
1355c3d75c Option to disable nsfw updates notifications 2024-05-22 13:05:33 +03:00
Infy's Tagalog Translations
8533168155 Translated using Weblate (Filipino)
Currently translated at 99.8% (640 of 641 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
Asmodeus
51f6ec6e55 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (641 of 641 strings)

Co-authored-by: Asmodeus <colligare1Asmodeum@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
Deivinni Silva
7e3f67c14d Translated using Weblate (Portuguese (Brazil))
Currently translated at 97.1% (623 of 641 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
gallegonovato
c51320f033 Translated using Weblate (Spanish)
Currently translated at 100.0% (641 of 641 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
Hosted Weblate
9c50a47abc 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
2024-05-22 13:05:06 +03:00
Scrambled777
473d273d18 Translated using Weblate (Hindi)
Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
gekka
f19b628655 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
Oğuz Ersen
fa74d4b27a Translated using Weblate (Turkish)
Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
Nicola Bortoletto
cdb6655e37 Translated using Weblate (Italian)
Currently translated at 93.4% (597 of 639 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
maryush
4f19f7ebdf Translated using Weblate (Polish)
Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (639 of 639 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (638 of 638 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-05-22 13:05:06 +03:00
Koitharu
bf8838f943 Save and share manga cover #253 2024-05-22 12:33:23 +03:00
Koitharu
1e1e9fabdc Merge pull request #885 from ranzou06/devel 2024-05-20 18:51:21 +03:00
Koitharu
745972a717 Added 0ms.dev images proxy support #771 2024-05-20 17:03:18 +03:00
Koitharu
6055776329 Fix crashes 2024-05-20 11:31:00 +03:00
Koitharu
4074791f9a Resolve SSL excetpions 2024-05-20 11:18:38 +03:00
Clebio
b8283acd0d feat: Implement Spen integration for enhanced stylus support
TY Alexander!
2024-05-16 22:15:10 -03:00
211 changed files with 3136 additions and 1370 deletions

View File

@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links: contact_links:
- name: ⚠️ Source issue - name: ⚠️ Source issue
url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new url: https://github.com/KotatsuApp/kotatsu-parsers/issues/new
about: If you have troubles with a manga parser or want to propose new manga source, please open an issue in the kotatsu-parsers repository instead about: If you have a problem with a specific **manga source** or want to propose a new one, please open an issue in the kotatsu-parsers repository instead

View File

@@ -60,7 +60,7 @@ body:
attributes: attributes:
label: Acknowledgements label: Acknowledgements
options: options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
required: true required: true
- label: If this is an issue with a parser, I should be opening an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose). - label: This is not an issue with a specific manga source. Otherwise, you have to open an issue in the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers/issues/new/choose).
required: true required: true

View File

@@ -20,5 +20,5 @@ body:
label: Acknowledgements label: Acknowledgements
description: Read this carefully, we will close and ignore your issue if you skimmed through this. description: Read this carefully, we will close and ignore your issue if you skimmed through this.
options: options:
- label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. - label: This is not a duplicate of an existing issue. Please look through the list of [open issues](https://github.com/KotatsuApp/Kotatsu/issues) before creating a new one.
required: true required: true

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 642 versionCode = 650
versionName = '7.0.1' versionName = '7.2.1'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:078b59b1e2') { implementation('com.github.KotatsuApp:kotatsu-parsers:f923acc5a7') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@@ -90,14 +90,15 @@ dependencies {
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24' implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.0' implementation 'androidx.activity:activity-ktx:1.9.0'
implementation 'androidx.fragment:fragment-ktx:1.7.1' implementation 'androidx.fragment:fragment-ktx:1.8.0'
implementation 'androidx.transition:transition-ktx:1.5.0'
implementation 'androidx.collection:collection-ktx:1.4.0' implementation 'androidx.collection:collection-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2'
implementation 'androidx.lifecycle:lifecycle-service:2.8.0' implementation 'androidx.lifecycle:lifecycle-service:2.8.2'
implementation 'androidx.lifecycle:lifecycle-process:2.8.0' implementation 'androidx.lifecycle:lifecycle-process:2.8.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.recyclerview:recyclerview:1.3.2'
@@ -105,7 +106,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
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.12.0' implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.0' implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.2'
implementation 'androidx.webkit:webkit:1.11.0' implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.0' implementation 'androidx.work:work-runtime:2.9.0'
@@ -135,7 +136,7 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.6.0' implementation 'io.coil-kt:coil-base:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0' implementation 'io.coil-kt:coil-svg:2.6.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:8cafac256e'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'

View File

@@ -1,12 +0,0 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".tracker.ui.debug.TrackerDebugActivity"
android:label="@string/check_for_new_chapters" />
</application>
</manifest>

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.settings
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import leakcanary.LeakCanary
import org.koitharu.kotatsu.R
import org.koitharu.workinspector.WorkInspector
class SettingsMenuProvider(
private val context: Context,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_settings, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_leaks -> {
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
true
}
R.id.action_works -> {
context.startActivity(WorkInspector.getIntent(context))
true
}
else -> false
}
}

View File

@@ -8,14 +8,9 @@
android:title="@string/leak_canary_display_activity_label" android:title="@string/leak_canary_display_activity_label"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@id/action_tracker"
android:title="@string/check_for_new_chapters"
app:showAsAction="never" />
<item <item
android:id="@id/action_works" android:id="@id/action_works"
android:title="Works" android:title="@string/wi_lib_name"
app:showAsAction="never" /> app:showAsAction="never" />
</menu> </menu>

View File

@@ -100,6 +100,13 @@
<intent-filter> <intent-filter>
<action android:name="${applicationId}.action.READ_MANGA" /> <action android:name="${applicationId}.action.READ_MANGA" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="com.samsung.android.support.REMOTE_ACTION" />
</intent-filter>
<meta-data
android:name="com.samsung.android.support.REMOTE_ACTION"
android:resource="@xml/remote_action" />
</activity> </activity>
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.SearchActivity" android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
@@ -248,6 +255,9 @@
<activity <activity
android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity" android:name="org.koitharu.kotatsu.settings.about.AppUpdateActivity"
android:label="@string/app_update_available" /> android:label="@string/app_update_available" />
<activity
android:name="org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity"
android:label="@string/tracker_debug_info" />
<service <service
android:name="androidx.work.impl.foreground.SystemForegroundService" android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject import javax.inject.Inject
@@ -57,7 +58,7 @@ class AlternativesUseCase @Inject constructor(
} }
private suspend fun getSources(ref: MangaSource): List<MangaSource> { private suspend fun getSources(ref: MangaSource): List<MangaSource> {
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2) val result = ArrayList<MangaSource>(MangaParserSource.entries.size - 2)
result.addAll(sourcesRepository.getEnabledSources()) result.addAll(sourcesRepository.getEnabledSources())
result.sortByDescending { it.priority(ref) } result.sortByDescending { it.priority(ref) }
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) }) result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
@@ -78,8 +79,10 @@ class AlternativesUseCase @Inject constructor(
private fun MangaSource.priority(ref: MangaSource): Int { private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0 var res = 0
if (locale == ref.locale) res += 2 if (this is MangaParserSource && ref is MangaParserSource) {
if (contentType == ref.contentType) res++ if (locale == ref.locale) res += 2
if (contentType == ref.contentType) res++
}
return res return res
} }
} }

View File

@@ -12,135 +12,184 @@ 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.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackEntity
import javax.inject.Inject import javax.inject.Inject
class MigrateUseCase @Inject constructor( class MigrateUseCase
private val mangaRepositoryFactory: MangaRepository.Factory, @Inject
private val mangaDataRepository: MangaDataRepository, constructor(
private val database: MangaDatabase, private val mangaRepositoryFactory: MangaRepository.Factory,
private val progressUpdateUseCase: ProgressUpdateUseCase, private val mangaDataRepository: MangaDataRepository,
) { private val database: MangaDatabase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
suspend operator fun invoke(oldManga: Manga, newManga: Manga) { private val scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) { ) {
runCatchingCancellable { suspend operator fun invoke(
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga) oldManga: Manga,
}.getOrDefault(oldManga) newManga: Manga,
} else { ) {
oldManga val oldDetails =
} if (oldManga.chapters.isNullOrEmpty()) {
val newDetails = if (newManga.chapters.isNullOrEmpty()) { runCatchingCancellable {
mangaRepositoryFactory.create(newManga.source).getDetails(newManga) mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
} else { }.getOrDefault(oldManga)
newManga } else {
} oldManga
mangaDataRepository.storeManga(newDetails) }
database.withTransaction { val newDetails =
// replace favorites if (newManga.chapters.isNullOrEmpty()) {
val favoritesDao = database.getFavouritesDao() mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id) } else {
if (oldFavourites.isNotEmpty()) { newManga
favoritesDao.delete(oldManga.id) }
for (f in oldFavourites) { mangaDataRepository.storeManga(newDetails)
val e = f.copy( database.withTransaction {
mangaId = newManga.id, // replace favorites
val favoritesDao = database.getFavouritesDao()
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
if (oldFavourites.isNotEmpty()) {
favoritesDao.delete(oldManga.id)
for (f in oldFavourites) {
val e =
f.copy(
mangaId = newManga.id,
)
favoritesDao.upsert(e)
}
}
// replace history
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
val newHistory =
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
newHistory
} else {
null
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack =
TrackEntity(
mangaId = newDetails.id,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
lastError = null,
)
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
}
// scrobbling
for (scrobbler in scrobblers) {
if (!scrobbler.isEnabled) {
continue
}
val prevInfo = scrobbler.getScrobblingInfoOrNull(oldDetails.id) ?: continue
scrobbler.unregisterScrobbling(oldDetails.id)
scrobbler.linkManga(newDetails.id, prevInfo.targetId)
scrobbler.updateScrobblingInfo(
mangaId = newDetails.id,
rating = prevInfo.rating,
status =
prevInfo.status ?: when {
newHistory == null -> ScrobblingStatus.PLANNED
newHistory.percent == 1f -> ScrobblingStatus.COMPLETED
else -> ScrobblingStatus.READING
},
comment = prevInfo.comment,
) )
favoritesDao.upsert(e) if (newHistory != null) {
scrobbler.scrobble(
manga = newDetails,
chapterId = newHistory.chapterId,
)
}
} }
} }
// replace history progressUpdateUseCase(newManga)
val historyDao = database.getHistoryDao()
val oldHistory = historyDao.find(oldDetails.id)
if (oldHistory != null) {
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
historyDao.delete(oldDetails.id)
historyDao.upsert(newHistory)
}
// track
val tracksDao = database.getTracksDao()
val oldTrack = tracksDao.find(oldDetails.id)
if (oldTrack != null) {
val lastChapter = newDetails.chapters?.lastOrNull()
val newTrack = TrackEntity(
mangaId = newDetails.id,
lastChapterId = lastChapter?.id ?: 0L,
newChapters = 0,
lastCheckTime = System.currentTimeMillis(),
lastChapterDate = lastChapter?.uploadDate ?: 0L,
lastResult = TrackEntity.RESULT_EXTERNAL_MODIFICATION,
)
tracksDao.delete(oldDetails.id)
tracksDao.upsert(newTrack)
}
} }
progressUpdateUseCase(newManga)
}
private fun makeNewHistory( private fun makeNewHistory(
oldManga: Manga, oldManga: Manga,
newManga: Manga, newManga: Manga,
history: HistoryEntity, history: HistoryEntity,
): HistoryEntity { ): HistoryEntity {
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
val branch = newManga.getPreferredBranch(null) val branch = newManga.getPreferredBranch(null)
val chapters = checkNotNull(newManga.getChapters(branch)) val chapters = checkNotNull(newManga.getChapters(branch))
val currentChapter = if (history.percent in 0f..1f) { val currentChapter =
chapters[(chapters.lastIndex * history.percent).toInt()] if (history.percent in 0f..1f) {
} else { chapters[(chapters.lastIndex * history.percent).toInt()]
chapters.first() } else {
chapters.first()
}
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
percent = history.percent,
deletedAt = 0,
chaptersCount = chapters.size,
)
} }
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch))
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
if (index < 0) {
index =
if (history.percent in 0f..1f) {
(oldChapters.lastIndex * history.percent).toInt()
} else {
0
}
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch =
if (newChapters.containsKey(branch)) {
branch
} else {
newManga.getPreferredBranch(null)
}
val newChapterId =
checkNotNull(newChapters[newBranch])
.let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity( return HistoryEntity(
mangaId = newManga.id, mangaId = newManga.id,
createdAt = history.createdAt, createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
chapterId = currentChapter.id, chapterId = newChapterId,
page = history.page, page = history.page,
scroll = history.scroll, scroll = history.scroll,
percent = history.percent, percent = PROGRESS_NONE,
deletedAt = 0, deletedAt = 0,
chaptersCount = chapters.size, chaptersCount = checkNotNull(newChapters[newBranch]).size,
) )
} }
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
val oldChapters = checkNotNull(oldManga.getChapters(branch)) private fun List<MangaChapter>.findByNumber(
var index = oldChapters.indexOfFirst { it.id == history.chapterId } volume: Int,
if (index < 0) { number: Float,
index = if (history.percent in 0f..1f) { ): MangaChapter? =
(oldChapters.lastIndex * history.percent).toInt() if (number <= 0f) {
null
} else { } else {
0 firstOrNull { it.volume == volume && it.number == number }
} }
}
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
val newBranch = if (newChapters.containsKey(branch)) {
branch
} else {
newManga.getPreferredBranch(null)
}
val newChapterId = checkNotNull(newChapters[newBranch]).let {
val oldChapter = oldChapters[index]
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
}.id
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
chapterId = newChapterId,
page = history.page,
scroll = history.scroll,
percent = PROGRESS_NONE,
deletedAt = 0,
chaptersCount = checkNotNull(newChapters[newBranch]).size,
)
} }
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
return if (number <= 0f) {
null
} else {
firstOrNull { it.volume == volume && it.number == number }
}
}
}

View File

@@ -10,6 +10,7 @@ import coil.request.ImageRequest
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
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.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
@@ -63,7 +64,7 @@ fun alternativeAD(
} }
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads) binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.chipSource.also { chip -> binding.chipSource.also { chip ->
chip.text = item.manga.source.title chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(item.manga.source.faviconUri()) .data(item.manga.source.faviconUri())
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)

View File

@@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
@@ -95,9 +96,9 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
getString( getString(
R.string.migrate_confirmation, R.string.migrate_confirmation,
viewModel.manga.title, viewModel.manga.title,
viewModel.manga.source.title, viewModel.manga.source.getTitle(this),
target.title, target.title,
target.source.title, target.source.getTitle(this),
), ),
) )
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)

View File

@@ -12,13 +12,14 @@ 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 dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import okhttp3.internal.userAgent
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
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.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -42,10 +43,9 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
} }
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source -> val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository val repository = mangaRepositoryFactory.create(mangaSource) as? RemoteMangaRepository
repository?.headers?.get(CommonHeaders.USER_AGENT) repository?.headers?.get(CommonHeaders.USER_AGENT)
}
viewBinding.webView.configureForParser(userAgent) viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webViewClient = BrowserClient(this)
@@ -108,8 +108,10 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
viewBinding.webView.stopLoading() if (hasViewBinding()) {
viewBinding.webView.destroy() viewBinding.webView.stopLoading()
viewBinding.webView.destroy()
}
} }
override fun onLoadingStateChanged(isLoading: Boolean) { override fun onLoadingStateChanged(isLoading: Boolean) {
@@ -145,7 +147,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
return Intent(context, BrowserActivity::class.java) return Intent(context, BrowserActivity::class.java)
.setData(Uri.parse(url)) .setData(Uri.parse(url))
.putExtra(EXTRA_TITLE, title) .putExtra(EXTRA_TITLE, title)
.putExtra(EXTRA_SOURCE, source) .putExtra(EXTRA_SOURCE, source?.name)
} }
} }
} }

View File

@@ -14,8 +14,9 @@ import coil.request.ErrorResult
import coil.request.ImageRequest import coil.request.ImageRequest
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
class CaptchaNotifier( class CaptchaNotifier(
@@ -46,7 +47,7 @@ class CaptchaNotifier(
.setGroup(GROUP_CAPTCHA) .setGroup(GROUP_CAPTCHA)
.setAutoCancel(true) .setAutoCancel(true)
.setVisibility( .setVisibility(
if (exception.source?.contentType == ContentType.HENTAI) { if (exception.source?.isNsfw() == true) {
NotificationCompat.VISIBILITY_SECRET NotificationCompat.VISIBILITY_SECRET
} else { } else {
NotificationCompat.VISIBILITY_PUBLIC NotificationCompat.VISIBILITY_PUBLIC
@@ -55,7 +56,7 @@ class CaptchaNotifier(
.setContentText( .setContentText(
context.getString( context.getString(
R.string.captcha_required_summary, R.string.captcha_required_summary,
exception.source?.title ?: context.getString(R.string.app_name), exception.source?.getTitle(context) ?: context.getString(R.string.app_name),
), ),
) )
.setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false)) .setContentIntent(PendingIntentCompat.getActivity(context, 0, intent, 0, false))

View File

@@ -27,8 +27,8 @@ import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
@@ -48,6 +48,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver import org.koitharu.kotatsu.settings.backup.BackupObserver
@@ -152,10 +153,12 @@ interface AppModule {
appProtectHelper: AppProtectHelper, appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle, activityRecreationHandle: ActivityRecreationHandle,
acraScreenLogger: AcraScreenLogger, acraScreenLogger: AcraScreenLogger,
screenshotPolicyHelper: ScreenshotPolicyHelper,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf( ): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper, appProtectHelper,
activityRecreationHandle, activityRecreationHandle,
acraScreenLogger, acraScreenLogger,
screenshotPolicyHelper,
) )
@Provides @Provides

View File

@@ -84,6 +84,7 @@ class JsonDeserializer(private val json: JSONObject) {
source = json.getString("source"), source = json.getString("source"),
isEnabled = json.getBoolean("enabled"), isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"), sortKey = json.getInt("sort_key"),
addedIn = json.getIntOrDefault("added_in", 0),
) )
fun toMap(): Map<String, Any?> { fun toMap(): Map<String, Any?> {

View File

@@ -16,16 +16,16 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone
private val isLowRam = application.isLowRamDevice() private val isLowRam = application.isLowRamDevice()
init {
application.registerComponentCallbacks(this)
}
private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES) private val detailsCache = ExpiringLruCache<SafeDeferred<Manga>>(if (isLowRam) 1 else 4, 5, TimeUnit.MINUTES)
private val pagesCache = private val pagesCache =
ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES) ExpiringLruCache<SafeDeferred<List<MangaPage>>>(if (isLowRam) 1 else 4, 10, TimeUnit.MINUTES)
private val relatedMangaCache = private val relatedMangaCache =
ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES) ExpiringLruCache<SafeDeferred<List<Manga>>>(if (isLowRam) 1 else 3, 10, TimeUnit.MINUTES)
init {
application.registerComponentCallbacks(this)
}
suspend fun getDetails(source: MangaSource, url: String): Manga? { suspend fun getDetails(source: MangaSource, url: String): Manga? {
return detailsCache[Key(source, url)]?.awaitOrNull() return detailsCache[Key(source, url)]?.awaitOrNull()
} }

View File

@@ -33,6 +33,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration18To19 import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration19To20 import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5 import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -58,7 +59,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 20 const val DATABASE_VERSION = 21
@Database( @Database(
entities = [ entities = [
@@ -118,6 +119,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration17To18(), Migration17To18(),
Migration18To19(), Migration18To19(),
Migration19To20(), Migration19To20(),
Migration20To21(),
) )
fun MangaDatabase(context: Context): MangaDatabase = Room fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -11,6 +11,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.explore.data.SourcesSortOrder
@@ -23,6 +24,9 @@ abstract class MangaSourcesDao {
@Query("SELECT source FROM sources WHERE enabled = 1") @Query("SELECT source FROM sources WHERE enabled = 1")
abstract suspend fun findAllEnabledNames(): List<String> abstract suspend fun findAllEnabledNames(): List<String>
@Query("SELECT * FROM sources WHERE added_in >= :version")
abstract suspend fun findAllFromVersion(version: Int): List<MangaSourceEntity>
@Query("SELECT * FROM sources ORDER BY sort_key") @Query("SELECT * FROM sources ORDER BY sort_key")
abstract fun observeAll(): Flow<List<MangaSourceEntity>> abstract fun observeAll(): Flow<List<MangaSourceEntity>>
@@ -68,6 +72,7 @@ abstract class MangaSourcesDao {
source = source, source = source,
isEnabled = isEnabled, isEnabled = isEnabled,
sortKey = getMaxSortKey() + 1, sortKey = getMaxSortKey() + 1,
addedIn = BuildConfig.VERSION_CODE,
) )
upsert(entity) upsert(entity)
} }

View File

@@ -14,4 +14,5 @@ data class MangaSourceEntity(
val source: String, val source: String,
@ColumnInfo(name = "enabled") val isEnabled: Boolean, @ColumnInfo(name = "enabled") val isEnabled: Boolean,
@ColumnInfo(name = "sort_key", index = true) val sortKey: Int, @ColumnInfo(name = "sort_key", index = true) val sortKey: Int,
@ColumnInfo(name = "added_in") val addedIn: Int,
) )

View File

@@ -4,7 +4,7 @@ import android.content.Context
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
class Migration16To17(context: Context) : Migration(16, 17) { class Migration16To17(context: Context) : Migration(16, 17) {
@@ -15,11 +15,8 @@ class Migration16To17(context: Context) : Migration(16, 17) {
db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)") db.execSQL("CREATE INDEX `index_sources_sort_key` ON `sources` (`sort_key`)")
val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty() val hiddenSources = prefs.getStringSet("sources_hidden", null).orEmpty()
val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty() val order = prefs.getString("sources_order_2", null)?.split('|').orEmpty()
val sources = MangaSource.entries val sources = MangaParserSource.entries
for (source in sources) { for (source in sources) {
if (source == MangaSource.LOCAL) {
continue
}
val name = source.name val name = source.name
val isHidden = name in hiddenSources val isHidden = name in hiddenSources
var sortKey = order.indexOf(name) var sortKey = order.indexOf(name)

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration20To21 : Migration(20, 21) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE tracks ADD COLUMN `last_error` TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE sources ADD COLUMN `added_in` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -1,36 +1,48 @@
package org.koitharu.kotatsu.core.exceptions.resolve package org.koitharu.kotatsu.core.exceptions.resolve
import android.content.Context
import android.widget.Toast
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.MutableScatterMap
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 dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseActivity.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.TaggedActivityResult import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
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.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import java.security.cert.CertPathValidatorException
import javax.net.ssl.SSLException
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> { class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
private val continuations = ArrayMap<String, Continuation<Boolean>>(1) private val continuations = MutableScatterMap<String, Continuation<Boolean>>(1)
private val activity: FragmentActivity? private val activity: FragmentActivity?
private val fragment: Fragment? private val fragment: Fragment?
private val sourceAuthContract: ActivityResultLauncher<MangaSource> private val sourceAuthContract: ActivityResultLauncher<MangaSource>
private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException> private val cloudflareContract: ActivityResultLauncher<CloudFlareProtectedException>
val context: Context?
get() = activity ?: fragment?.context
constructor(activity: FragmentActivity) { constructor(activity: FragmentActivity) {
this.activity = activity this.activity = activity
fragment = null fragment = null
@@ -56,6 +68,12 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
suspend fun resolve(e: Throwable): Boolean = when (e) { suspend fun resolve(e: Throwable): Boolean = when (e) {
is CloudFlareProtectedException -> resolveCF(e) is CloudFlareProtectedException -> resolveCF(e)
is AuthRequiredException -> resolveAuthException(e.source) is AuthRequiredException -> resolveAuthException(e.source)
is SSLException,
is CertPathValidatorException -> {
showSslErrorDialog()
false
}
is NotFoundException -> { is NotFoundException -> {
openInBrowser(e.url) openInBrowser(e.url)
false false
@@ -80,13 +98,37 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
} }
private fun openInBrowser(url: String) { private fun openInBrowser(url: String) {
val context = activity ?: fragment?.activity ?: return context?.run {
context.startActivity(BrowserActivity.newIntent(context, url, null, null)) startActivity(BrowserActivity.newIntent(this, url, null, null))
}
} }
private fun openAlternatives(manga: Manga) { private fun openAlternatives(manga: Manga) {
val context = activity ?: fragment?.activity ?: return context?.run {
context.startActivity(AlternativesActivity.newIntent(context, manga)) startActivity(AlternativesActivity.newIntent(this, manga))
}
}
private fun showSslErrorDialog() {
val ctx = context ?: return
val settings = getAppSettings(ctx)
if (settings.isSSLBypassEnabled) {
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
return
}
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.ignore_ssl_errors)
.setMessage(R.string.ignore_ssl_errors_summary)
.setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
ctx.findActivity()?.finishAffinity()
}.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun getAppSettings(context: Context): AppSettings {
return EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(context).settings
} }
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager) private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
@@ -99,6 +141,9 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
is AuthRequiredException -> R.string.sign_in is AuthRequiredException -> R.string.sign_in
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0 is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0 is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
is SSLException,
is CertPathValidatorException -> R.string.fix
else -> 0 else -> 0
} }

View File

@@ -11,7 +11,6 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
@@ -109,7 +108,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
} }
val Manga.isLocal: Boolean val Manga.isLocal: Boolean
get() = source == MangaSource.LOCAL get() = source == LocalMangaSource
val Manga.appUrl: Uri val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon() get() = Uri.parse("https://kotatsu.app/manga").buildUpon()

View File

@@ -7,26 +7,41 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan import android.text.style.SuperscriptSpan
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
fun MangaSource(name: String): MangaSource { data object LocalMangaSource : MangaSource {
MangaSource.entries.forEach { override val name = "LOCAL"
if (it.name == name) return it
}
return MangaSource.DUMMY
} }
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI data object UnknownMangaSource : MangaSource {
override val name = "UNKNOWN"
}
fun MangaSource(name: String?): MangaSource {
when (name) {
null,
UnknownMangaSource.name -> UnknownMangaSource
LocalMangaSource.name -> LocalMangaSource
}
MangaParserSource.entries.forEach {
if (it.name == name) return it
}
return UnknownMangaSource
}
fun MangaSource.isNsfw() = when (this) {
is MangaParserSource -> contentType == ContentType.HENTAI
else -> false
}
@get:StringRes @get:StringRes
val ContentType.titleResId val ContentType.titleResId
@@ -37,23 +52,23 @@ val ContentType.titleResId
ContentType.OTHER -> R.string.content_type_other ContentType.OTHER -> R.string.content_type_other
} }
fun MangaSource.getSummary(context: Context): String { fun MangaSource.getSummary(context: Context): String? = when (this) {
val type = context.getString(contentType.titleResId) is MangaParserSource -> {
val locale = locale?.toLocale().getDisplayName(context) val type = context.getString(contentType.titleResId)
return context.getString(R.string.source_summary_pattern, type, locale) val locale = locale.toLocale().getDisplayName(context)
} context.getString(R.string.source_summary_pattern, type, locale)
fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) {
buildSpannedString {
append(title)
append(' ')
appendNsfwLabel(context)
} }
} else {
title else -> null
} }
private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( fun MangaSource.getTitle(context: Context): String = when (this) {
is MangaParserSource -> title
LocalMangaSource -> context.getString(R.string.local_storage)
else -> context.getString(R.string.unknown)
}
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)), ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
SuperscriptSpan(), SuperscriptSpan(),

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import kotlinx.parcelize.Parceler
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaSource
class MangaSourceParceler : Parceler<MangaSource> {
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
override fun MangaSource.write(parcel: Parcel, flags: Int) {
parcel.writeString(name)
}
}

View File

@@ -4,9 +4,8 @@ import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize @Parcelize
data class ParcelableChapter( data class ParcelableChapter(
@@ -25,8 +24,8 @@ data class ParcelableChapter(
scanlator = parcel.readString(), scanlator = parcel.readString(),
uploadDate = parcel.readLong(), uploadDate = parcel.readLong(),
branch = parcel.readString(), branch = parcel.readString(),
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY, source = MangaSource(parcel.readString()),
) ),
) )
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) { override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
@@ -38,7 +37,7 @@ data class ParcelableChapter(
parcel.writeString(scanlator) parcel.writeString(scanlator)
parcel.writeLong(uploadDate) parcel.writeLong(uploadDate)
parcel.writeString(branch) parcel.writeString(branch)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }
} }

View File

@@ -5,6 +5,7 @@ import android.os.Parcelable
import androidx.core.os.ParcelCompat import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -30,7 +31,7 @@ data class ParcelableManga(
parcel.writeParcelable(ParcelableMangaTags(tags), flags) parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state) parcel.writeSerializable(state)
parcel.writeString(author) parcel.writeString(author)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
override fun create(parcel: Parcel) = ParcelableManga( override fun create(parcel: Parcel) = ParcelableManga(
@@ -49,8 +50,8 @@ data class ParcelableManga(
state = parcel.readSerializableCompat(), state = parcel.readSerializableCompat(),
author = parcel.readString(), author = parcel.readString(),
chapters = null, chapters = null,
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) ),
) )
} }
} }

View File

@@ -5,7 +5,7 @@ import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
object MangaPageParceler : Parceler<MangaPage> { object MangaPageParceler : Parceler<MangaPage> {
@@ -13,14 +13,14 @@ object MangaPageParceler : Parceler<MangaPage> {
id = parcel.readLong(), id = parcel.readLong(),
url = requireNotNull(parcel.readString()), url = requireNotNull(parcel.readString()),
preview = parcel.readString(), preview = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) )
override fun MangaPage.write(parcel: Parcel, flags: Int) { override fun MangaPage.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id) parcel.writeLong(id)
parcel.writeString(url) parcel.writeString(url)
parcel.writeString(preview) parcel.writeString(preview)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }

View File

@@ -5,20 +5,20 @@ import android.os.Parcelable
import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
object MangaTagParceler : Parceler<MangaTag> { object MangaTagParceler : Parceler<MangaTag> {
override fun create(parcel: Parcel) = MangaTag( override fun create(parcel: Parcel) = MangaTag(
title = requireNotNull(parcel.readString()), title = requireNotNull(parcel.readString()),
key = requireNotNull(parcel.readString()), key = requireNotNull(parcel.readString()),
source = requireNotNull(parcel.readSerializableCompat()), source = MangaSource(parcel.readString()),
) )
override fun MangaTag.write(parcel: Parcel, flags: Int) { override fun MangaTag.write(parcel: Parcel, flags: Int) {
parcel.writeString(title) parcel.writeString(title)
parcel.writeString(key) parcel.writeString(key)
parcel.writeSerializable(source) parcel.writeString(source.name)
} }
} }

View File

@@ -4,8 +4,10 @@ import android.util.Log
import dagger.Lazy import dagger.Lazy
import okhttp3.Headers import okhttp3.Headers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Interceptor.Chain
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.IOException
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -13,6 +15,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mergeWith import org.koitharu.kotatsu.parsers.util.mergeWith
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.IDN import java.net.IDN
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -23,7 +26,7 @@ class CommonHeadersInterceptor @Inject constructor(
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>, private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
) : Interceptor { ) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Chain): Response {
val request = chain.request() val request = chain.request()
val source = request.tag(MangaSource::class.java) val source = request.tag(MangaSource::class.java)
val repository = if (source != null) { val repository = if (source != null) {
@@ -46,7 +49,7 @@ class CommonHeadersInterceptor @Inject constructor(
headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/") headersBuilder.trySet(CommonHeaders.REFERER, "https://$idn/")
} }
val newRequest = request.newBuilder().headers(headersBuilder.build()).build() val newRequest = request.newBuilder().headers(headersBuilder.build()).build()
return repository?.intercept(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest) return repository?.interceptSafe(ProxyChain(chain, newRequest)) ?: chain.proceed(newRequest)
} }
private fun Headers.Builder.trySet(name: String, value: String) = try { private fun Headers.Builder.trySet(name: String, value: String) = try {
@@ -55,10 +58,21 @@ class CommonHeadersInterceptor @Inject constructor(
e.printStackTraceDebug() e.printStackTraceDebug()
} }
private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable {
intercept(chain)
}.getOrElse { e ->
if (e is IOException) {
throw e
} else {
// only IOException can be safely thrown from an Interceptor
throw IOException("Error in interceptor: ${e.message}", e)
}
}
private class ProxyChain( private class ProxyChain(
private val delegate: Interceptor.Chain, private val delegate: Chain,
private val request: Request, private val request: Request,
) : Interceptor.Chain by delegate { ) : Chain by delegate {
override fun request(): Request = request override fun request(): Request = request
} }

View File

@@ -83,6 +83,11 @@ class DoHManager(
tryGetByIp("2a10:50c0::2:ff"), tryGetByIp("2a10:50c0::2:ff"),
), ),
).build() ).build()
DoHProvider.ZERO_MS -> DnsOverHttps.Builder().client(bootstrapClient)
.url("https://2ca4h4crra.cloudflare-gateway.com/dns-query".toHttpUrl())
.resolvePublicAddresses(true)
.build()
} }
private fun tryGetByIp(ip: String): InetAddress? = try { private fun tryGetByIp(ip: String): InetAddress? = try {

View File

@@ -2,5 +2,5 @@ package org.koitharu.kotatsu.core.network
enum class DoHProvider { enum class DoHProvider {
NONE, GOOGLE, CLOUDFLARE, ADGUARD NONE, GOOGLE, CLOUDFLARE, ADGUARD, ZERO_MS
} }

View File

@@ -1,106 +0,0 @@
package org.koitharu.kotatsu.core.network
import android.util.Log
import androidx.collection.ArraySet
import coil.intercept.Interceptor
import coil.request.ErrorResult
import coil.request.ImageResult
import coil.request.SuccessResult
import coil.size.Dimension
import coil.size.isOriginal
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Collections
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ImageProxyInterceptor @Inject constructor(
private val settings: AppSettings,
) : Interceptor {
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val request = chain.request
if (!settings.isImagesProxyEnabled) {
return chain.proceed(request)
}
val url: HttpUrl? = when (val data = request.data) {
is HttpUrl -> data
is String -> data.toHttpUrlOrNull()
else -> null
}
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
return chain.proceed(request)
}
val newUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", url.toString())
.addQueryParameter("we", null)
val size = request.sizeResolver.size()
if (!size.isOriginal) {
newUrl.addQueryParameter("crop", "cover")
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
}
val newRequest = request.newBuilder()
.data(newUrl.build())
.build()
val result = chain.proceed(newRequest)
return if (result is SuccessResult) {
result
} else {
logDebug((result as? ErrorResult)?.throwable)
chain.proceed(request).also {
if (it is SuccessResult) {
blacklist.add(url.host)
}
}
}
}
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
if (!settings.isImagesProxyEnabled) {
return okHttp.newCall(request).await()
}
val sourceUrl = request.url
val targetUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", sourceUrl.toString())
.addQueryParameter("we", null)
val newRequest = request.newBuilder()
.url(targetUrl.build())
.build()
return runCatchingCancellable {
okHttp.doCall(newRequest)
}.recover {
logDebug(it)
okHttp.doCall(request).also {
blacklist.add(sourceUrl.host)
}
}.getOrThrow()
}
private suspend fun OkHttpClient.doCall(request: Request): Response {
return newCall(request).await().ensureSuccess()
}
private fun logDebug(e: Throwable?) {
if (BuildConfig.DEBUG) {
Log.w("ImageProxy", e.toString())
}
}
}

View File

@@ -15,6 +15,7 @@ import okhttp3.internal.publicsuffix.PublicSuffixDatabase
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.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
@@ -26,8 +27,8 @@ class MirrorSwitchInterceptor @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : Interceptor { ) : Interceptor {
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java) private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java) private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
val isEnabled: Boolean val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable get() = settings.isMirrorSwitchingAvailable
@@ -145,15 +146,15 @@ class MirrorSwitchInterceptor @Inject constructor(
return source().readByteArray().toResponseBody(contentType()) return source().readByteArray().toResponseBody(contentType())
} }
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) { private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
Any() Any()
} }
private fun isBlacklisted(source: MangaSource, domain: String): Boolean { private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true return blacklist[source]?.contains(domain) == true
} }
private fun addToBlacklist(source: MangaSource, domain: String) { private fun addToBlacklist(source: MangaParserSource, domain: String) {
blacklist.getOrPut(source) { blacklist.getOrPut(source) {
ArraySet(2) ArraySet(2)
}.add(domain) }.add(domain)

View File

@@ -15,6 +15,8 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar import org.koitharu.kotatsu.core.network.cookies.AndroidCookieJar
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread
import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.data.LocalStorageManager
@@ -29,6 +31,9 @@ interface NetworkModule {
@Binds @Binds
fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar fun bindCookieJar(androidCookieJar: MutableCookieJar): CookieJar
@Binds
fun bindImageProxyInterceptor(impl: RealImageProxyInterceptor): ImageProxyInterceptor
companion object { companion object {
@Provides @Provides

View File

@@ -0,0 +1,87 @@
package org.koitharu.kotatsu.core.network.imageproxy
import android.util.Log
import androidx.collection.ArraySet
import coil.intercept.Interceptor
import coil.network.HttpException
import coil.request.ErrorResult
import coil.request.ImageRequest
import coil.request.ImageResult
import coil.request.SuccessResult
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.HttpURLConnection
import java.util.Collections
abstract class BaseImageProxyInterceptor : ImageProxyInterceptor {
private val blacklist = Collections.synchronizedSet(ArraySet<String>())
final override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val request = chain.request
val url: HttpUrl? = when (val data = request.data) {
is HttpUrl -> data
is String -> data.toHttpUrlOrNull()
else -> null
}
if (url == null || !url.isHttpOrHttps || url.host in blacklist) {
return chain.proceed(request)
}
val newRequest = onInterceptImageRequest(request, url)
return when (val result = chain.proceed(newRequest)) {
is SuccessResult -> result
is ErrorResult -> {
logDebug(result.throwable, newRequest.data)
chain.proceed(request).also {
if (it is SuccessResult && result.throwable.isBlockedByServer()) {
blacklist.add(url.host)
}
}
}
}
}
final override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
val newRequest = onInterceptPageRequest(request)
return runCatchingCancellable {
okHttp.doCall(newRequest)
}.recover { error ->
logDebug(error, newRequest.url)
okHttp.doCall(request).also {
if (error.isBlockedByServer()) {
blacklist.add(request.url.host)
}
}
}.getOrThrow()
}
protected abstract suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest
protected abstract suspend fun onInterceptPageRequest(request: Request): Request
private suspend fun OkHttpClient.doCall(request: Request): Response {
return newCall(request).await().ensureSuccess()
}
private fun logDebug(e: Throwable, url: Any) {
if (BuildConfig.DEBUG) {
Log.w("ImageProxy", "${e.message}: $url", e)
}
}
private fun Throwable.isBlockedByServer(): Boolean {
return this is CloudFlareBlockedException
|| (this is HttpException && response.code == HttpURLConnection.HTTP_FORBIDDEN)
|| (this is HttpStatusException && statusCode == HttpURLConnection.HTTP_FORBIDDEN)
}
}

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
interface ImageProxyInterceptor : Interceptor {
suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response
}

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.intercept.Interceptor
import coil.request.ImageResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.plus
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.util.await
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RealImageProxyInterceptor @Inject constructor(
private val settings: AppSettings,
) : ImageProxyInterceptor {
private val delegate = settings.observeAsStateFlow(
scope = processLifecycleScope + Dispatchers.Default,
key = AppSettings.KEY_IMAGES_PROXY,
valueProducer = { createDelegate() },
)
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
return delegate.value?.intercept(chain) ?: chain.proceed(chain.request)
}
override suspend fun interceptPageRequest(request: Request, okHttp: OkHttpClient): Response {
return delegate.value?.interceptPageRequest(request, okHttp) ?: okHttp.newCall(request).await()
}
private fun createDelegate(): ImageProxyInterceptor? = when (val proxy = settings.imagesProxy) {
-1 -> null
0 -> WsrvNlProxyInterceptor()
1 -> ZeroMsProxyInterceptor()
else -> error("Unsupported images proxy $proxy")
}
}

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest
import coil.size.Dimension
import coil.size.isOriginal
import okhttp3.HttpUrl
import okhttp3.Request
class WsrvNlProxyInterceptor : BaseImageProxyInterceptor() {
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
val newUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", url.toString())
.addQueryParameter("we", null)
val size = request.sizeResolver.size()
if (!size.isOriginal) {
newUrl.addQueryParameter("crop", "cover")
(size.height as? Dimension.Pixels)?.let { newUrl.addQueryParameter("h", it.toString()) }
(size.width as? Dimension.Pixels)?.let { newUrl.addQueryParameter("w", it.toString()) }
}
return request.newBuilder()
.data(newUrl.build())
.build()
}
override suspend fun onInterceptPageRequest(request: Request): Request {
val sourceUrl = request.url
val targetUrl = HttpUrl.Builder()
.scheme("https")
.host("wsrv.nl")
.addQueryParameter("url", sourceUrl.toString())
.addQueryParameter("we", null)
return request.newBuilder()
.url(targetUrl.build())
.build()
}
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.core.network.imageproxy
import coil.request.ImageRequest
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
class ZeroMsProxyInterceptor : BaseImageProxyInterceptor() {
override suspend fun onInterceptImageRequest(request: ImageRequest, url: HttpUrl): ImageRequest {
if (url.host == "x.0ms.dev" || url.host == "0ms.dev") {
return request
}
val newUrl = ("https://x.0ms.dev/q70/$url").toHttpUrl()
return request.newBuilder()
.data(newUrl)
.build()
}
override suspend fun onInterceptPageRequest(request: Request): Request {
val newUrl = ("https://x.0ms.dev/q70/${request.url}").toHttpUrl()
return request.newBuilder()
.url(newUrl)
.build()
}
}

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -173,9 +174,10 @@ class AppShortcutManager @Inject constructor(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) }, onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
) )
val title = source.getTitle(context)
ShortcutInfoCompat.Builder(context, source.name) ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title) .setShortLabel(title)
.setLongLabel(source.title) .setLongLabel(title)
.setIcon(icon) .setIcon(icon)
.setLongLived(true) .setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source)) .setIntent(MangaListActivity.newIntent(context, source))

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.core.parser
import android.graphics.Canvas
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.bitmap.Rect
import java.io.OutputStream
import android.graphics.Bitmap as AndroidBitmap
import android.graphics.Rect as AndroidRect
class BitmapWrapper private constructor(
private val androidBitmap: AndroidBitmap,
) : Bitmap {
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
override val height: Int
get() = androidBitmap.height
override val width: Int
get() = androidBitmap.width
override fun drawBitmap(sourceBitmap: Bitmap, src: Rect, dst: Rect) {
val androidSourceBitmap = (sourceBitmap as BitmapWrapper).androidBitmap
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
}
fun compressTo(output: OutputStream) {
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
}
companion object {
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
)
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
)
private fun Rect.toAndroidRect() = AndroidRect(left, top, right, bottom)
}
}

View File

@@ -8,7 +8,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
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 java.util.EnumSet import java.util.EnumSet
@@ -16,7 +16,7 @@ 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(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) { class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost") get() = ConfigKey.Domain("localhost")

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
import java.util.Locale
/**
* This parser is just for parser development, it should not be used in releases
*/
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
override val sortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val states: Set<MangaState>
get() = emptySet()
override val contentRatings: Set<ContentRating>
get() = emptySet()
override var defaultSortOrder: SortOrder
get() = SortOrder.NEWEST
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = false
override val isTagsExclusionSupported: Boolean
get() = false
override val isSearchSupported: Boolean
get() = false
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
override suspend fun getTags(): Set<MangaTag> = stub(null)
override suspend fun getLocales(): Set<Locale> = stub(null)
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("This manga source is not supported", manga)
}
}

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -101,7 +102,7 @@ class MangaDataRepository @Inject constructor(
suspend fun cleanupLocalManga() { suspend fun cleanupLocalManga() {
val dao = db.getMangaDao() val dao = db.getMangaDao()
val broken = dao.findAllBySource(MangaSource.LOCAL.name) val broken = dao.findAllBySource(LocalMangaSource.name)
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false } .filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
if (broken.isNotEmpty()) { if (broken.isNotEmpty()) {
dao.delete(broken.map { it.manga }) dao.delete(broken.map { it.manga })

View File

@@ -4,10 +4,11 @@ import android.net.Uri
import coil.request.CachePolicy import coil.request.CachePolicy
import dagger.Reusable import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -36,7 +37,7 @@ class MangaLinkResolver @Inject constructor(
require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" } require(uri.pathSegments.singleOrNull() == "manga") { "Invalid url" }
val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" } val sourceName = requireNotNull(uri.getQueryParameter("source")) { "Source is not specified" }
val source = MangaSource(sourceName) val source = MangaSource(sourceName)
require(source != MangaSource.DUMMY) { "Manga source $sourceName is not supported" } require(source != UnknownMangaSource) { "Manga source $sourceName is not supported" }
val repo = repositoryFactory.create(source) val repo = repositoryFactory.create(source)
return repo.findExact( return repo.findExact(
url = uri.getQueryParameter("url"), url = uri.getQueryParameter("url"),
@@ -108,7 +109,7 @@ class MangaLinkResolver @Inject constructor(
url = url, url = url,
publicUrl = "", publicUrl = "",
rating = 0.0f, rating = 0.0f,
isNsfw = source.contentType == ContentType.HENTAI, isNsfw = source.isNsfw(),
coverUrl = "", coverUrl = "",
tags = emptySet(), tags = emptySet(),
state = null, state = null,

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.parser
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.util.Base64 import android.util.Base64
import android.webkit.WebView import android.webkit.WebView
import androidx.annotation.MainThread import androidx.annotation.MainThread
@@ -10,15 +11,21 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.Buffer
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.bitmap.Bitmap
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents import org.koitharu.kotatsu.parsers.network.UserAgents
@@ -68,6 +75,27 @@ class MangaLoaderContextImpl @Inject constructor(
return LocaleListCompat.getAdjustedDefault().toList() return LocaleListCompat.getAdjustedDefault().toList()
} }
override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response {
val image = response.requireBody().byteStream()
val opts = BitmapFactory.Options()
opts.inMutable = true
val bitmap = BitmapFactory.decodeStream(image, null, opts) ?: error("Cannot decode bitmap")
val result = redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper
val body = Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
return response.newBuilder()
.body(body)
.build()
}
override fun createBitmap(width: Int, height: Int): Bitmap {
return BitmapWrapper.create(width, height)
}
@MainThread @MainThread
private fun obtainWebView(): WebView { private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also { return webViewCached?.get() ?: WebView(androidContext).also {

View File

@@ -2,12 +2,11 @@ package org.koitharu.kotatsu.core.parser
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.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
fun MangaParser(source: MangaSource, loaderContext: MangaLoaderContext): MangaParser { fun MangaParser(source: MangaParserSource, loaderContext: MangaLoaderContext): MangaParser {
return if (source == MangaSource.DUMMY) { return when (source) {
DummyParser(loaderContext) MangaParserSource.DUMMY -> DummyParser(loaderContext)
} else { else -> loaderContext.newParserInstance(source)
loaderContext.newParserInstance(source)
} }
} }

View File

@@ -1,7 +1,10 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.collection.ArrayMap
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
@@ -10,12 +13,12 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState 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.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.EnumMap
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -61,24 +64,35 @@ interface MangaRepository {
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) { ) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java) private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
@AnyThread @AnyThread
fun create(source: MangaSource): MangaRepository { fun create(source: MangaSource): MangaRepository {
if (source == MangaSource.LOCAL) { when (source) {
return localMangaRepository LocalMangaSource -> return localMangaRepository
UnknownMangaSource -> return EmptyMangaRepository(source)
} }
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
return synchronized(cache) { return synchronized(cache) {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository( val repository = createRepository(source)
parser = MangaParser(source, loaderContext), if (repository != null) {
cache = contentCache, cache[source] = WeakReference(repository)
mirrorSwitchInterceptor = mirrorSwitchInterceptor, repository
) } else {
cache[source] = WeakReference(repository) EmptyMangaRepository(source)
repository }
} }
} }
private fun createRepository(source: MangaSource): MangaRepository? = when (source) {
is MangaParserSource -> RemoteMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
else -> null
}
} }
} }

View File

@@ -28,7 +28,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState 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.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -46,7 +46,7 @@ class RemoteMangaRepository(
private val relatedMangaMutex = MultiMutex<Long>() private val relatedMangaMutex = MultiMutex<Long>()
private val pagesMutex = MultiMutex<Long>() private val pagesMutex = MultiMutex<Long>()
override val source: MangaSource override val source: MangaParserSource
get() = parser.source get() = parser.source
override val sortOrders: Set<SortOrder> override val sortOrders: Set<SortOrder>

View File

@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
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.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.util.withExtraCloseable import org.koitharu.kotatsu.local.data.util.withExtraCloseable
@@ -45,7 +46,7 @@ class FaviconFetcher(
) : Fetcher { ) : Fetcher {
private val diskCacheKey private val diskCacheKey
get() = options.diskCacheKey ?: "${mangaSource.name}[${mangaSource.ordinal}]x${options.size.toCacheKey()}" get() = options.diskCacheKey ?: "${mangaSource.name}x${options.size.toCacheKey()}"
private val fileSystem private val fileSystem
get() = checkNotNull(diskCache.value).fileSystem get() = checkNotNull(diskCache.value).fileSystem
@@ -150,10 +151,6 @@ class FaviconFetcher(
return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK return if (networkResponse != null) DataSource.NETWORK else DataSource.DISK
} }
private fun Response.requireBody(): ResponseBody {
return checkNotNull(body) { "response body == null" }
}
private fun Size.toCacheKey() = buildString { private fun Size.toCacheKey() = buildString {
append(width.toString()) append(width.toString())
append('x') append('x')

View File

@@ -155,6 +155,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isTrackerNotificationsEnabled: Boolean val isTrackerNotificationsEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true) get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
val isTrackerNsfwDisabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
var notificationSound: Uri var notificationSound: Uri
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull() get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
?: Settings.System.DEFAULT_NOTIFICATION_URI ?: Settings.System.DEFAULT_NOTIFICATION_URI
@@ -287,17 +290,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_SOURCES_GRID, true) get() = prefs.getBoolean(KEY_SOURCES_GRID, true)
set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) } set(value) = prefs.edit { putBoolean(KEY_SOURCES_GRID, value) }
val isNewSourcesTipEnabled: Boolean var sourcesVersion: Int
get() = prefs.getBoolean(KEY_SOURCES_NEW, true) get() = prefs.getInt(KEY_SOURCES_VERSION, 0)
set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) }
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false)
val screenshotsPolicy: ScreenshotsPolicy val screenshotsPolicy: ScreenshotsPolicy
get() = runCatching { get() = prefs.getEnumValue(KEY_SCREENSHOTS_POLICY, ScreenshotsPolicy.ALLOW)
val key = prefs.getString(KEY_SCREENSHOTS_POLICY, null)?.uppercase(Locale.ROOT)
if (key == null) ScreenshotsPolicy.ALLOW else ScreenshotsPolicy.valueOf(key)
}.getOrDefault(ScreenshotsPolicy.ALLOW)
var userSpecifiedMangaDirectories: Set<File> var userSpecifiedMangaDirectories: Set<File>
get() { get() {
@@ -380,14 +381,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isImagesProxyEnabled: Boolean val imagesProxy: Int
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) get() {
val raw = prefs.getString(KEY_IMAGES_PROXY, null)?.toIntOrNull()
return raw ?: if (prefs.getBoolean(KEY_IMAGES_PROXY_OLD, false)) 0 else -1
}
val dnsOverHttps: DoHProvider val dnsOverHttps: DoHProvider
get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE) get() = prefs.getEnumValue(KEY_DOH, DoHProvider.NONE)
val isSSLBypassEnabled: Boolean var isSSLBypassEnabled: Boolean
get() = prefs.getBoolean(KEY_SSL_BYPASS, false) get() = prefs.getBoolean(KEY_SSL_BYPASS, false)
set(value) = prefs.edit { putBoolean(KEY_SSL_BYPASS, value) }
val proxyType: Proxy.Type val proxyType: Proxy.Type
get() { get() {
@@ -547,8 +552,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
companion object { companion object {
const val PAGE_SWITCH_VOLUME_KEYS = "volume"
const val TRACK_HISTORY = "history" const val TRACK_HISTORY = "history"
const val TRACK_FAVOURITES = "favourites" const val TRACK_FAVOURITES = "favourites"
@@ -585,6 +588,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_TRACK_CATEGORIES = "track_categories" const val KEY_TRACK_CATEGORIES = "track_categories"
const val KEY_TRACK_WARNING = "track_warning" const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
@@ -597,7 +601,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num" const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app" const val KEY_PROTECT_APP = "protect_app"
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio" const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version"
const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup" const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
@@ -647,9 +650,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PREFETCH_CONTENT = "prefetch_content" const val KEY_PREFETCH_CONTENT = "prefetch_content"
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_SOURCES_GRID = "sources_grid" const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_SOURCES_NEW = "sources_new"
const val KEY_UPDATES_UNSTABLE = "updates_unstable" const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed" const val KEY_TIPS_CLOSED = "tips_closed"
const val KEY_SSL_BYPASS = "ssl_bypass" const val KEY_SSL_BYPASS = "ssl_bypass"
@@ -662,7 +663,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_AUTH = "proxy_auth" const val KEY_PROXY_AUTH = "proxy_auth"
const val KEY_PROXY_LOGIN = "proxy_login" const val KEY_PROXY_LOGIN = "proxy_login"
const val KEY_PROXY_PASSWORD = "proxy_password" const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy" const val KEY_IMAGES_PROXY = "images_proxy_2"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs" const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw" const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga" const val KEY_RELATED_MANGA = "related_manga"
@@ -676,7 +677,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_CF_CONTRAST = "cf_contrast" const val KEY_CF_CONTRAST = "cf_contrast"
const val KEY_CF_INVERTED = "cf_inverted" const val KEY_CF_INVERTED = "cf_inverted"
const val KEY_CF_GRAYSCALE = "cf_grayscale" const val KEY_CF_GRAYSCALE = "cf_grayscale"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_PAGES_TAB = "pages_tab" const val KEY_PAGES_TAB = "pages_tab"
const val KEY_DETAILS_TAB = "details_tab" const val KEY_DETAILS_TAB = "details_tab"
const val KEY_DETAILS_LAST_TAB = "details_last_tab" const val KEY_DETAILS_LAST_TAB = "details_last_tab"
@@ -684,9 +684,19 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PAGES_SAVE_DIR = "pages_dir" const val KEY_PAGES_SAVE_DIR = "pages_dir"
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask" const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
const val KEY_STATS_ENABLED = "stats_on" const val KEY_STATS_ENABLED = "stats_on"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val KEY_FEED_HEADER = "feed_header" const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types" const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
const val KEY_SOURCES_VERSION = "sources_version"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_TRACKER_DEBUG = "tracker_debug"
const val KEY_LOGS_SHARE = "logs_share"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"
} }
} }

View File

@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.prefs
enum class ScreenshotsPolicy { enum class ScreenshotsPolicy {
// Do not rename this // Do not rename this
ALLOW, BLOCK_NSFW, BLOCK_ALL; ALLOW, BLOCK_NSFW, BLOCK_INCOGNITO, BLOCK_ALL;
} }

View File

@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit import androidx.core.content.edit
import okhttp3.internal.isSensitiveHeader
import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.putEnumValue
@@ -12,6 +11,7 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource 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.settings.utils.validation.DomainValidator
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
@@ -31,7 +31,11 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
.ifNullOrEmpty { key.defaultValue } .ifNullOrEmpty { key.defaultValue }
.sanitizeHeaderValue() .sanitizeHeaderValue()
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue } is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue)
?.trim()
?.takeIf { DomainValidator.isValidDomain(it) }
?: key.defaultValue
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue) is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
} as T } as T

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.ui
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.View import android.view.View
@@ -18,6 +19,8 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@@ -25,10 +28,12 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> : abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(), AppCompatActivity(),
ScreenshotPolicyHelper.ContentContainer,
WindowInsetsDelegate.WindowInsetsListener { WindowInsetsDelegate.WindowInsetsListener {
private var isAmoledTheme = false private var isAmoledTheme = false
@@ -92,10 +97,20 @@ abstract class BaseActivity<B : ViewBinding> :
} }
override fun onSupportNavigateUp(): Boolean { override fun onSupportNavigateUp(): Boolean {
if (supportFragmentManager.popBackStackImmediate()) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// TODO fix behavior on Android 14
dispatchNavigateUp()
return true
}
val fm = supportFragmentManager
if (fm.isStateSaved) {
return false return false
} }
dispatchNavigateUp() if (fm.backStackEntryCount > 0) {
fm.popBackStack()
} else {
dispatchNavigateUp()
}
return true return true
} }
@@ -140,6 +155,8 @@ abstract class BaseActivity<B : ViewBinding> :
} }
} }
override fun isNsfwContent(): Flow<Boolean> = flowOf(false)
private fun putDataToExtras(intent: Intent?) { private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data) intent?.putExtra(EXTRA_DATA, intent.data)
} }
@@ -159,6 +176,8 @@ abstract class BaseActivity<B : ViewBinding> :
} }
} }
protected fun hasViewBinding() = ::viewBinding.isInitialized
@EntryPoint @EntryPoint
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface BaseActivityEntryPoint { interface BaseActivityEntryPoint {

View File

@@ -63,7 +63,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
) )
} }
protected fun setTitle(title: CharSequence?) { protected open fun setTitle(title: CharSequence?) {
(activity as? SettingsActivity)?.setSectionTitle(title) (activity as? SettingsActivity)?.setSectionTitle(title)
} }

View File

@@ -68,7 +68,7 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.call(error) errorEvent.call(error)
} }
protected inline suspend fun <T> withLoading(block: () -> T): T = try { protected inline fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment() loadingCounter.increment()
block() block()
} finally { } finally {

View File

@@ -52,6 +52,16 @@ class FastScrollRecyclerView @JvmOverloads constructor(
fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE fastScroller.visibility = if (isFastScrollerEnabled) visibility else GONE
} }
override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
super.setPadding(left, top, right, bottom)
fastScroller.setPadding(left, top, right, bottom)
}
override fun setPaddingRelative(start: Int, top: Int, end: Int, bottom: Int) {
super.setPaddingRelative(start, top, end, bottom)
fastScroller.setPaddingRelative(start, top, end, bottom)
}
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
fastScroller.attachRecyclerView(this) fastScroller.attachRecyclerView(this)

View File

@@ -1,22 +1,34 @@
package org.koitharu.kotatsu.core.ui.sheet package org.koitharu.kotatsu.core.ui.sheet
import android.annotation.SuppressLint
import android.view.View import android.view.View
import android.view.ViewGroup
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
class BottomSheetCollapseCallback( class BottomSheetCollapseCallback(
private val behavior: BottomSheetBehavior<*>, private val sheet: ViewGroup,
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) { private val behavior: BottomSheetBehavior<*> = BottomSheetBehavior.from(sheet),
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED || behavior.state == STATE_HALF_EXPANDED) {
init { init {
behavior.addBottomSheetCallback( behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() { object : BottomSheetBehavior.BottomSheetCallback() {
@SuppressLint("SwitchIntDef")
override fun onStateChanged(view: View, state: Int) { override fun onStateChanged(view: View, state: Int) {
isEnabled = state == STATE_EXPANDED || state == STATE_HALF_EXPANDED when (state) {
STATE_EXPANDED,
STATE_HALF_EXPANDED -> isEnabled = true
STATE_COLLAPSED,
STATE_HIDDEN -> isEnabled = false
}
} }
override fun onSlide(p0: View, p1: Float) = Unit override fun onSlide(p0: View, p1: Float) = Unit
@@ -24,7 +36,11 @@ class BottomSheetCollapseCallback(
) )
} }
override fun handleOnBackPressed() { override fun handleOnBackPressed() = behavior.handleBackInvoked()
behavior.state = STATE_COLLAPSED
} override fun handleOnBackCancelled() = behavior.cancelBackProgress()
override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent)
override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent)
} }

View File

@@ -12,6 +12,8 @@ import com.google.android.material.chip.ChipGroup
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.castOrNull import org.koitharu.kotatsu.core.util.ext.castOrNull
import com.google.android.material.R as materialR
class ChipsView @JvmOverloads constructor( class ChipsView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
@@ -24,7 +26,9 @@ class ChipsView @JvmOverloads constructor(
onChipClickListener?.onChipClick(it as Chip, it.tag) onChipClickListener?.onChipClick(it as Chip, it.tag)
} }
private val chipOnCloseListener = OnClickListener { private val chipOnCloseListener = OnClickListener {
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag) val chip = it as Chip
val data = it.tag
onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data)
} }
private val chipStyle: Int private val chipStyle: Int
var onChipClickListener: OnChipClickListener? = null var onChipClickListener: OnChipClickListener? = null
@@ -48,7 +52,7 @@ class ChipsView @JvmOverloads constructor(
if (isInEditMode) { if (isInEditMode) {
setChips( setChips(
List(5) { List(5) {
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false) ChipModel(title = "Chip $it")
}, },
) )
} }
@@ -99,6 +103,15 @@ class ChipsView @JvmOverloads constructor(
chip.isChipIconVisible = true chip.isChipIconVisible = true
} }
chip.isChecked = model.isChecked chip.isChecked = model.isChecked
chip.isCheckedIconVisible = chip.isCheckable && model.icon == 0
chip.isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
chip.setCloseIconResource(
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
)
true
} else {
false
}
chip.tag = model.data chip.tag = model.data
} }
@@ -106,12 +119,11 @@ class ChipsView @JvmOverloads constructor(
val chip = Chip(context) val chip = Chip(context)
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle) val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
chip.setChipDrawable(drawable) chip.setChipDrawable(drawable)
chip.isCheckedIconVisible = true
chip.isChipIconVisible = false chip.isChipIconVisible = false
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false) chip.setEnsureMinTouchTargetSize(false)
chip.setOnClickListener(chipOnClickListener) chip.setOnClickListener(chipOnClickListener)
chip.isElegantTextHeight = false
addView(chip) addView(chip)
return chip return chip
} }
@@ -127,11 +139,12 @@ class ChipsView @JvmOverloads constructor(
} }
data class ChipModel( data class ChipModel(
@ColorRes val tint: Int,
val title: CharSequence, val title: CharSequence,
@DrawableRes val icon: Int, @DrawableRes val icon: Int = 0,
val isCheckable: Boolean, val isCheckable: Boolean = false,
val isChecked: Boolean, @ColorRes val tint: Int = 0,
val isChecked: Boolean = false,
val isDropdown: Boolean = false,
val data: Any? = null, val data: Any? = null,
) )

View File

@@ -1,14 +1,27 @@
package org.koitharu.kotatsu.core.util package org.koitharu.kotatsu.core.util
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.core.util.ext.map import org.koitharu.kotatsu.core.util.ext.iterator
import java.util.Locale import java.util.Locale
class LocaleComparator : Comparator<Locale> { class LocaleComparator : Comparator<Locale> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context) private val deviceLocales: List<String>
.map { it.language }
.distinct() init {
val localeList = LocaleListCompat.getAdjustedDefault()
deviceLocales = buildList(localeList.size() + 1) {
add("")
val set = HashSet<String>(localeList.size() + 1)
set.add("")
for (locale in localeList) {
val lang = locale.language
if (set.add(lang)) {
add(lang)
}
}
}
}
override fun compare(a: Locale, b: Locale): Int { override fun compare(a: Locale, b: Locale): Int {
val indexA = deviceLocales.indexOf(a.language) val indexA = deviceLocales.indexOf(a.language)

View File

@@ -1,13 +1,12 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly import okhttp3.internal.closeQuietly
import okhttp3.internal.isSensitiveHeader
import okio.IOException import okio.IOException
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.HttpStatusException import org.jsoup.HttpStatusException
@@ -42,6 +41,8 @@ fun Response.ensureSuccess() = apply {
} }
} }
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c -> fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
c.name(name) c.name(name)
c.value(value) c.value(value)

View File

@@ -22,11 +22,10 @@ fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementE
fun String.toLocale() = Locale(this) fun String.toLocale() = Locale(this)
fun Locale?.getDisplayName(context: Context): String { fun Locale?.getDisplayName(context: Context): String = when (this) {
if (this == null) { null -> context.getString(R.string.all_languages)
return context.getString(R.string.various_languages) Locale.ROOT -> context.getString(R.string.various_languages)
} else -> getDisplayLanguage(this).toTitleCase(this)
return getDisplayLanguage(this).toTitleCase(this)
} }
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> { private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {

View File

@@ -5,7 +5,9 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -62,7 +64,7 @@ class MangaPrefetchService : CoroutineIntentService() {
private suspend fun prefetchLast() { private suspend fun prefetchLast() {
val last = historyRepository.getLastOrNull() ?: return val last = historyRepository.getLastOrNull() ?: return
if (last.source == MangaSource.LOCAL) return if (last.isLocal) return
val repo = mangaRepositoryFactory.create(last.source) val repo = mangaRepositoryFactory.create(last.source)
val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return val details = runCatchingCancellable { repo.getDetails(last) }.getOrNull() ?: return
val chapters = details.chapters val chapters = details.chapters
@@ -110,7 +112,7 @@ class MangaPrefetchService : CoroutineIntentService() {
} }
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
if (source == MangaSource.LOCAL || context.isPowerSaveMode()) { if (source == LocalMangaSource || context.isPowerSaveMode()) {
return false return false
} }
val entryPoint = EntryPointAccessors.fromApplication( val entryPoint = EntryPointAccessors.fromApplication(

View File

@@ -31,17 +31,21 @@ import coil.request.ImageRequest
import coil.request.SuccessResult import coil.request.SuccessResult
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.iconResId import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.model.titleResId
@@ -93,7 +97,6 @@ import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
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.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
@@ -153,8 +156,8 @@ class DetailsActivity :
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance() viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.chipsTags.onChipClickListener = this viewBinding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView) TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
viewBinding.containerBottomSheet?.let { BottomSheetBehavior.from(it) }?.let { behavior -> viewBinding.containerBottomSheet?.let { sheet ->
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(behavior)) onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
} }
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated) viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
@@ -198,6 +201,8 @@ class DetailsActivity :
addMenuProvider(menuProvider) addMenuProvider(menuProvider)
} }
override fun isNsfwContent(): Flow<Boolean> = viewModel.manga.map { it?.isNsfw == true }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false) R.id.button_read -> openReader(isIncognitoMode = false)
@@ -460,10 +465,10 @@ class DetailsActivity :
imageViewState.isVisible = false imageViewState.isVisible = false
} }
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) { if (manga.source == LocalMangaSource || manga.source == UnknownMangaSource) {
infoLayout.chipSource.isVisible = false infoLayout.chipSource.isVisible = false
} else { } else {
infoLayout.chipSource.text = manga.source.title infoLayout.chipSource.text = manga.source.getTitle(this@DetailsActivity)
infoLayout.chipSource.isVisible = true infoLayout.chipSource.isVisible = true
} }
@@ -535,7 +540,7 @@ class DetailsActivity :
} }
val isFirstCall = buttonRead.tag == null val isFirstCall = buttonRead.tag == null
buttonRead.tag = Unit buttonRead.tag = Unit
buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, !isFirstCall) buttonRead.setProgress(info.percent.coerceIn(0f, 1f), !isFirstCall)
buttonDownload?.isEnabled = info.isValid && info.canDownload buttonDownload?.isEnabled = info.isValid && info.canDownload
buttonRead.isEnabled = info.isValid buttonRead.isEnabled = info.isValid
} }
@@ -613,10 +618,7 @@ class DetailsActivity :
ChipsView.ChipModel( ChipsView.ChipModel(
title = tag.title, title = tag.title,
tint = tagHighlighter.getTagTint(tag), tint = tagHighlighter.getTagTint(tag),
icon = 0,
data = tag, data = tag,
isCheckable = false,
isChecked = false,
) )
}, },
) )

View File

@@ -16,11 +16,12 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
@@ -38,10 +39,10 @@ class DetailsMenuProvider(
override fun onPrepareMenu(menu: Menu) { override fun onPrepareMenu(menu: Menu) {
val manga = viewModel.manga.value val manga = viewModel.manga.value
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != MangaSource.LOCAL menu.findItem(R.id.action_alternatives).isVisible = manga?.source != LocalMangaSource
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_scrobbling).isVisible = viewModel.isScrobblingAvailable menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
@@ -53,7 +54,7 @@ class DetailsMenuProvider(
R.id.action_share -> { R.id.action_share -> {
viewModel.manga.value?.let { viewModel.manga.value?.let {
val shareHelper = ShareHelper(activity) val shareHelper = ShareHelper(activity)
if (it.source == MangaSource.LOCAL) { if (it.isLocal) {
shareHelper.shareCbz(listOf(it.url.toUri().toFile())) shareHelper.shareCbz(listOf(it.url.toUri().toFile()))
} else { } else {
shareHelper.shareMangaLink(it) shareHelper.shareMangaLink(it)

View File

@@ -93,15 +93,19 @@ class DetailsViewModel @Inject constructor(
val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) }) val details = MutableStateFlow(intent.manga?.let { MangaDetails(it, null, null, false) })
val manga = details.map { x -> x?.toManga() } val manga = details.map { x -> x?.toManga() }
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val history = historyRepository.observeOne(mangaId) val history = historyRepository.observeOne(mangaId)
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val favouriteCategories = interactor.observeFavourite(mangaId) val favouriteCategories = interactor.observeFavourite(mangaId)
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet())
val isStatsAvailable = statsRepository.observeHasStats(mangaId) val isStatsAvailable = statsRepository.observeHasStats(mangaId)
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val remoteManga = MutableStateFlow<Manga?>(null) val remoteManga = MutableStateFlow<Manga?>(null)
@@ -162,7 +166,7 @@ class DetailsViewModel @Inject constructor(
val onMangaRemoved = MutableEventFlow<Manga>() val onMangaRemoved = MutableEventFlow<Manga>()
val isScrobblingAvailable: Boolean val isScrobblingAvailable: Boolean
get() = scrobblers.any { it.isAvailable } get() = scrobblers.any { it.isEnabled }
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
@@ -393,7 +397,7 @@ class DetailsViewModel @Inject constructor(
private fun getScrobbler(index: Int): Scrobbler? { private fun getScrobbler(index: Int): Scrobbler? {
val info = scrobblingInfo.value.getOrNull(index) val info = scrobblingInfo.value.getOrNull(index)
val scrobbler = if (info != null) { val scrobbler = if (info != null) {
scrobblers.find { it.scrobblerService == info.scrobbler && it.isAvailable } scrobblers.find { it.scrobblerService == info.scrobbler && it.isEnabled }
} else { } else {
null null
} }

View File

@@ -16,6 +16,13 @@ data class HistoryInfo(
val canContinue val canContinue
get() = currentChapter >= 0 get() = currentChapter >= 0
val percent: Float
get() = if (history != null && (canContinue || isChapterMissing)) {
history.percent
} else {
0f
}
} }
fun HistoryInfo( fun HistoryInfo(

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -40,7 +41,6 @@ import org.koitharu.kotatsu.details.ui.withVolumeHeaders
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
@@ -218,7 +218,7 @@ class ChaptersFragment :
var canSave = true var canSave = true
var canDelete = true var canDelete = true
items.forEach { (_, x) -> items.forEach { (_, x) ->
val isLocal = x.isDownloaded || x.chapter.source == MangaSource.LOCAL val isLocal = x.isDownloaded || x.chapter.source == LocalMangaSource
if (isLocal) canSave = false else canDelete = false if (isLocal) canSave = false else canDelete = false
} }
menu.findItem(R.id.action_save).isVisible = canSave menu.findItem(R.id.action_save).isVisible = canSave

View File

@@ -19,8 +19,8 @@ import okhttp3.OkHttpClient
import okio.Path.Companion.toOkioPath import okio.Path.Companion.toOkioPath
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isFileUri import org.koitharu.kotatsu.local.data.isFileUri

View File

@@ -21,13 +21,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
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.util.format import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
@@ -231,7 +231,7 @@ class DownloadNotificationFactory @AssistedInject constructor(
if (manga != null) { if (manga != null) {
DetailsActivity.newIntent(context, manga) DetailsActivity.newIntent(context, manga)
} else { } else {
MangaListActivity.newIntent(context, MangaSource.LOCAL) MangaListActivity.newIntent(context, LocalMangaSource)
}, },
PendingIntent.FLAG_CANCEL_CURRENT, PendingIntent.FLAG_CANCEL_CURRENT,
false, false,

View File

@@ -43,6 +43,7 @@ import okio.sink
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -177,7 +178,7 @@ class DownloadWorker @AssistedInject constructor(
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) } checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
var output: LocalMangaOutput? = null var output: LocalMangaOutput? = null
try { try {
if (manga.source == MangaSource.LOCAL) { if (manga.isLocal) {
manga = localMangaRepository.getRemoteManga(manga) manga = localMangaRepository.getRemoteManga(manga)
?: error("Cannot obtain remote manga instance") ?: error("Cannot obtain remote manga instance")
} }

View File

@@ -6,21 +6,23 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@Reusable @Reusable
@@ -29,39 +31,76 @@ class MangaSourcesRepository @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) { ) {
private val isNewSourcesAssimilated = AtomicBoolean(false)
private val dao: MangaSourcesDao private val dao: MangaSourcesDao
get() = db.getSourcesDao() get() = db.getSourcesDao()
private val remoteSources = EnumSet.allOf(MangaSource::class.java).apply { private val remoteSources = EnumSet.allOf(MangaParserSource::class.java).apply {
remove(MangaSource.LOCAL)
if (!BuildConfig.DEBUG) { if (!BuildConfig.DEBUG) {
remove(MangaSource.DUMMY) remove(MangaParserSource.DUMMY)
} }
} }
val allMangaSources: Set<MangaSource> val allMangaSources: Set<MangaParserSource>
get() = Collections.unmodifiableSet(remoteSources) get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> { suspend fun getEnabledSources(): List<MangaSource> {
assimilateNewSources()
val order = settings.sourcesSortOrder val order = settings.sourcesSortOrder
return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order) return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order)
} }
suspend fun getDisabledSources(): Set<MangaSource> { suspend fun getDisabledSources(): Set<MangaSource> {
assimilateNewSources()
val result = EnumSet.copyOf(remoteSources) val result = EnumSet.copyOf(remoteSources)
val enabled = dao.findAllEnabledNames() val enabled = dao.findAllEnabledNames()
for (name in enabled) { for (name in enabled) {
val source = MangaSource(name) val source = name.toMangaSourceOrNull() ?: continue
result.remove(source) result.remove(source)
} }
if (settings.isNsfwContentDisabled) {
result.removeAll { it.isNsfw() }
}
return result return result
} }
suspend fun getAvailableSources(
isDisabledOnly: Boolean,
isNewOnly: Boolean,
excludeBroken: Boolean,
types: Set<ContentType>,
query: String?,
locale: String?,
sortOrder: SourcesSortOrder?,
): List<MangaParserSource> {
assimilateNewSources()
val entities = dao.findAll().toMutableList()
if (isDisabledOnly) {
entities.removeAll { it.isEnabled }
}
if (isNewOnly) {
entities.retainAll { it.addedIn == BuildConfig.VERSION_CODE }
}
val sources = entities.toSources(
skipNsfwSources = settings.isNsfwContentDisabled,
sortOrder = sortOrder,
)
if (locale != null) {
sources.retainAll { it.locale == locale }
}
if (excludeBroken) {
sources.removeAll { it.isBroken }
}
if (types.isNotEmpty()) {
sources.retainAll { it.contentType in types }
}
if (!query.isNullOrEmpty()) {
sources.retainAll {
it.title.contains(query, ignoreCase = true) || it.name.contains(query, ignoreCase = true)
}
}
return sources
}
fun observeIsEnabled(source: MangaSource): Flow<Boolean> { fun observeIsEnabled(source: MangaSource): Flow<Boolean> {
return dao.observeIsEnabled(source.name) return dao.observeIsEnabled(source.name).onStart { assimilateNewSources() }
} }
fun observeEnabledSourcesCount(): Flow<Int> { fun observeEnabledSourcesCount(): Flow<Int> {
@@ -69,8 +108,10 @@ class MangaSourcesRepository @Inject constructor(
observeIsNsfwDisabled(), observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL), dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, sources -> ) { skipNsfw, sources ->
sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() } sources.count {
}.distinctUntilChanged() it.source.toMangaSourceOrNull()?.let { s -> !skipNsfw || !s.isNsfw() } == true
}
}.distinctUntilChanged().onStart { assimilateNewSources() }
} }
fun observeAvailableSourcesCount(): Flow<Int> { fun observeAvailableSourcesCount(): Flow<Int> {
@@ -82,7 +123,7 @@ class MangaSourcesRepository @Inject constructor(
allMangaSources.count { x -> allMangaSources.count { x ->
x.name !in enabled && (!skipNsfw || !x.isNsfw()) x.name !in enabled && (!skipNsfw || !x.isNsfw())
} }
}.distinctUntilChanged() }.distinctUntilChanged().onStart { assimilateNewSources() }
} }
fun observeEnabledSources(): Flow<List<MangaSource>> = combine( fun observeEnabledSources(): Flow<List<MangaSource>> = combine(
@@ -92,18 +133,18 @@ class MangaSourcesRepository @Inject constructor(
dao.observeEnabled(order).map { dao.observeEnabled(order).map {
it.toSources(skipNsfw, order) it.toSources(skipNsfw, order)
} }
}.flatMapLatest { it } }.flatMapLatest { it }.onStart { assimilateNewSources() }
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities -> fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size) val result = ArrayList<Pair<MangaSource, Boolean>>(entities.size)
for (entity in entities) { for (entity in entities) {
val source = MangaSource(entity.source) val source = entity.source.toMangaSourceOrNull() ?: continue
if (source in remoteSources) { if (source in remoteSources) {
result.add(source to entity.isEnabled) result.add(source to entity.isEnabled)
} }
} }
result result
} }.onStart { assimilateNewSources() }
suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle { suspend fun setSourcesEnabled(sources: Collection<MangaSource>, isEnabled: Boolean): ReversibleHandle {
setSourcesEnabledImpl(sources, isEnabled) setSourcesEnabledImpl(sources, isEnabled)
@@ -114,6 +155,7 @@ class MangaSourcesRepository @Inject constructor(
suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) { suspend fun setSourcesEnabledExclusive(sources: Set<MangaSource>) {
db.withTransaction { db.withTransaction {
assimilateNewSources()
for (s in remoteSources) { for (s in remoteSources) {
dao.setEnabled(s.name, s in sources) dao.setEnabled(s.name, s in sources)
} }
@@ -135,31 +177,34 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
fun observeNewSources(): Flow<Set<MangaSource>> = observeIsNewSourcesEnabled().flatMapLatest { fun observeHasNewSources(): Flow<Boolean> = observeIsNsfwDisabled().map { skipNsfw ->
if (it) { val sources = dao.findAllFromVersion(BuildConfig.VERSION_CODE).toSources(skipNsfw, null)
combine( sources.isNotEmpty() && sources.size != remoteSources.size
dao.observeAll(), }.onStart { assimilateNewSources() }
observeIsNsfwDisabled(),
) { entities, skipNsfw -> fun observeHasNewSourcesForBadge(): Flow<Boolean> = combine(
val result = EnumSet.copyOf(remoteSources) settings.observeAsFlow(AppSettings.KEY_SOURCES_VERSION) { sourcesVersion },
for (e in entities) { observeIsNsfwDisabled(),
result.remove(MangaSource(e.source)) ) { version, skipNsfw ->
} if (version < BuildConfig.VERSION_CODE) {
if (skipNsfw) { val sources = dao.findAllFromVersion(version).toSources(skipNsfw, null)
result.removeAll { x -> x.isNsfw() } sources.isNotEmpty()
}
result
}.distinctUntilChanged()
} else { } else {
assimilateNewSources() false
flowOf(emptySet())
} }
}.onStart { assimilateNewSources() }
fun clearNewSourcesBadge() {
settings.sourcesVersion = BuildConfig.VERSION_CODE
} }
suspend fun assimilateNewSources(): Set<MangaSource> { private suspend fun assimilateNewSources(): Boolean {
if (isNewSourcesAssimilated.getAndSet(true)) {
return false
}
val new = getNewSources() val new = getNewSources()
if (new.isEmpty()) { if (new.isEmpty()) {
return emptySet() return false
} }
var maxSortKey = dao.getMaxSortKey() var maxSortKey = dao.getMaxSortKey()
val entities = new.map { x -> val entities = new.map { x ->
@@ -167,17 +212,15 @@ class MangaSourcesRepository @Inject constructor(
source = x.name, source = x.name,
isEnabled = false, isEnabled = false,
sortKey = ++maxSortKey, sortKey = ++maxSortKey,
addedIn = BuildConfig.VERSION_CODE,
) )
} }
dao.insertIfAbsent(entities) dao.insertIfAbsent(entities)
if (settings.isNsfwContentDisabled) { return true
new.removeAll { x -> x.isNsfw() }
}
return new
} }
suspend fun isSetupRequired(): Boolean { suspend fun isSetupRequired(): Boolean {
return dao.findAll().isEmpty() return settings.sourcesVersion == 0 && dao.findAllEnabledNames().isEmpty()
} }
private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) { private suspend fun setSourcesEnabledImpl(sources: Collection<MangaSource>, isEnabled: Boolean) {
@@ -192,11 +235,11 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
private suspend fun getNewSources(): MutableSet<MangaSource> { private suspend fun getNewSources(): MutableSet<out MangaSource> {
val entities = dao.findAll() val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources) val result = EnumSet.copyOf(remoteSources)
for (e in entities) { for (e in entities) {
result.remove(MangaSource(e.source)) result.remove(e.source.toMangaSourceOrNull() ?: continue)
} }
return result return result
} }
@@ -204,10 +247,10 @@ class MangaSourcesRepository @Inject constructor(
private fun List<MangaSourceEntity>.toSources( private fun List<MangaSourceEntity>.toSources(
skipNsfwSources: Boolean, skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?, sortOrder: SourcesSortOrder?,
): List<MangaSource> { ): MutableList<MangaParserSource> {
val result = ArrayList<MangaSource>(size) val result = ArrayList<MangaParserSource>(size)
for (entity in this) { for (entity in this) {
val source = MangaSource(entity.source) val source = entity.source.toMangaSourceOrNull() ?: continue
if (skipNsfwSources && source.isNsfw()) { if (skipNsfwSources && source.isNsfw()) {
continue continue
} }
@@ -225,11 +268,9 @@ class MangaSourcesRepository @Inject constructor(
isNsfwContentDisabled isNsfwContentDisabled
} }
private fun observeIsNewSourcesEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_NEW) {
isNewSourcesTipEnabled
}
private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) { private fun observeSortOrder() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ORDER) {
sourcesSortOrder sourcesSortOrder
} }
private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.explore.domain package org.koitharu.kotatsu.explore.domain
import org.koitharu.kotatsu.core.model.isNsfw
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.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.almostEquals import org.koitharu.kotatsu.core.util.ext.almostEquals
@@ -7,7 +8,6 @@ import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -45,7 +45,7 @@ class ExploreRepository @Inject constructor(
suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga { suspend fun findRandomManga(source: MangaSource, tagsLimit: Int): Manga {
val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f) val tagsBlacklist = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f)
val skipNsfw = settings.isSuggestionsExcludeNsfw && source.contentType != ContentType.HENTAI val skipNsfw = settings.isSuggestionsExcludeNsfw && !source.isNsfw()
val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull { val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
if (it in tagsBlacklist) null else it.title if (it in tagsBlacklist) null else it.title
} }

View File

@@ -20,6 +20,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
@@ -27,7 +28,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver
import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -40,13 +40,11 @@ import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.TipModel
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.MangaParserSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
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.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import javax.inject.Inject import javax.inject.Inject
@@ -56,7 +54,7 @@ class ExploreFragment :
BaseFragment<FragmentExploreBinding>(), BaseFragment<FragmentExploreBinding>(),
RecyclerViewOwner, RecyclerViewOwner,
ExploreListEventListener, ExploreListEventListener,
OnListItemClickListener<MangaSourceItem>, TipView.OnButtonClickListener, ListSelectionController.Callback2 { OnListItemClickListener<MangaSourceItem>, ListSelectionController.Callback2 {
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@@ -74,7 +72,7 @@ class ExploreFragment :
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this, this) { manga, view -> exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this) { manga, view ->
startActivity(DetailsActivity.newIntent(view.context, manga)) startActivity(DetailsActivity.newIntent(view.context, manga))
} }
sourceSelectionController = ListSelectionController( sourceSelectionController = ListSelectionController(
@@ -124,21 +122,9 @@ class ExploreFragment :
} }
} }
override fun onPrimaryButtonClick(tipView: TipView) {
when ((tipView.tag as? TipModel)?.key) {
ExploreViewModel.TIP_NEW_SOURCES -> NewSourcesDialogFragment.show(childFragmentManager)
}
}
override fun onSecondaryButtonClick(tipView: TipView) {
when ((tipView.tag as? TipModel)?.key) {
ExploreViewModel.TIP_NEW_SOURCES -> viewModel.discardNewSources()
}
}
override fun onClick(v: View) { override fun onClick(v: View) {
val intent = when (v.id) { val intent = when (v.id) {
R.id.button_local -> MangaListActivity.newIntent(v.context, MangaSource.LOCAL) R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource)
R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context) R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
R.id.button_more -> SuggestionsActivity.newIntent(v.context) R.id.button_more -> SuggestionsActivity.newIntent(v.context)
R.id.button_downloads -> DownloadsActivity.newIntent(v.context) R.id.button_downloads -> DownloadsActivity.newIntent(v.context)
@@ -188,7 +174,7 @@ class ExploreFragment :
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean { override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id -> val selectedSources = controller.peekCheckedIds().mapNotNullToSet { id ->
MangaSource.entries.getOrNull(id.toInt()) MangaParserSource.entries.getOrNull(id.toInt()) // TODO
} }
if (selectedSources.isEmpty()) { if (selectedSources.isEmpty()) {
return false return false

View File

@@ -102,12 +102,6 @@ class ExploreViewModel @Inject constructor(
} }
} }
fun discardNewSources() {
launchJob(Dispatchers.Default) {
sourcesRepository.assimilateNewSources()
}
}
fun requestPinShortcut(source: MangaSource) { fun requestPinShortcut(source: MangaSource) {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
shortcutManager.requestPinShortcut(source) shortcutManager.requestPinShortcut(source)
@@ -124,7 +118,7 @@ class ExploreViewModel @Inject constructor(
getSuggestionFlow(), getSuggestionFlow(),
isGrid, isGrid,
isRandomLoading, isRandomLoading,
sourcesRepository.observeNewSources(), sourcesRepository.observeHasNewSourcesForBadge(),
) { content, suggestions, grid, randomLoading, newSources -> ) { content, suggestions, grid, randomLoading, newSources ->
buildList(content, suggestions, grid, randomLoading, newSources) buildList(content, suggestions, grid, randomLoading, newSources)
}.withErrorHandling() }.withErrorHandling()
@@ -134,7 +128,7 @@ class ExploreViewModel @Inject constructor(
recommendation: List<Manga>, recommendation: List<Manga>,
isGrid: Boolean, isGrid: Boolean,
randomLoading: Boolean, randomLoading: Boolean,
newSources: Set<MangaSource>, hasNewSources: Boolean,
): List<ListModel> { ): List<ListModel> {
val result = ArrayList<ListModel>(sources.size + 3) val result = ArrayList<ListModel>(sources.size + 3)
result += ExploreButtons(randomLoading) result += ExploreButtons(randomLoading)
@@ -146,7 +140,7 @@ class ExploreViewModel @Inject constructor(
result += ListHeader( result += ListHeader(
textRes = R.string.remote_sources, textRes = R.string.remote_sources,
buttonTextRes = R.string.catalog, buttonTextRes = R.string.catalog,
badge = if (newSources.isNotEmpty()) "" else null, badge = if (hasNewSources) "" else null,
) )
sources.mapTo(result) { MangaSourceItem(it, isGrid) } sources.mapTo(result) { MangaSourceItem(it, isGrid) }
} else { } else {
@@ -191,6 +185,5 @@ class ExploreViewModel @Inject constructor(
private const val TIP_SUGGESTIONS = "suggestions" private const val TIP_SUGGESTIONS = "suggestions"
private const val SUGGESTIONS_COUNT = 8 private const val SUGGESTIONS_COUNT = 8
const val TIP_NEW_SOURCES = "new_sources"
} }
} }

View File

@@ -4,13 +4,11 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.adapter.tipAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -18,7 +16,6 @@ class ExploreAdapter(
coil: ImageLoader, coil: ImageLoader,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
listener: ExploreListEventListener, listener: ExploreListEventListener,
tipClickListener: TipView.OnButtonClickListener,
clickListener: OnListItemClickListener<MangaSourceItem>, clickListener: OnListItemClickListener<MangaSourceItem>,
mangaClickListener: OnListItemClickListener<Manga>, mangaClickListener: OnListItemClickListener<Manga>,
) : BaseListAdapter<ListModel>() { ) : BaseListAdapter<ListModel>() {
@@ -34,6 +31,5 @@ class ExploreAdapter(
addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner)) addDelegate(ListItemType.EXPLORE_SOURCE_GRID, exploreSourceGridItemAD(coil, clickListener, lifecycleOwner))
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.TIP, tipAD(tipClickListener))
} }
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.explore.ui.model package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -8,8 +9,7 @@ data class MangaSourceItem(
val isGrid: Boolean, val isGrid: Boolean,
) : ListModel { ) : ListModel {
val id: Long val id: Long = source.name.longHashCode()
get() = source.ordinal.toLong()
override fun areItemsTheSame(other: ListModel): Boolean { override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaSourceItem && other.source == source return other is MangaSourceItem && other.source == source

View File

@@ -27,15 +27,20 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit") @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
abstract suspend fun findLast(limit: Int): List<FavouriteManga> abstract suspend fun findLast(limit: Int): List<FavouriteManga>
fun observeAll(order: ListSortOrder): Flow<List<FavouriteManga>> { fun observeAll(order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
val query = buildString {
@Language("RoomSql") append(
val query = SimpleSQLiteQuery( "SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " + "WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy", )
) append(orderBy)
return observeAllImpl(query) if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}
return observeAllImpl(SimpleSQLiteQuery(query))
} }
@Transaction @Transaction
@@ -52,16 +57,21 @@ abstract class FavouritesDao {
) )
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga> abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<FavouriteManga>> { fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)
val query = buildString {
append(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
)
append(orderBy)
if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}
@Language("RoomSql") return observeAllImpl(SimpleSQLiteQuery(query, arrayOf<Any>(categoryId)))
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
arrayOf<Any>(categoryId),
)
return observeAllImpl(query)
} }
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> { suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {

View File

@@ -38,8 +38,8 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList() return entities.toMangaList()
} }
fun observeAll(order: ListSortOrder): Flow<List<Manga>> { fun observeAll(order: ListSortOrder, limit: Int): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(order) return db.getFavouritesDao().observeAll(order, limit)
.mapItems { it.toManga() } .mapItems { it.toManga() }
} }
@@ -48,14 +48,14 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList() return entities.toMangaList()
} }
fun observeAll(categoryId: Long, order: ListSortOrder): Flow<List<Manga>> { fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(categoryId, order) return db.getFavouritesDao().observeAll(categoryId, order, limit)
.mapItems { it.toManga() } .mapItems { it.toManga() }
} }
fun observeAll(categoryId: Long): Flow<List<Manga>> { fun observeAll(categoryId: Long, limit: Int): Flow<List<Manga>> {
return observeOrder(categoryId) return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order) } .flatMapLatest { order -> observeAll(categoryId, order, limit) }
} }
fun observeMangaCount(): Flow<Int> { fun observeMangaCount(): Flow<Int> {
@@ -63,12 +63,6 @@ class FavouritesRepository @Inject constructor(
.distinctUntilChanged() .distinctUntilChanged()
} }
suspend fun getCategories(): List<FavouriteCategory> {
return db.getFavouriteCategoriesDao().findAll().map {
it.toFavouriteCategory()
}
}
fun observeCategories(): Flow<List<FavouriteCategory>> { fun observeCategories(): Flow<List<FavouriteCategory>> {
return db.getFavouriteCategoriesDao().observeAll().mapItems { return db.getFavouriteCategoriesDao().observeAll().mapItems {
it.toFavouriteCategory() it.toFavouriteCategory()

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.favourites.domain.model package org.koitharu.kotatsu.favourites.domain.model
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.parsers.util.find
data class Cover( data class Cover(
val url: String, val url: String,
val source: String, val source: String,
) { ) {
val mangaSource: MangaSource? val mangaSource by lazy { MangaSource(source) }
get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
} }

View File

@@ -10,13 +10,13 @@ import androidx.fragment.app.viewModels
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 org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint @AndroidEntryPoint
class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener { class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickListener {
@@ -33,7 +33,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
binding.recyclerView.isVP2BugWorkaroundEnabled = true binding.recyclerView.isVP2BugWorkaroundEnabled = true
} }
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = viewModel.requestMoreItems()
override fun onFilterClick(view: View?) { override fun onFilterClick(view: View?) {
val menu = PopupMenu(view?.context ?: return, view) val menu = PopupMenu(view?.context ?: return, view)
@@ -57,9 +57,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
} }
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }

View File

@@ -32,8 +32,11 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
private const val PAGE_SIZE = 20
@HiltViewModel @HiltViewModel
class FavouritesListViewModel @Inject constructor( class FavouritesListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
@@ -46,6 +49,8 @@ class FavouritesListViewModel @Inject constructor(
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
private val refreshTrigger = MutableStateFlow(Any()) private val refreshTrigger = MutableStateFlow(Any())
private val limit = MutableStateFlow(PAGE_SIZE)
private val isReady = AtomicBoolean(false)
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode } override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
@@ -61,13 +66,7 @@ class FavouritesListViewModel @Inject constructor(
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
override val content = combine( override val content = combine(
if (categoryId == NO_ID) { observeFavorites(),
sortOrder.filterNotNull().flatMapLatest {
repository.observeAll(it)
}
} else {
repository.observeAll(categoryId)
},
listMode, listMode,
refreshTrigger, refreshTrigger,
) { list, mode, _ -> ) { list, mode, _ ->
@@ -85,7 +84,10 @@ class FavouritesListViewModel @Inject constructor(
), ),
) )
else -> list.toUi(mode, listExtraProvider) else -> {
isReady.set(true)
list.toUi(mode, listExtraProvider)
}
} }
}.catch { }.catch {
emit(listOf(it.toErrorState(canRetry = false))) emit(listOf(it.toErrorState(canRetry = false)))
@@ -126,4 +128,19 @@ class FavouritesListViewModel @Inject constructor(
repository.setCategoryOrder(categoryId, order) repository.setCategoryOrder(categoryId, order)
} }
} }
fun requestMoreItems() {
if (isReady.compareAndSet(true, false)) {
limit.value += PAGE_SIZE
}
}
private fun observeFavorites() = if (categoryId == NO_ID) {
combine(sortOrder.filterNotNull(), limit, ::Pair)
.flatMapLatest { repository.observeAll(it.first, it.second) }
} else {
limit.flatMapLatest {
repository.observeAll(categoryId, it)
}
}
} }

View File

@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState 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.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -257,7 +258,7 @@ class FilterCoordinator @Inject constructor(
} }
oldValue.copy( oldValue.copy(
tagsExclude = newTags, tagsExclude = newTags,
tags = oldValue.tags - newTags tags = oldValue.tags - newTags,
) )
} }
} }
@@ -308,7 +309,7 @@ class FilterCoordinator @Inject constructor(
currentState.update { oldValue -> currentState.update { oldValue ->
oldValue.copy( oldValue.copy(
tags = tags, tags = tags,
tagsExclude = oldValue.tagsExclude - tags tagsExclude = oldValue.tagsExclude - tags,
) )
} }
} }
@@ -391,9 +392,7 @@ class FilterCoordinator @Inject constructor(
val result = LinkedList<ChipsView.ChipModel>() val result = LinkedList<ChipsView.ChipModel>()
for (tag in tags) { for (tag in tags) {
val model = ChipsView.ChipModel( val model = ChipsView.ChipModel(
tint = 0,
title = tag.title, title = tag.title,
icon = 0,
isCheckable = true, isCheckable = true,
isChecked = selectedTags.remove(tag), isChecked = selectedTags.remove(tag),
data = tag, data = tag,
@@ -406,9 +405,7 @@ class FilterCoordinator @Inject constructor(
} }
for (tag in selectedTags) { for (tag in selectedTags) {
val model = ChipsView.ChipModel( val model = ChipsView.ChipModel(
tint = 0,
title = tag.title, title = tag.title,
icon = 0,
isCheckable = true, isCheckable = true,
isChecked = true, isChecked = true,
data = tag, data = tag,
@@ -455,7 +452,7 @@ class FilterCoordinator @Inject constructor(
} }
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> { private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
val result = TreeSet(TagTitleComparator(repository.source.locale)) val result = TreeSet(TagTitleComparator((repository.source as? MangaParserSource)?.locale))
result.addAll(secondary) result.addAll(secondary)
result.addAll(primary) result.addAll(primary)
return result return result

View File

@@ -61,10 +61,7 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
} }
private fun moreTagsChip() = ChipsView.ChipModel( private fun moreTagsChip() = ChipsView.ChipModel(
tint = 0,
title = getString(R.string.more), title = getString(R.string.more),
icon = materialR.drawable.abc_ic_menu_overflow_material, icon = materialR.drawable.abc_ic_menu_overflow_material,
isCheckable = false,
isChecked = false,
) )
} }

View File

@@ -17,6 +17,7 @@ import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getDisplayName
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showDistinct
@@ -29,7 +30,6 @@ import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.MangaState 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.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale import java.util.Locale
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -122,10 +122,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
b.spinnerLocale.context, b.spinnerLocale.context,
android.R.layout.simple_spinner_dropdown_item, android.R.layout.simple_spinner_dropdown_item,
android.R.id.text1, android.R.id.text1,
value.availableItems.map { value.availableItems.map { it.getDisplayName(b.spinnerLocale.context) },
it?.getDisplayLanguage(it)?.toTitleCase(it)
?: b.spinnerLocale.context.getString(R.string.various_languages)
},
) )
val selectedIndex = value.availableItems.indexOf(selected) val selectedIndex = value.availableItems.indexOf(selected)
if (selectedIndex >= 0) { if (selectedIndex >= 0) {
@@ -144,9 +141,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1) val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
value.selectedItems.mapTo(chips) { tag -> value.selectedItems.mapTo(chips) { tag ->
ChipsView.ChipModel( ChipsView.ChipModel(
tint = 0,
title = tag.title, title = tag.title,
icon = 0,
isCheckable = true, isCheckable = true,
isChecked = true, isChecked = true,
data = tag, data = tag,
@@ -155,9 +150,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
value.availableItems.mapNotNullTo(chips) { tag -> value.availableItems.mapNotNullTo(chips) { tag ->
if (tag !in value.selectedItems) { if (tag !in value.selectedItems) {
ChipsView.ChipModel( ChipsView.ChipModel(
tint = 0,
title = tag.title, title = tag.title,
icon = 0,
isCheckable = true, isCheckable = true,
isChecked = false, isChecked = false,
data = tag, data = tag,
@@ -168,12 +161,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
} }
chips.add( chips.add(
ChipsView.ChipModel( ChipsView.ChipModel(
tint = 0,
title = getString(R.string.more), title = getString(R.string.more),
icon = materialR.drawable.abc_ic_menu_overflow_material, icon = materialR.drawable.abc_ic_menu_overflow_material,
isCheckable = false,
isChecked = false,
data = null,
), ),
) )
b.chipsGenres.setChips(chips) b.chipsGenres.setChips(chips)
@@ -200,9 +189,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
value.availableItems.mapNotNullTo(chips) { tag -> value.availableItems.mapNotNullTo(chips) { tag ->
if (tag !in value.selectedItems) { if (tag !in value.selectedItems) {
ChipsView.ChipModel( ChipsView.ChipModel(
tint = 0,
title = tag.title, title = tag.title,
icon = 0,
isCheckable = true, isCheckable = true,
isChecked = false, isChecked = false,
data = tag, data = tag,
@@ -213,12 +200,8 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
} }
chips.add( chips.add(
ChipsView.ChipModel( ChipsView.ChipModel(
tint = 0,
title = getString(R.string.more), title = getString(R.string.more),
icon = materialR.drawable.abc_ic_menu_overflow_material, icon = materialR.drawable.abc_ic_menu_overflow_material,
isCheckable = false,
isChecked = false,
data = null,
), ),
) )
b.chipsGenresExclude.setChips(chips) b.chipsGenresExclude.setChips(chips)
@@ -233,9 +216,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
} }
val chips = value.availableItems.map { state -> val chips = value.availableItems.map { state ->
ChipsView.ChipModel( ChipsView.ChipModel(
tint = 0,
title = getString(state.titleResId), title = getString(state.titleResId),
icon = 0,
isCheckable = true, isCheckable = true,
isChecked = state in value.selectedItems, isChecked = state in value.selectedItems,
data = state, data = state,
@@ -253,9 +234,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
} }
val chips = value.availableItems.map { contentRating -> val chips = value.availableItems.map { contentRating ->
ChipsView.ChipModel( ChipsView.ChipModel(
tint = 0,
title = getString(contentRating.titleResId), title = getString(contentRating.titleResId),
icon = 0,
isCheckable = true, isCheckable = true,
isChecked = contentRating in value.selectedItems, isChecked = contentRating in value.selectedItems,
data = contentRating, data = contentRating,

View File

@@ -9,7 +9,6 @@ import androidx.room.Transaction
import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
@@ -28,8 +27,7 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit") @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>> abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
// TODO pagination fun observeAll(order: ListSortOrder, limit: Int): Flow<List<HistoryWithManga>> {
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
val orderBy = when (order) { val orderBy = when (order) {
ListSortOrder.LAST_READ -> "history.updated_at DESC" ListSortOrder.LAST_READ -> "history.updated_at DESC"
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC" ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
@@ -43,13 +41,18 @@ abstract class HistoryDao {
ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC" ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
else -> throw IllegalArgumentException("Sort order $order is not supported") else -> throw IllegalArgumentException("Sort order $order is not supported")
} }
val query = buildString {
@Language("RoomSql") append(
val query = SimpleSQLiteQuery( "SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " + "WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY ",
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY $orderBy", )
) append(orderBy)
return observeAllImpl(query) if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}
return observeAllImpl(SimpleSQLiteQuery(query))
} }
@Query("SELECT manga_id FROM history WHERE deleted_at = 0") @Query("SELECT manga_id FROM history WHERE deleted_at = 0")

View File

@@ -74,8 +74,8 @@ class HistoryRepository @Inject constructor(
} }
} }
fun observeAllWithHistory(order: ListSortOrder): Flow<List<MangaWithHistory>> { fun observeAllWithHistory(order: ListSortOrder, limit: Int): Flow<List<MangaWithHistory>> {
return db.getHistoryDao().observeAll(order).mapItems { return db.getHistoryDao().observeAll(order, limit).mapItems {
MangaWithHistory( MangaWithHistory(
it.manga.toManga(it.tags.toMangaTags()), it.manga.toManga(it.tags.toMangaTags()),
it.history.toMangaHistory(), it.history.toMangaHistory(),

View File

@@ -8,6 +8,7 @@ import androidx.fragment.app.viewModels
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 org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkManageIntent import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
@@ -17,7 +18,6 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.parsers.model.MangaSource
@AndroidEntryPoint @AndroidEntryPoint
class HistoryListFragment : MangaListFragment() { class HistoryListFragment : MangaListFragment() {
@@ -32,7 +32,7 @@ class HistoryListFragment : MangaListFragment() {
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
} }
override fun onScrolledToEnd() = Unit override fun onScrolledToEnd() = viewModel.requestMoreItems()
override fun onEmptyActionClick() { override fun onEmptyActionClick() {
startActivity(NetworkManageIntent()) startActivity(NetworkManageIntent())
@@ -44,9 +44,7 @@ class HistoryListFragment : MangaListFragment() {
} }
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { menu.findItem(R.id.action_save)?.isVisible = selectedItems.none { it.isLocal }
it.source == MangaSource.LOCAL
}
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.history.ui
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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -43,8 +44,11 @@ import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant import java.time.Instant
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
private const val PAGE_SIZE = 20
@HiltViewModel @HiltViewModel
class HistoryListViewModel @Inject constructor( class HistoryListViewModel @Inject constructor(
private val repository: HistoryRepository, private val repository: HistoryRepository,
@@ -62,8 +66,11 @@ class HistoryListViewModel @Inject constructor(
valueProducer = { historySortOrder }, valueProducer = { historySortOrder },
) )
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_HISTORY) { historyListMode } override val listMode = settings.observeAsStateFlow(
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.historyListMode) scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_LIST_MODE_HISTORY,
valueProducer = { historyListMode },
)
private val isGroupingEnabled = settings.observeAsFlow( private val isGroupingEnabled = settings.observeAsFlow(
key = AppSettings.KEY_HISTORY_GROUPING, key = AppSettings.KEY_HISTORY_GROUPING,
@@ -72,6 +79,9 @@ class HistoryListViewModel @Inject constructor(
g && s.isGroupingSupported() g && s.isGroupingSupported()
} }
private val limit = MutableStateFlow(PAGE_SIZE)
private val isReady = AtomicBoolean(false)
val isStatsEnabled = settings.observeAsStateFlow( val isStatsEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_STATS_ENABLED, key = AppSettings.KEY_STATS_ENABLED,
@@ -79,7 +89,7 @@ class HistoryListViewModel @Inject constructor(
) )
override val content = combine( override val content = combine(
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) }, observeHistory(),
isGroupingEnabled, isGroupingEnabled,
listMode, listMode,
networkState, networkState,
@@ -95,7 +105,10 @@ class HistoryListViewModel @Inject constructor(
), ),
) )
else -> mapList(list, grouped, mode, online, incognito) else -> {
isReady.set(true)
mapList(list, grouped, mode, online, incognito)
}
} }
}.onStart { }.onStart {
loadingCounter.increment() loadingCounter.increment()
@@ -138,6 +151,15 @@ class HistoryListViewModel @Inject constructor(
} }
} }
fun requestMoreItems() {
if (isReady.compareAndSet(true, false)) {
limit.value += PAGE_SIZE
}
}
private fun observeHistory() = combine(sortOrder, limit, ::Pair)
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second) }
private suspend fun mapList( private suspend fun mapList(
list: List<MangaWithHistory>, list: List<MangaWithHistory>,
grouped: Boolean, grouped: Boolean,

View File

@@ -7,11 +7,12 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.marginBottom
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.ImageLoader import coil.ImageLoader
import coil.request.CachePolicy import coil.request.CachePolicy
import coil.request.ErrorResult import coil.request.ErrorResult
@@ -20,17 +21,26 @@ import coil.request.SuccessResult
import coil.target.ViewTarget import coil.target.ViewTarget
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayIcon
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ActivityImageBinding import org.koitharu.kotatsu.databinding.ActivityImageBinding
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint @AndroidEntryPoint
class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listener, View.OnClickListener { class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listener, View.OnClickListener {
@@ -39,27 +49,45 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
private var errorBinding: ItemErrorStateBinding? = null private var errorBinding: ItemErrorStateBinding? = null
private val viewModel: ImageViewModel by viewModels()
private lateinit var menuMediator: PopupMenuMediator
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityImageBinding.inflate(layoutInflater)) setContentView(ActivityImageBinding.inflate(layoutInflater))
viewBinding.buttonBack.setOnClickListener(this) viewBinding.buttonBack.setOnClickListener(this)
loadImage(intent.data) viewBinding.buttonMenu.setOnClickListener(this)
val imageUrl = requireNotNull(intent.data)
val menuProvider = ImageMenuProvider(
activity = this,
snackbarHost = viewBinding.root,
viewModel = viewModel,
)
menuMediator = PopupMenuMediator(menuProvider)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.root, null))
viewModel.onImageSaved.observeEvent(this, ::onImageSaved)
loadImage(imageUrl)
} }
override fun onWindowInsetsChanged(insets: Insets) { override fun onWindowInsetsChanged(insets: Insets) {
with(viewBinding.buttonBack) { viewBinding.buttonBack.updateLayoutParams<ViewGroup.MarginLayoutParams> {
updateLayoutParams<ViewGroup.MarginLayoutParams> { topMargin = insets.top + bottomMargin
topMargin = insets.top + marginBottom leftMargin = insets.left + bottomMargin
leftMargin = insets.left + marginBottom rightMargin = insets.right + bottomMargin
rightMargin = insets.right + marginBottom }
} viewBinding.buttonMenu.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top + bottomMargin
leftMargin = insets.left + bottomMargin
rightMargin = insets.right + bottomMargin
} }
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_back -> dispatchNavigateUp() R.id.button_back -> dispatchNavigateUp()
R.id.button_menu -> menuMediator.onLongClick(v)
else -> loadImage(intent.data) else -> loadImage(intent.data)
} }
} }
@@ -92,11 +120,34 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
.memoryCachePolicy(CachePolicy.DISABLED) .memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this) .lifecycle(this)
.listener(this) .listener(this)
.tag(intent.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)) .source(MangaSource(intent.getStringExtra(EXTRA_SOURCE)))
.target(SsivTarget(viewBinding.ssiv)) .target(SsivTarget(viewBinding.ssiv))
.enqueueWith(coil) .enqueueWith(coil)
} }
private fun onImageSaved(uri: Uri) {
Snackbar.make(viewBinding.root, R.string.page_saved, Snackbar.LENGTH_LONG)
.setAction(R.string.share) {
ShareHelper(this).shareImage(uri)
}.show()
}
private fun onLoadingStateChanged(isLoading: Boolean) {
val button = viewBinding.buttonMenu
button.isClickable = !isLoading
if (isLoading) {
button.setImageDrawable(
CircularProgressDrawable(this).also {
it.setStyle(CircularProgressDrawable.LARGE)
it.setColorSchemeColors(getThemeColor(com.google.android.material.R.attr.colorControlNormal))
it.start()
},
)
} else {
button.setImageResource(materialR.drawable.abc_ic_menu_overflow_material)
}
}
private class SsivTarget( private class SsivTarget(
override val view: SubsamplingScaleImageView, override val view: SubsamplingScaleImageView,
) : ViewTarget<SubsamplingScaleImageView> { ) : ViewTarget<SubsamplingScaleImageView> {
@@ -124,12 +175,12 @@ class ImageActivity : BaseActivity<ActivityImageBinding>(), ImageRequest.Listene
companion object { companion object {
private const val EXTRA_SOURCE = "source" const val EXTRA_SOURCE = "source"
fun newIntent(context: Context, url: String, source: MangaSource?): Intent { fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
return Intent(context, ImageActivity::class.java) return Intent(context, ImageActivity::class.java)
.setData(Uri.parse(url)) .setData(Uri.parse(url))
.putExtra(EXTRA_SOURCE, source) .putExtra(EXTRA_SOURCE, source?.name)
} }
} }
} }

View File

@@ -0,0 +1,68 @@
package org.koitharu.kotatsu.image.ui
import android.Manifest
import android.os.Build
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuProvider
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.local.data.isZipUri
class ImageMenuProvider(
private val activity: ComponentActivity,
private val snackbarHost: View,
private val viewModel: ImageViewModel,
) : MenuProvider {
private val permissionLauncher = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted ->
if (isGranted) {
saveImage()
}
}
private val saveLauncher = activity.registerForActivityResult(
ActivityResultContracts.CreateDocument("image/png"),
) { uri ->
if (uri != null) {
viewModel.saveImage(uri)
}
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_image, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_save -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
saveImage()
}
true
}
else -> false
}
private fun saveImage() {
val name = activity.intent.data?.let {
if (it.isZipUri()) {
it.fragment
} else {
it.lastPathSegment
}?.substringBeforeLast('.')?.plus(".png")
}
if (name == null || !saveLauncher.tryLaunch(name)) {
Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
}
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.image.ui
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.SavedStateHandle
import coil.ImageLoader
import coil.request.CachePolicy
import coil.request.ImageRequest
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.source
import javax.inject.Inject
@HiltViewModel
class ImageViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val savedStateHandle: SavedStateHandle,
private val coil: ImageLoader,
) : BaseViewModel() {
val onImageSaved = MutableEventFlow<Uri>()
fun saveImage(destination: Uri) {
launchLoadingJob(Dispatchers.Default) {
val request = ImageRequest.Builder(context)
.memoryCachePolicy(CachePolicy.READ_ONLY)
.data(savedStateHandle.require<Uri>(BaseActivity.EXTRA_DATA))
.memoryCachePolicy(CachePolicy.DISABLED)
.source(savedStateHandle[ImageActivity.EXTRA_SOURCE])
.build()
val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap()
runInterruptible(Dispatchers.IO) {
context.contentResolver.openOutputStream(destination)?.use { output ->
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
} ?: error("Cannot open output stream")
}
onImageSaved.call(destination)
}
}
}

View File

@@ -37,9 +37,6 @@ suspend fun Manga.toListDetailedModel(
ChipsView.ChipModel( ChipsView.ChipModel(
tint = extraProvider?.getTagTint(it) ?: 0, tint = extraProvider?.getTagTint(it) ?: 0,
title = it.title, title = it.title,
icon = 0,
isCheckable = false,
isChecked = false,
data = it, data = it,
) )
}, },

View File

@@ -85,10 +85,7 @@ class PreviewViewModel @Inject constructor(
ChipsView.ChipModel( ChipsView.ChipModel(
title = tag.title, title = tag.title,
tint = extraProvider.getTagTint(tag), tint = extraProvider.getTagTint(tag),
icon = 0,
data = tag, data = tag,
isCheckable = false,
isChecked = false,
) )
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())

View File

@@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
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.core.prefs.AppSettings
@@ -29,7 +30,6 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState 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.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -49,7 +49,7 @@ class LocalMangaRepository @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : MangaRepository { ) : MangaRepository {
override val source = MangaSource.LOCAL override val source = LocalMangaSource
private val locks = MultiMutex<Long>() private val locks = MultiMutex<Long>()
private val localMappingCache = LocalMangaMappingCache() private val localMappingCache = LocalMangaMappingCache()
@@ -100,7 +100,7 @@ class LocalMangaRepository @Inject constructor(
} }
override suspend fun getDetails(manga: Manga): Manga = when { override suspend fun getDetails(manga: Manga): Manga = when {
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)?.manga) { !manga.isLocal -> requireNotNull(findSavedManga(manga)?.manga) {
"Manga is not local or saved" "Manga is not local or saved"
} }
@@ -227,9 +227,11 @@ class LocalMangaRepository @Inject constructor(
}.filterNotNullTo(ArrayList(files.size)) }.filterNotNullTo(ArrayList(files.size))
} }
private suspend fun getAllFiles() = storageManager.getReadableDirs().asSequence().flatMap { dir -> private suspend fun getAllFiles() = storageManager.getReadableDirs()
dir.children() .asSequence()
} .flatMap { dir ->
dir.children().filterNot { it.isHidden }
}
private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga } private fun Collection<LocalManga>.unwrap(): List<Manga> = map { it.manga }
} }

View File

@@ -4,6 +4,7 @@ import androidx.annotation.WorkerThread
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -58,7 +59,7 @@ class MangaIndex(source: String?) {
} }
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching { fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
val source = MangaSource.valueOf(json.getString("source")) val source = MangaSource(json.getString("source"))
Manga( Manga(
id = json.getLong("id"), id = json.getLong("id"),
title = json.getString("title"), title = json.getString("title"),

View File

@@ -4,6 +4,7 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.children import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.creationTime import org.koitharu.kotatsu.core.util.ext.creationTime
@@ -18,7 +19,6 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File import java.io.File
import java.util.TreeMap import java.util.TreeMap
@@ -47,7 +47,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(), index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
) )
val manga = info?.copy2( val manga = info?.copy2(
source = MangaSource.LOCAL, source = LocalMangaSource,
url = mangaUri, url = mangaUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
@@ -59,14 +59,14 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
// old downloads // old downloads
chapterFiles.values.elementAtOrNull(i) chapterFiles.values.elementAtOrNull(i)
} ?: return@mapIndexedNotNull null } ?: return@mapIndexedNotNull null
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL) c.copy(url = file.toUri().toString(), source = LocalMangaSource)
}, },
) ?: Manga( ) ?: Manga(
id = root.absolutePath.longHashCode(), id = root.absolutePath.longHashCode(),
title = root.name.toHumanReadable(), title = root.name.toHumanReadable(),
url = mangaUri, url = mangaUri,
publicUrl = mangaUri, publicUrl = mangaUri,
source = MangaSource.LOCAL, source = LocalMangaSource,
coverUrl = findFirstImageEntry().orEmpty(), coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.values.mapIndexed { i, f -> chapters = chapterFiles.values.mapIndexed { i, f ->
MangaChapter( MangaChapter(
@@ -74,7 +74,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
name = f.nameWithoutExtension.toHumanReadable(), name = f.nameWithoutExtension.toHumanReadable(),
number = 0f, number = 0f,
volume = 0, volume = 0,
source = MangaSource.LOCAL, source = LocalMangaSource,
uploadDate = f.creationTime, uploadDate = f.creationTime,
url = f.toUri().toString(), url = f.toUri().toString(),
scanlator = null, scanlator = null,
@@ -106,7 +106,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name }) .toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map { .map {
val pageUri = it.toUri().toString() val pageUri = it.toUri().toString()
MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL) MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
} }
} else { } else {
ZipFile(file).use { zip -> ZipFile(file).use { zip ->
@@ -121,7 +121,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
id = pageUri.longHashCode(), id = pageUri.longHashCode(),
url = pageUri, url = pageUri,
preview = null, preview = null,
source = MangaSource.LOCAL, source = LocalMangaSource,
) )
} }
} }

View File

@@ -7,6 +7,7 @@ import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.longHashCode import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.util.ext.readText
@@ -17,7 +18,6 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File import java.io.File
import java.util.Enumeration import java.util.Enumeration
@@ -47,12 +47,12 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(), entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
) )
return@use info.copy2( return@use info.copy2(
source = MangaSource.LOCAL, source = LocalMangaSource,
url = fileUri, url = fileUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
chapters = info.chapters?.map { c -> chapters = info.chapters?.map { c ->
c.copy(url = fileUri, source = MangaSource.LOCAL) c.copy(url = fileUri, source = LocalMangaSource)
}, },
) )
} }
@@ -70,7 +70,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
title = title, title = title,
url = fileUri, url = fileUri,
publicUrl = fileUri, publicUrl = fileUri,
source = MangaSource.LOCAL, source = LocalMangaSource,
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()), coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
chapters = chapters.sortedWith(AlphanumComparator()) chapters = chapters.sortedWith(AlphanumComparator())
.mapIndexed { i, s -> .mapIndexed { i, s ->
@@ -79,7 +79,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
name = s.ifEmpty { title }, name = s.ifEmpty { title },
number = 0f, number = 0f,
volume = 0, volume = 0,
source = MangaSource.LOCAL, source = LocalMangaSource,
uploadDate = 0L, uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString(), url = uriBuilder.fragment(s).build().toString(),
scanlator = null, scanlator = null,
@@ -135,7 +135,7 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
id = entryUri.longHashCode(), id = entryUri.longHashCode(),
url = entryUri, url = entryUri,
preview = null, preview = null,
source = MangaSource.LOCAL, source = LocalMangaSource,
) )
} }
} }

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