Compare commits

...

101 Commits

Author SHA1 Message Date
Koitharu
b46c00f2d0 Fix parsers version 2024-10-05 16:28:29 +03:00
Anon
9358617a3a Translated using Weblate (Serbian)
Currently translated at 100.0% (729 of 729 strings)

Translated using Weblate (Serbian)

Currently translated at 96.4% (703 of 729 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
Draken
ba9f31835f Translated using Weblate (Vietnamese)
Currently translated at 100.0% (732 of 732 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (729 of 729 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
abc0922001
357308bfbb Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 90.5% (660 of 729 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
gekka
cab56209c1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (732 of 732 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (729 of 729 strings)

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

Translated using Weblate (Turkish)

Currently translated at 100.0% (729 of 729 strings)

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

Translated using Weblate (Spanish)

Currently translated at 100.0% (729 of 729 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
Infy's Tagalog Translations
a57fcce72b Translated using Weblate (Filipino)
Currently translated at 98.3% (717 of 729 strings)

Translated using Weblate (Filipino)

Currently translated at 98.3% (716 of 728 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-10-05 16:03:55 +03:00
Priit Jõerüüt
2e2a818c05 Translated using Weblate (Estonian)
Currently translated at 71.0% (520 of 732 strings)

Translated using Weblate (Estonian)

Currently translated at 67.9% (495 of 728 strings)

Translated using Weblate (Estonian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/et/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-10-05 16:03:55 +03:00
Matt
b6f618101f Translated using Weblate (Portuguese)
Currently translated at 98.7% (719 of 728 strings)

Co-authored-by: Matt <contact.mattdev@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-10-05 16:03:55 +03:00
Koitharu
0ce368751a Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2024-10-05 16:03:07 +03:00
Koitharu
1d28538893 Fixes batch 2024-10-05 16:02:49 +03:00
Koitharu
4ad2f3f608 Fixes batch 2024-10-04 14:27:01 +03:00
Koitharu
5301cc7f97 Ability to start download paused 2024-10-04 11:20:49 +03:00
Koitharu
1290db4a7c Fixes batch 2024-10-04 10:23:49 +03:00
Koitharu
1f1309d934 Increase source add button size 2024-10-03 14:32:34 +03:00
Koitharu
350f1521a6 Fix warnings and cleanup 2024-10-03 13:08:09 +03:00
mnv
cebce20bed Fix MangaSource import and improve user agent handling
MangaSource class was imported twice from different packages.
2024-10-03 09:16:30 +03:00
Koitharu
e5b6947586 Fix StrictMode errors 2024-10-02 16:39:21 +03:00
Koitharu
ac96c49b60 Strict mode notificaiton for debug build 2024-10-02 15:13:54 +03:00
Koitharu
a4345a40bf Update dependencies 2024-10-02 11:34:51 +03:00
Koitharu
f518acb8ee Skip error for local manga list (close #1113, close #1115) 2024-09-29 19:46:48 +03:00
大王叫我来巡山
b39a51d497 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (728 of 728 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
Felipe Nascimento
8819d8b1ee Translated using Weblate (Portuguese)
Currently translated at 98.6% (718 of 728 strings)

Co-authored-by: Felipe Nascimento <f.kgb@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
Draken
05a502b89a Translated using Weblate (Vietnamese)
Currently translated at 100.0% (728 of 728 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
gekka
c320e3c26a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (723 of 724 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
Matt
938849c31e Translated using Weblate (Japanese)
Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Matt <contact.mattdev@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ja/
Translation: Kotatsu/plurals
2024-09-29 19:43:34 +03:00
Oğuz Ersen
95c243daa1 Translated using Weblate (Turkish)
Currently translated at 100.0% (728 of 728 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
gallegonovato
6ce6a02b56 Translated using Weblate (Spanish)
Currently translated at 100.0% (728 of 728 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-29 19:43:34 +03:00
Koitharu
e92e9fb393 Update SSIV 2024-09-29 19:43:09 +03:00
Koitharu
f4186a2787 Remove loggers and reorganize settings 2024-09-27 14:40:31 +03:00
Koitharu
8b93b699d3 Update readme 2024-09-26 16:02:52 +03:00
Koitharu
7e13482ba5 Translated using Weblate (Russian)
Currently translated at 99.7% (722 of 724 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
gallegonovato
04700a22c8 Translated using Weblate (Spanish)
Currently translated at 100.0% (724 of 724 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
Hosted Weblate
549d08cc06 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-09-26 15:34:44 +03:00
Infy's Tagalog Translations
0fccaf3fbc Translated using Weblate (Filipino)
Currently translated at 98.4% (713 of 724 strings)

Translated using Weblate (Filipino)

Currently translated at 98.8% (712 of 720 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-09-26 15:34:44 +03:00
Felipe Nascimento
c7e0a47bee Translated using Weblate (Portuguese)
Currently translated at 98.7% (711 of 720 strings)

Co-authored-by: Felipe Nascimento <f.kgb@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
Макар Разин
d527b6e390 Translated using Weblate (Russian)
Currently translated at 100.0% (720 of 720 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (720 of 720 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/
Translation: Kotatsu/Strings
2024-09-26 15:34:44 +03:00
Koitharu
12b2af6b93 Update parsers 2024-09-26 14:46:36 +03:00
Koitharu
63f4fab40f Fix applying filter 2024-09-26 12:33:35 +03:00
Koitharu
9a444cf965 Migrate external sources to new filter 2024-09-25 12:28:10 +03:00
Koitharu
b8be2f7158 Fix sync auth activity ui 2024-09-25 09:48:17 +03:00
Draken
9e2074040f Translated using Weblate (Vietnamese)
Currently translated at 100.0% (720 of 720 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-25 09:47:57 +03:00
Infy's Tagalog Translations
020c151e31 Translated using Weblate (Filipino)
Currently translated at 98.8% (712 of 720 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-09-25 09:47:57 +03:00
gekka
52eb33a992 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (719 of 720 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-25 09:47:57 +03:00
大王叫我来巡山
907b8fd0ec Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (719 of 720 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-25 09:47:57 +03:00
Oğuz Ersen
e35b2088a1 Translated using Weblate (Turkish)
Currently translated at 100.0% (720 of 720 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-25 09:47:57 +03:00
Faiz Faadhillah
fbb4efb3df Improve Spen integration support 2024-09-25 09:47:03 +03:00
Koitharu
7ff47a322e Update parsers 2024-09-24 17:56:11 +03:00
Koitharu
fda1af5500 Merge branch 'devel' of https://hosted.weblate.org/git/kotatsu/strings into devel 2024-09-24 17:51:14 +03:00
Koitharu
d88847d137 Various fixes 2024-09-24 17:48:13 +03:00
Koitharu
063527b240 Context menus 2024-09-24 17:48:12 +03:00
gallegonovato
b0470110a8 Translated using Weblate (Spanish)
Currently translated at 100.0% (720 of 720 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
2024-09-24 16:46:39 +02:00
Anonymous
5a2a31d1c8 Translated using Weblate (Hungarian)
Currently translated at 86.6% (622 of 718 strings)

Translated using Weblate (Nepali)

Currently translated at 32.3% (232 of 718 strings)

Translated using Weblate (Hindi)

Currently translated at 93.0% (668 of 718 strings)

Translated using Weblate (Portuguese)

Currently translated at 92.7% (666 of 718 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
desu sude
3b009d7c55 Translated using Weblate (Latvian)
Currently translated at 24.4% (175 of 717 strings)

Co-authored-by: desu sude <cobsonslittlecocksleeve@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lv/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Anon
f7e937f2b8 Translated using Weblate (Serbian)
Currently translated at 100.0% (698 of 698 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Milo Ivir
16e23cc1cf Translated using Weblate (Croatian)
Currently translated at 98.7% (689 of 698 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Infy's Tagalog Translations
d12528d80f Translated using Weblate (Filipino)
Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (698 of 698 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-09-24 17:46:34 +03:00
abc0922001
9f04c7b148 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 92.1% (643 of 698 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Amirreza Safavi
7a3942f100 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
gekka
8e46f64f2a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.7% (716 of 718 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (691 of 692 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Shayan
44c50fca2d Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Shayan <shayans31516@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Amirreza Safavi
55b4d14a93 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
gallegonovato
743693299f Translated using Weblate (Spanish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Draken
7950a685a6 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Oğuz Ersen
97cfcb5c01 Translated using Weblate (Turkish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Fikri Akbar
b2dfcefee8 Translated using Weblate (Indonesian)
Currently translated at 99.8% (688 of 689 strings)

Co-authored-by: Fikri Akbar <akbarfikri1221@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-09-24 17:46:34 +03:00
Anonymous
ee1ade40c3 Translated using Weblate (Hungarian)
Currently translated at 86.6% (622 of 718 strings)

Translated using Weblate (Nepali)

Currently translated at 32.3% (232 of 718 strings)

Translated using Weblate (Hindi)

Currently translated at 93.0% (668 of 718 strings)

Translated using Weblate (Portuguese)

Currently translated at 92.7% (666 of 718 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
desu sude
3690e15cff Translated using Weblate (Latvian)
Currently translated at 24.4% (175 of 717 strings)

Co-authored-by: desu sude <cobsonslittlecocksleeve@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/lv/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Anon
a955dfbe50 Translated using Weblate (Serbian)
Currently translated at 100.0% (698 of 698 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Milo Ivir
5e9daa1206 Translated using Weblate (Croatian)
Currently translated at 98.7% (689 of 698 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hr/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Infy's Tagalog Translations
a3c2956a4d Translated using Weblate (Filipino)
Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (698 of 698 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-09-24 11:03:58 +02:00
abc0922001
10ecd92715 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 92.1% (643 of 698 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Amirreza Safavi
37d2d986ef Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
gekka
0aadd6ebe2 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (717 of 718 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (716 of 718 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.7% (696 of 698 strings)

Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.8% (691 of 692 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Shayan
c23ec9a4b8 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Shayan <shayans31516@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 11:03:58 +02:00
Amirreza Safavi
22a37923f9 Translated using Weblate (Persian)
Currently translated at 41.6% (288 of 692 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
gallegonovato
3fc506b438 Translated using Weblate (Spanish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
Draken
e98dbd5069 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
Oğuz Ersen
2a469b27c5 Translated using Weblate (Turkish)
Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (718 of 718 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (717 of 717 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (710 of 710 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (698 of 698 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (692 of 692 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
Fikri Akbar
0f3ef4559f Translated using Weblate (Indonesian)
Currently translated at 99.8% (688 of 689 strings)

Co-authored-by: Fikri Akbar <akbarfikri1221@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-09-24 11:03:57 +02:00
Koitharu
a87ef0a0a6 Search in history, favorites and local 2024-09-24 11:44:02 +03:00
Koitharu
a7a0a7f0db Fix sources catalog content types 2024-09-24 10:18:10 +03:00
Koitharu
bc4622d610 Local manga source filter 2024-09-23 19:22:48 +03:00
Koitharu
8365603bf1 Update supported domains 2024-09-23 18:28:55 +03:00
Koitharu
b1eabdba79 Improve quick filters 2024-09-23 14:46:29 +03:00
Koitharu
169e31e9ba Local manga index in database 2024-09-23 08:55:17 +03:00
Koitharu
66644d55a4 Search manga with filters 2024-09-22 17:44:36 +03:00
Koitharu
98314960cf Improve filter 2024-09-21 11:58:45 +03:00
Koitharu
b73e44874d Add new filter fields 2024-09-21 09:11:58 +03:00
Koitharu
6f45a44070 Update parsers and filters 2024-09-21 08:22:32 +03:00
Koitharu
d9d11d685e Update parsers 2024-09-15 16:05:52 +03:00
Koitharu
5359267b5a Update dependencies 2024-09-15 15:16:07 +03:00
Koitharu
a6662ab501 Batch manga fix functionality 2024-09-15 15:02:34 +03:00
Amirreza Safavi
699a619c27 Translated using Weblate (Persian)
Currently translated at 77.7% (7 of 9 strings)

Translated using Weblate (Persian)

Currently translated at 37.4% (258 of 689 strings)

Co-authored-by: Amirreza Safavi <amirxcatsanddragons@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fa/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fa/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-09-14 14:42:29 +03:00
Anon
85ccbbf719 Translated using Weblate (Serbian)
Currently translated at 100.0% (689 of 689 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-09-14 14:42:29 +03:00
Henrique
a396b33f3d Translated using Weblate (Portuguese (Brazil))
Currently translated at 96.6% (666 of 689 strings)

Co-authored-by: Henrique <heluis110@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-09-14 14:42:29 +03:00
Макар Разин
6076f775c3 Translated using Weblate (Russian)
Currently translated at 100.0% (689 of 689 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (689 of 689 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/
Translation: Kotatsu/Strings
2024-09-14 14:42:29 +03:00
Dilip Patel
379fa88b4e grammar fix 2024-09-14 14:39:36 +03:00
Koitharu
9b24c507c5 Option to automatically download new chapters (close #425, close #602, close #955) 2024-09-14 12:12:55 +03:00
Koitharu
98bd79f0be Update dependencies 2024-09-11 08:50:51 +03:00
296 changed files with 5920 additions and 3978 deletions

View File

@@ -1,8 +1,8 @@
# Kotatsu
Kotatsu is a free and open source manga reader for Android.
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) ![Kotlin](https://img.shields.io/github/languages/top/KotatsuApp/Kotatsu) ![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5)
[![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Download
@@ -12,16 +12,15 @@ Kotatsu is a free and open source manga reader for Android.
### Main Features
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
* Search manga by name and genres
* Search manga by name, genres, and more filters
* Reading history and bookmarks
* Favourites organized by user-defined categories
* Favorites organized by user-defined categories
* Downloading manga and reading it offline. Third-party CBZ archives also supported
* Tablet-optimized Material You UI
* Standard and Webtoon-optimized reader
* Standard and Webtoon-optimized customizable reader
* Notifications about new chapters with updates feed
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password/fingerprint protect access to the app
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
* Password/fingerprint-protected access to the app
### Screenshots
@@ -53,5 +52,5 @@ install instructions.
### DMCA disclaimer
The developers of this application does not have any affiliation with the content available in the app.
It is collecting from the sources freely available through any web browser.
The developers of this application do not have any affiliation with the content available in the app.
It collects content from sources that are freely available through any web browser

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 668
versionName = '7.5.2'
versionCode = 675
versionName = '7.6.2'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -48,11 +48,11 @@ android {
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
jvmTarget = JavaVersion.VERSION_11.toString()
freeCompilerArgs += [
'-opt-in=kotlin.ExperimentalStdlibApi',
'-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi',
@@ -64,7 +64,7 @@ android {
}
lint {
abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'NotifyDataSetChanged', 'SetJavaScriptEnabled'
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled'
}
testOptions {
unitTests.includeAndroidResources true
@@ -82,24 +82,23 @@ afterEvaluate {
}
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:ad726a3fd7') {
implementation('com.github.KotatsuApp:kotatsu-parsers:6f7e1fcfb2') {
exclude group: 'org.json', module: 'json'
}
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0-RC.2'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:2.0.20'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.2'
implementation 'androidx.fragment:fragment-ktx:1.8.3'
implementation 'androidx.fragment:fragment-ktx:1.8.4'
implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.3'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.5'
implementation 'androidx.lifecycle:lifecycle-service:2.8.5'
implementation 'androidx.lifecycle:lifecycle-process:2.8.5'
implementation 'androidx.collection:collection-ktx:1.4.4'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6'
implementation 'androidx.lifecycle:lifecycle-service:2.8.6'
implementation 'androidx.lifecycle:lifecycle-process:2.8.6'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
@@ -107,7 +106,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.5'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.8.6'
implementation 'androidx.webkit:webkit:1.11.0'
implementation 'androidx.work:work-runtime:2.9.1'
@@ -125,7 +124,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
implementation 'com.squareup.okio:okio:3.9.0'
implementation 'com.squareup.okio:okio:3.9.1'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
@@ -137,12 +136,12 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:4ec7176962'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:e04098de68'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.3'
implementation 'ch.acra:acra-dialog:5.11.3'
implementation 'ch.acra:acra-http:5.11.4'
implementation 'ch.acra:acra-dialog:5.11.4'
implementation 'org.conscrypt:conscrypt-android:2.5.2'
@@ -151,14 +150,14 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
androidTestImplementation 'androidx.test:runner:1.6.1'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0'
androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu
import android.content.Context
import android.os.Build
import android.os.StrictMode
import androidx.fragment.app.strictmode.FragmentStrictMode
import org.koitharu.kotatsu.core.BaseApp
@@ -18,30 +19,55 @@ class KotatsuApp : BaseApp() {
}
private fun enableStrictMode() {
val notifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
StrictModeNotifier(this)
} else {
null
}
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build(),
StrictMode.ThreadPolicy.Builder().apply {
detectNetwork()
detectDiskWrites()
detectCustomSlowCalls()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectUnbufferedIo()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) detectResourceMismatches()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) detectExplicitGc()
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectAll()
.setClassInstanceLimit(LocalMangaRepository::class.java, 1)
.setClassInstanceLimit(PagesCache::class.java, 1)
.setClassInstanceLimit(MangaLoaderContext::class.java, 1)
.setClassInstanceLimit(PageLoader::class.java, 1)
.setClassInstanceLimit(ReaderViewModel::class.java, 1)
.penaltyLog()
.build(),
StrictMode.VmPolicy.Builder().apply {
detectActivityLeaks()
detectLeakedSqlLiteObjects()
detectLeakedClosableObjects()
detectLeakedRegistrationObjects()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detectContentUriWithoutPermission()
detectFileUriExposure()
setClassInstanceLimit(LocalMangaRepository::class.java, 1)
setClassInstanceLimit(PagesCache::class.java, 1)
setClassInstanceLimit(MangaLoaderContext::class.java, 1)
setClassInstanceLimit(PageLoader::class.java, 1)
setClassInstanceLimit(ReaderViewModel::class.java, 1)
penaltyLog()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build()
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
.detectWrongFragmentContainer()
.detectRetainInstanceUsage()
.detectSetUserVisibleHint()
.detectFragmentTagUsage()
.build()
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
detectWrongFragmentContainer()
detectFragmentTagUsage()
detectRetainInstanceUsage()
detectSetUserVisibleHint()
detectWrongNestedHierarchy()
detectFragmentReuse()
penaltyLog()
if (notifier != null) {
penaltyListener(notifier)
}
}.build()
}
}

View File

@@ -0,0 +1,64 @@
package org.koitharu.kotatsu
import android.app.Notification
import android.app.Notification.BigTextStyle
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.os.StrictMode
import android.os.strictmode.Violation
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
import androidx.fragment.app.strictmode.FragmentStrictMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import kotlin.math.absoluteValue
import androidx.fragment.app.strictmode.Violation as FragmentViolation
@RequiresApi(Build.VERSION_CODES.P)
class StrictModeNotifier(
private val context: Context,
) : StrictMode.OnVmViolationListener, StrictMode.OnThreadViolationListener, FragmentStrictMode.OnViolationListener {
val executor = Dispatchers.Default.asExecutor()
private val notificationManager by lazy {
val nm = checkNotNull(context.getSystemService<NotificationManager>())
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.strict_mode),
NotificationManager.IMPORTANCE_LOW,
)
nm.createNotificationChannel(channel)
nm
}
override fun onVmViolation(v: Violation) = showNotification(v)
override fun onThreadViolation(v: Violation) = showNotification(v)
override fun onViolation(violation: FragmentViolation) = showNotification(violation)
private fun showNotification(violation: Throwable) = Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle(context.getString(R.string.strict_mode))
.setContentText(violation.message)
.setStyle(
BigTextStyle()
.setBigContentTitle(context.getString(R.string.strict_mode))
.setSummaryText(violation.message)
.bigText(violation.stackTraceToString()),
).setShowWhen(true)
.setContentIntent(ErrorReporterReceiver.getPendingIntent(context, violation))
.setAutoCancel(true)
.setGroup(CHANNEL_ID)
.build()
.let { notificationManager.notify(CHANNEL_ID, violation.hashCode().absoluteValue, it) }
private companion object {
const val CHANNEL_ID = "strict_mode"
}
}

View File

@@ -1,3 +1,4 @@
<resources>
<string name="app_name" translatable="false">Kotatsu Dev</string>
</resources>
<string name="strict_mode">Strict mode</string>
</resources>

File diff suppressed because it is too large Load Diff

View File

@@ -14,18 +14,21 @@ import org.koitharu.kotatsu.parsers.model.Manga
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.SortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
private const val MAX_PARALLELISM = 4
private const val MATCH_THRESHOLD = 0.2f
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga): Flow<Manga> {
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
val sources = getSources(manga.source)
if (sources.isEmpty()) {
return emptyFlow()
@@ -34,17 +37,17 @@ class AlternativesUseCase @Inject constructor(
return channelFlow {
for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.isSearchSupported) {
if (!repository.filterCapabilities.isSearchSupported) {
continue
}
launch {
val list = runCatchingCancellable {
semaphore.withPermit {
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
}
}.getOrDefault(emptyList())
for (item in list) {
if (item.matches(manga)) {
if (item.matches(manga, matchThreshold)) {
send(item)
}
}
@@ -65,16 +68,16 @@ class AlternativesUseCase @Inject constructor(
return result
}
private fun Manga.matches(ref: Manga): Boolean {
return matchesTitles(title, ref.title) ||
matchesTitles(title, ref.altTitle) ||
matchesTitles(altTitle, ref.title) ||
matchesTitles(altTitle, ref.altTitle)
private fun Manga.matches(ref: Manga, threshold: Float): Boolean {
return matchesTitles(title, ref.title, threshold) ||
matchesTitles(title, ref.altTitle, threshold) ||
matchesTitles(altTitle, ref.title, threshold) ||
matchesTitles(altTitle, ref.altTitle, threshold)
}
private fun matchesTitles(a: String?, b: String?): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
}
private fun MangaSource.priority(ref: MangaSource): Int {

View File

@@ -0,0 +1,93 @@
package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
class AutoFixUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val mangaDataRepository: MangaDataRepository,
) {
suspend operator fun invoke(mangaId: Long): Pair<Manga, Manga?> {
val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" }
.getDetailsSafe()
if (seed.isHealthy()) {
return seed to null // no fix required
}
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
.filter { it.isHealthy() }
.runningFold<Manga, Manga?>(null) { best, candidate ->
if (best == null || best < candidate) {
candidate
} else {
best
}
}.selectLastWithTimeout(4, 40, TimeUnit.SECONDS)
migrateUseCase(seed, replacement ?: throw NoAlternativesException(ParcelableManga(seed)))
return seed to replacement
}
private suspend fun Manga.isHealthy(): Boolean = runCatchingCancellable {
val repo = mangaRepositoryFactory.create(source)
val details = if (this.chapters != null) this else repo.getDetails(this)
val firstChapter = details.chapters?.firstOrNull() ?: return@runCatchingCancellable false
val pageUrl = repo.getPageUrl(repo.getPages(firstChapter).first())
pageUrl.toHttpUrlOrNull() != null
}.getOrDefault(false)
private suspend fun Manga.getDetailsSafe() = runCatchingCancellable {
mangaRepositoryFactory.create(source).getDetails(this)
}.getOrDefault(this)
private operator fun Manga.compareTo(other: Manga) = chaptersCount().compareTo(other.chaptersCount())
@Suppress("UNCHECKED_CAST", "OPT_IN_USAGE")
private suspend fun <T> Flow<T>.selectLastWithTimeout(
minCount: Int,
timeout: Long,
timeUnit: TimeUnit
): T? = channelFlow<T?> {
var lastValue: T? = null
launch {
delay(timeUnit.toMillis(timeout))
close(InternalTimeoutException(lastValue))
}
withIndex().transformWhile { (index, value) ->
lastValue = value
emit(value)
index < minCount && !isClosedForSend
}.collect {
send(it)
}
}.catch { e ->
if (e is InternalTimeoutException) {
emit(e.value as T?)
} else {
throw e
}
}.lastOrNull()
class NoAlternativesException(val seed: ParcelableManga) : NoSuchElementException()
private class InternalTimeoutException(val value: Any?) : CancellationException()
}

View File

@@ -136,7 +136,7 @@ constructor(
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
updatedAt = history.updatedAt,
chapterId = currentChapter.id,
page = history.page,
scroll = history.scroll,
@@ -173,7 +173,7 @@ constructor(
return HistoryEntity(
mangaId = newManga.id,
createdAt = history.createdAt,
updatedAt = System.currentTimeMillis(),
updatedAt = history.updatedAt,
chapterId = newChapterId,
page = history.page,
scroll = history.scroll,

View File

@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -81,7 +82,14 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
R.id.chip_source -> startActivity(
MangaListActivity.newIntent(
this,
item.manga.source,
MangaListFilter(query = viewModel.manga.title),
),
)
R.id.button_migrate -> confirmMigration(item.manga)
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
}

View File

@@ -0,0 +1,197 @@
package org.koitharu.kotatsu.alternatives.ui
import android.annotation.SuppressLint
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class AutoFixService : CoroutineIntentService() {
@Inject
lateinit var autoFixUseCase: AutoFixUseCase
@Inject
lateinit var coil: ImageLoader
private lateinit var notificationManager: NotificationManagerCompat
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(applicationContext)
}
override suspend fun processIntent(startId: Int, intent: Intent) {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(startId)
try {
for (mangaId in ids) {
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(result)
notificationManager.notify(TAG, startId, notification)
}
}
} finally {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
}
override fun onError(startId: Int, error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification)
}
}
@SuppressLint("InlinedApi")
private fun startForeground(startId: Int) {
val title = applicationContext.getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title)
.setShowBadge(false)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
notificationManager.createNotificationChannel(channel)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setSilent(true)
.setOngoing(true)
.setProgress(0, 0, true)
.setSmallIcon(R.drawable.ic_stat_auto_fix)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction(
materialR.drawable.material_ic_clear_black_24dp,
applicationContext.getString(android.R.string.cancel),
getCancelIntent(startId),
)
.build()
ServiceCompat.startForeground(
this,
FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private suspend fun buildNotification(result: Result<Pair<Manga, Manga?>>): Notification {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setSilent(true)
.setAutoCancel(true)
result.onSuccess { (seed, replacement) ->
if (replacement != null) {
notification.setLargeIcon(
coil.execute(
ImageRequest.Builder(applicationContext)
.data(replacement.coverUrl)
.tag(replacement.source)
.build(),
).toBitmapOrNull(),
)
notification.setSubText(replacement.title)
val intent = DetailsActivity.newIntent(applicationContext, replacement)
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
replacement.id.toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
),
).setVisibility(
if (replacement.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC,
)
notification
.setContentTitle(applicationContext.getString(R.string.fixed))
.setContentText(
applicationContext.getString(
R.string.manga_replaced,
seed.title,
seed.source.getTitle(applicationContext),
replacement.title,
replacement.source.getTitle(applicationContext),
),
)
.setSmallIcon(R.drawable.ic_stat_done)
} else {
notification
.setContentTitle(applicationContext.getString(R.string.fixing_manga))
.setContentText(applicationContext.getString(R.string.no_fix_required, seed.title))
.setSmallIcon(android.R.drawable.stat_sys_warning)
}
}.onFailure { error ->
notification
.setContentTitle(applicationContext.getString(R.string.error_occurred))
.setContentText(
if (error is AutoFixUseCase.NoAlternativesException) {
applicationContext.getString(R.string.no_alternatives_found, error.seed.manga.title)
} else {
error.getDisplayMessage(applicationContext.resources)
},
).setSmallIcon(android.R.drawable.stat_notify_error)
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
notification.addAction(
R.drawable.ic_alert_outline,
applicationContext.getString(R.string.report),
reportIntent,
)
}
}
return notification.build()
}
companion object {
private const val DATA_IDS = "ids"
private const val TAG = "auto_fix"
private const val CHANNEL_ID = "auto_fix"
private const val FOREGROUND_NOTIFICATION_ID = 38
fun start(context: Context, mangaIds: Collection<Long>): Boolean = try {
val intent = Intent(context, AutoFixService::class.java)
intent.putExtra(DATA_IDS, mangaIds.toLongArray())
ContextCompat.startForegroundService(context, intent)
true
} catch (e: Exception) {
e.printStackTraceDebug()
false
}
}
}

View File

@@ -17,9 +17,6 @@ data class Bookmark(
val percent: Float,
) : ListModel {
val directImageUrl: String?
get() = if (isImageUrlDirect()) imageUrl else null
val imageLoadData: Any
get() = if (isImageUrlDirect()) imageUrl else toMangaPage()

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
@@ -129,7 +130,11 @@ class AllBookmarksFragment :
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(item.pageId) ?: false
return selectionController?.onItemLongClick(view, item.pageId) ?: false
}
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.pageId) ?: false
}
override fun onRetryClick(error: Throwable) = Unit
@@ -148,23 +153,23 @@ class AllBookmarksFragment :
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menuInflater: MenuInflater,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
mode: ActionMode?,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
viewModel.removeBookmarks(ids)
mode.finish()
mode?.finish()
true
}

View File

@@ -22,10 +22,7 @@ fun bookmarkLargeAD(
) = adapterDelegateViewBinding<Bookmark, ListModel, ItemBookmarkLargeBinding>(
{ inflater, parent -> ItemBookmarkLargeBinding.inflate(inflater, parent, false) },
) {
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {

View File

@@ -21,10 +21,7 @@ fun bookmarkListAD(
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
) {
val listener = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(listener)
binding.root.setOnLongClickListener(listener)
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {

View File

@@ -12,7 +12,6 @@ import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import dagger.hilt.android.AndroidEntryPoint
import okhttp3.internal.userAgent
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.network.CommonHeaders
@@ -45,7 +44,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
}
val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
viewBinding.webView.webViewClient = BrowserClient(this)

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.provider.SearchRecentSuggestions
import android.text.Html

View File

@@ -11,6 +11,7 @@ import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.acra.ACRA
@@ -28,6 +29,9 @@ import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import java.security.Security
import javax.inject.Inject
@@ -60,6 +64,13 @@ open class BaseApp : Application(), Configuration.Provider {
@Inject
lateinit var workManagerProvider: Provider<WorkManager>
@Inject
lateinit var localMangaIndexProvider: Provider<LocalMangaIndex>
@Inject
@LocalStorageChanges
lateinit var localStorageChanges: MutableSharedFlow<LocalManga?>
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
@@ -82,6 +93,7 @@ open class BaseApp : Application(), Configuration.Provider {
}
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()
localStorageChanges.collect(localMangaIndexProvider.get())
}
workScheduleManager.init()
WorkServiceStopHelper(workManagerProvider).setup()

View File

@@ -5,9 +5,11 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.BadParcelableException
import androidx.core.app.PendingIntentCompat
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.report
class ErrorReporterReceiver : BroadcastReceiver() {
@@ -22,12 +24,15 @@ class ErrorReporterReceiver : BroadcastReceiver() {
private const val EXTRA_ERROR = "err"
private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR"
fun getPendingIntent(context: Context, e: Throwable): PendingIntent {
fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try {
val intent = Intent(context, ErrorReporterReceiver::class.java)
intent.setAction(ACTION_REPORT)
intent.setData(Uri.parse("err://${e.hashCode()}"))
intent.putExtra(EXTRA_ERROR, e)
return checkNotNull(PendingIntentCompat.getBroadcast(context, 0, intent, 0, false))
PendingIntentCompat.getBroadcast(context, 0, intent, 0, false)
} catch (e: BadParcelableException) {
e.printStackTraceDebug()
null
}
}
}

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration19To20
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration20To21
import org.koitharu.kotatsu.core.db.migrations.Migration21To22
import org.koitharu.kotatsu.core.db.migrations.Migration22To23
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -50,6 +51,8 @@ import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouritesDao
import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexDao
import org.koitharu.kotatsu.local.data.index.LocalMangaIndexEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.stats.data.StatsDao
@@ -60,14 +63,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 22
const val DATABASE_VERSION = 23
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class,
],
version = DATABASE_VERSION,
)
@@ -98,6 +101,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getSourcesDao(): MangaSourcesDao
abstract fun getStatsDao(): StatsDao
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -122,6 +127,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration19To20(),
Migration20To21(),
Migration21To22(),
Migration22To23(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -39,6 +39,8 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags())
fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
// Model to entity
fun Manga.toEntity() = MangaEntity(

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration22To23 : Migration(22, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS `local_index` (`manga_id` INTEGER NOT NULL, `path` TEXT NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource

View File

@@ -3,5 +3,5 @@ package org.koitharu.kotatsu.core.exceptions
import okio.IOException
class NoDataReceivedException(
private val url: String,
url: String,
) : IOException("No data has been received from $url")

View File

@@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultCaller
import androidx.annotation.StringRes
import androidx.collection.MutableScatterMap
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@@ -19,7 +18,8 @@ import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.restartApplication
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
@@ -124,15 +124,16 @@ class ExceptionResolver @AssistedInject constructor(
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) { _, _ ->
buildAlertDialog(ctx) {
setTitle(R.string.ignore_ssl_errors)
setMessage(R.string.ignore_ssl_errors_summary)
setPositiveButton(R.string.apply) { _, _ ->
settings.isSSLBypassEnabled = true
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_SHORT).show()
ctx.findActivity()?.finishAffinity()
}.setNegativeButton(android.R.string.cancel, null)
.show()
Toast.makeText(ctx, R.string.settings_apply_restart_required, Toast.LENGTH_LONG).show()
ctx.restartApplication()
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}
private inline fun Host.withContext(block: Context.() -> Unit) {

View File

@@ -1,20 +1,31 @@
package org.koitharu.kotatsu.core.fs
import android.os.Build
import org.koitharu.kotatsu.core.util.iterator.CloseableIterator
import androidx.annotation.RequiresApi
import org.koitharu.kotatsu.core.util.CloseableSequence
import org.koitharu.kotatsu.core.util.iterator.MappingIterator
import java.io.File
import java.nio.file.Files
import java.nio.file.Path
class FileSequence(private val dir: File) : Sequence<File> {
sealed interface FileSequence : CloseableSequence<File> {
override fun iterator(): Iterator<File> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val stream = Files.newDirectoryStream(dir.toPath())
CloseableIterator(MappingIterator(stream.iterator(), Path::toFile), stream)
} else {
dir.listFiles().orEmpty().iterator()
}
@RequiresApi(Build.VERSION_CODES.O)
class StreamImpl(dir: File) : FileSequence {
private val stream = Files.newDirectoryStream(dir.toPath())
override fun iterator(): Iterator<File> = MappingIterator(stream.iterator(), Path::toFile)
override fun close() = stream.close()
}
class ListImpl(dir: File) : FileSequence {
private val list = dir.listFiles().orEmpty()
override fun iterator(): Iterator<File> = list.iterator()
override fun close() = Unit
}
}

View File

@@ -1,148 +0,0 @@
package org.koitharu.kotatsu.core.logs
import android.content.Context
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.subdir
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
import java.util.concurrent.ConcurrentLinkedQueue
private const val DIR = "logs"
private const val FLUSH_DELAY = 2_000L
private const val MAX_SIZE_BYTES = 1024 * 1024 // 1 MB
class FileLogger(
context: Context,
private val settings: AppSettings,
name: String,
) {
val file by lazy {
val dir = context.getExternalFilesDir(DIR) ?: context.filesDir.subdir(DIR)
File(dir, "$name.log")
}
val isEnabled: Boolean
get() = settings.isLoggingEnabled
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT).withLocale(Locale.ROOT)
private val buffer = ConcurrentLinkedQueue<String>()
private val mutex = Mutex()
private var flushJob: Job? = null
fun log(message: String, e: Throwable? = null) {
if (!isEnabled) {
return
}
val text = buildString {
append(dateTimeFormatter.format(LocalDateTime.now()))
append(": ")
if (e != null) {
append("E!")
}
append(message)
if (e != null) {
append(' ')
append(e.stackTraceToString())
appendLine()
}
}
buffer.add(text)
postFlush()
}
inline fun log(messageProducer: () -> String) {
if (isEnabled) {
log(messageProducer())
}
}
suspend fun flush() {
if (!isEnabled) {
return
}
flushJob?.cancelAndJoin()
flushImpl()
}
@WorkerThread
fun flushBlocking() {
if (!isEnabled) {
return
}
runBlockingSafe { flushJob?.cancelAndJoin() }
runBlockingSafe { flushImpl() }
}
private fun postFlush() {
if (flushJob?.isActive == true) {
return
}
flushJob = processLifecycleScope.launch(Dispatchers.Default) {
delay(FLUSH_DELAY)
runCatchingCancellable {
flushImpl()
}.onFailure {
it.printStackTraceDebug()
}
}
}
private suspend fun flushImpl() = withContext(NonCancellable) {
mutex.withLock {
if (buffer.isEmpty()) {
return@withContext
}
runInterruptible(Dispatchers.IO) {
if (file.length() > MAX_SIZE_BYTES) {
rotate()
}
FileOutputStream(file, true).use {
while (true) {
val message = buffer.poll() ?: break
it.write(message.toByteArray())
it.write('\n'.code)
}
it.flush()
}
}
}
}
@WorkerThread
private fun rotate() {
val length = file.length()
val bakFile = File(file.parentFile, file.name + ".bak")
file.renameTo(bakFile)
bakFile.inputStream().use { input ->
input.skip(length - MAX_SIZE_BYTES / 2)
file.outputStream().use { output ->
input.copyTo(output)
output.flush()
}
}
bakFile.delete()
}
private inline fun runBlockingSafe(crossinline block: suspend () -> Unit) = try {
runBlocking(NonCancellable) { block() }
} catch (_: InterruptedException) {
}
}

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.core.logs
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class TrackerLogger
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class SyncLogger

View File

@@ -1,40 +0,0 @@
package org.koitharu.kotatsu.core.logs
import android.content.Context
import androidx.collection.arraySetOf
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet
import org.koitharu.kotatsu.core.prefs.AppSettings
@Module
@InstallIn(SingletonComponent::class)
object LoggersModule {
@Provides
@TrackerLogger
fun provideTrackerLogger(
@ApplicationContext context: Context,
settings: AppSettings,
) = FileLogger(context, settings, "tracker")
@Provides
@SyncLogger
fun provideSyncLogger(
@ApplicationContext context: Context,
settings: AppSettings,
) = FileLogger(context, settings, "sync")
@Provides
@ElementsIntoSet
fun provideAllLoggers(
@TrackerLogger trackerLogger: FileLogger,
@SyncLogger syncLogger: FileLogger,
): Set<@JvmSuppressWildcards FileLogger> = arraySetOf(
trackerLogger,
syncLogger,
)
}

View File

@@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.model.SortOrder
@Deprecated("")
enum class GenericSortOrder(
@StringRes val titleResId: Int,
val ascending: SortOrder,

View File

@@ -1,16 +1,21 @@
package org.koitharu.kotatsu.core.model
import android.net.Uri
import android.text.SpannableStringBuilder
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.collection.MutableObjectIntMap
import androidx.core.os.LocaleListCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.strikeThrough
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Demographic
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.MangaState
import org.koitharu.kotatsu.parsers.util.formatSimple
import org.koitharu.kotatsu.parsers.util.mapToSet
@@ -68,6 +73,17 @@ val ContentRating.titleResId: Int
ContentRating.ADULT -> R.string.rating_adult
}
@get:StringRes
val Demographic.titleResId: Int
get() = when (this) {
Demographic.SHOUNEN -> R.string.demographic_shounen
Demographic.SHOUJO -> R.string.demographic_shoujo
Demographic.SEINEN -> R.string.demographic_seinen
Demographic.JOSEI -> R.string.demographic_josei
Demographic.KODOMO -> R.string.demographic_kodomo
Demographic.NONE -> R.string.none
}
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id)
}
@@ -110,6 +126,9 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
val Manga.isLocal: Boolean
get() = source == LocalMangaSource
val Manga.isBroken: Boolean
get() = source == UnknownMangaSource
val Manga.appUrl: Uri
get() = Uri.parse("https://kotatsu.app/manga").buildUpon()
.appendQueryParameter("source", source.name)
@@ -138,3 +157,26 @@ fun Manga.chaptersCount(): Int {
}
return max
}
fun MangaListFilter.getSummary() = buildSpannedString {
if (!query.isNullOrEmpty()) {
append(query)
if (tags.isNotEmpty() || tagsExclude.isNotEmpty()) {
append(' ')
append('(')
appendTagsSummary(this@getSummary)
append(')')
}
} else {
appendTagsSummary(this@getSummary)
}
}
private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
filter.tags.joinTo(this) { it.title }
if (filter.tagsExclude.isNotEmpty()) {
strikeThrough {
filter.tagsExclude.joinTo(this) { it.title }
}
}
}

View File

@@ -12,4 +12,5 @@ data class MangaHistory(
val page: Int,
val scroll: Int,
val percent: Float,
val chaptersCount: Int,
) : Parcelable

View File

@@ -43,6 +43,8 @@ fun MangaSource(name: String?): MangaSource {
return UnknownMangaSource
}
fun Collection<String>.toMangaSources() = map(::MangaSource)
fun MangaSource.isNsfw(): Boolean = when (this) {
is MangaSourceInfo -> mangaSource.isNsfw()
is MangaParserSource -> contentType == ContentType.HENTAI
@@ -56,13 +58,26 @@ val ContentType.titleResId
ContentType.HENTAI -> R.string.content_type_hentai
ContentType.COMICS -> R.string.content_type_comics
ContentType.OTHER -> R.string.content_type_other
ContentType.MANHWA -> R.string.content_type_manhwa
ContentType.MANHUA -> R.string.content_type_manhua
ContentType.NOVEL -> R.string.content_type_novel
ContentType.ONE_SHOT -> R.string.content_type_one_shot
ContentType.DOUJINSHI -> R.string.content_type_doujinshi
ContentType.IMAGE_SET -> R.string.content_type_image_set
ContentType.ARTIST_CG -> R.string.content_type_artist_cg
ContentType.GAME_CG -> R.string.content_type_game_cg
}
fun MangaSource.getSummary(context: Context): String? = when (this) {
is MangaSourceInfo -> mangaSource.getSummary(context)
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
mangaSource.unwrap()
} else {
this
}
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
is MangaParserSource -> {
val type = context.getString(contentType.titleResId)
val locale = locale.toLocale().getDisplayName(context)
val type = context.getString(source.contentType.titleResId)
val locale = source.locale.toLocale().getDisplayName(context)
context.getString(R.string.source_summary_pattern, type, locale)
}
@@ -71,11 +86,10 @@ fun MangaSource.getSummary(context: Context): String? = when (this) {
else -> null
}
fun MangaSource.getTitle(context: Context): String = when (this) {
is MangaSourceInfo -> mangaSource.getTitle(context)
is MangaParserSource -> title
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
is MangaParserSource -> source.title
LocalMangaSource -> context.getString(R.string.local_storage)
is ExternalMangaSource -> resolveName(context)
is ExternalMangaSource -> source.resolveName(context)
else -> context.getString(R.string.unknown)
}

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readEnumSet
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.core.util.ext.writeEnumSet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState
object MangaListFilterParceler : Parceler<MangaListFilter> {
override fun MangaListFilter.write(parcel: Parcel, flags: Int) {
parcel.writeString(query)
parcel.writeParcelable(ParcelableMangaTags(tags), 0)
parcel.writeParcelable(ParcelableMangaTags(tagsExclude), 0)
parcel.writeSerializable(locale)
parcel.writeSerializable(originalLocale)
parcel.writeEnumSet(states)
parcel.writeEnumSet(contentRating)
parcel.writeEnumSet(types)
parcel.writeEnumSet(demographics)
parcel.writeInt(year)
parcel.writeInt(yearFrom)
parcel.writeInt(yearTo)
}
override fun create(parcel: Parcel) = MangaListFilter(
query = parcel.readString(),
tags = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
tagsExclude = parcel.readParcelableCompat<ParcelableMangaTags>()?.tags.orEmpty(),
locale = parcel.readSerializableCompat(),
originalLocale = parcel.readSerializableCompat(),
states = parcel.readEnumSet<MangaState>().orEmpty(),
contentRating = parcel.readEnumSet<ContentRating>().orEmpty(),
types = parcel.readEnumSet<ContentType>().orEmpty(),
demographics = parcel.readEnumSet<Demographic>().orEmpty(),
year = parcel.readInt(),
yearFrom = parcel.readInt(),
yearTo = parcel.readInt(),
)
}
@Parcelize
@TypeParceler<MangaListFilter, MangaListFilterParceler>
data class ParcelableMangaListFilter(val filter: MangaListFilter) : Parcelable

View File

@@ -74,7 +74,7 @@ interface NetworkModule {
if (settings.isSSLBypassEnabled) {
disableCertificateVerification()
} else {
installExtraCertsificates(contextProvider.get())
installExtraCertificates(contextProvider.get())
}
cache(cache)
addInterceptor(GZipInterceptor())

View File

@@ -35,7 +35,7 @@ fun OkHttpClient.Builder.disableCertificateVerification() = also { builder ->
}
}
fun OkHttpClient.Builder.installExtraCertsificates(context: Context) = also { builder ->
fun OkHttpClient.Builder.installExtraCertificates(context: Context) = also { builder ->
val certificatesBuilder = HandshakeCertificates.Builder()
.addPlatformTrustedCertificates()
val assets = context.assets.list("").orEmpty()

View File

@@ -180,7 +180,7 @@ class AppShortcutManager @Inject constructor(
.setLongLabel(title)
.setIcon(icon)
.setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source))
.setIntent(MangaListActivity.newIntent(context, source, null))
.build()
}
}

View File

@@ -9,7 +9,7 @@ import android.graphics.Rect as AndroidRect
class BitmapWrapper private constructor(
private val androidBitmap: AndroidBitmap,
) : Bitmap {
) : Bitmap, AutoCloseable {
private val canvas by lazy { Canvas(androidBitmap) } // is not always used, so initialized lazily
@@ -24,17 +24,21 @@ class BitmapWrapper private constructor(
canvas.drawBitmap(androidSourceBitmap, src.toAndroidRect(), dst.toAndroidRect(), null)
}
override fun close() {
androidBitmap.recycle()
}
fun compressTo(output: OutputStream) {
androidBitmap.compress(AndroidBitmap.CompressFormat.PNG, 100, output)
}
companion object {
fun create(width: Int, height: Int): Bitmap = BitmapWrapper(
fun create(width: Int, height: Int) = BitmapWrapper(
AndroidBitmap.createBitmap(width, height, AndroidBitmap.Config.ARGB_8888),
)
fun create(bitmap: AndroidBitmap): Bitmap = BitmapWrapper(
fun create(bitmap: AndroidBitmap) = BitmapWrapper(
if (bitmap.isMutable) bitmap else bitmap.copy(AndroidBitmap.Config.ARGB_8888, true),
)

View File

@@ -7,9 +7,10 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.EnumSet
@@ -24,14 +25,17 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse
override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
private fun stub(manga: Manga?): Nothing {
throw UnsupportedSourceException("Usage of Dummy parser", manga)
}

View File

@@ -1,37 +1,29 @@
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.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
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
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 val filterCapabilities: MangaListFilterCapabilities
get() = MangaListFilterCapabilities()
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> = stub(null)
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
@@ -39,9 +31,7 @@ class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
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 getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)

View File

@@ -61,7 +61,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) {
val list = getList(0, MangaListFilter.Search(title))
val list = getList(0, null, MangaListFilter(query = title))
if (url != null) {
list.find { it.url == url }?.let {
return it
@@ -80,7 +80,7 @@ class MangaLinkResolver @Inject constructor(
}.ifNullOrEmpty {
seed.author
} ?: return@runCatchingCancellable null
val seedList = getList(0, MangaListFilter.Search(seedTitle))
val seedList = getList(0, null, MangaListFilter(query = seedTitle))
seedList.first { x -> x.url == url }
}.getOrThrow()
}

View File

@@ -7,6 +7,7 @@ import android.util.Base64
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.core.os.LocaleListCompat
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
@@ -21,14 +22,16 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.core.util.ext.use
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.model.MangaSource
import org.koitharu.kotatsu.parsers.network.UserAgents
import org.koitharu.kotatsu.parsers.util.map
import org.koitharu.kotatsu.parsers.util.mimeType
import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject
@@ -76,32 +79,25 @@ class MangaLoaderContextImpl @Inject constructor(
}
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()
return response.map { body ->
val opts = BitmapFactory.Options()
opts.inMutable = true
BitmapFactory.decodeStream(body.byteStream(), null, opts)?.use { bitmap ->
(redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result ->
Buffer().also {
result.compressTo(it.outputStream())
}.asResponseBody("image/jpeg".toMediaType())
}
} ?: throw ImageDecodeException(response.request.url.toString(), response.mimeType)
}
}
override fun createBitmap(width: Int, height: Int): Bitmap {
return BitmapWrapper.create(width, height)
}
override fun createBitmap(width: Int, height: Int): Bitmap = BitmapWrapper.create(width, height)
@MainThread
private fun obtainWebView(): WebView {
return webViewCached?.get() ?: WebView(androidContext).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
private fun obtainWebView(): WebView = webViewCached?.get() ?: WebView(androidContext).also {
it.configureForParser(null)
webViewCached = WeakReference(it)
}
private fun obtainWebViewUserAgent(): String {

View File

@@ -13,18 +13,16 @@ import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
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.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.set
@@ -35,19 +33,11 @@ interface MangaRepository {
val sortOrders: Set<SortOrder>
val states: Set<MangaState>
val contentRatings: Set<ContentRating>
var defaultSortOrder: SortOrder
val isMultipleTagsSupported: Boolean
val filterCapabilities: MangaListFilterCapabilities
val isTagsExclusionSupported: Boolean
val isSearchSupported: Boolean
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga
@@ -55,14 +45,12 @@ interface MangaRepository {
suspend fun getPageUrl(page: MangaPage): String
suspend fun getTags(): Set<MangaTag>
suspend fun getLocales(): Set<Locale>
suspend fun getFilterOptions(): MangaListFilterOptions
suspend fun getRelated(seed: Manga): List<Manga>
suspend fun find(manga: Manga): Manga? {
val list = getList(0, MangaListFilter.Search(manga.title))
val list = getList(0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
return list.find { x -> x.id == manga.id }
}

View File

@@ -8,19 +8,18 @@ import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.domain
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Locale
class ParserMangaRepository(
private val parser: MangaParser,
@@ -28,17 +27,20 @@ class ParserMangaRepository(
cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor {
private val filterOptionsLazy = SuspendLazy {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFilterOptions()
}
}
override val source: MangaParserSource
get() = parser.source
override val sortOrders: Set<SortOrder>
get() = parser.availableSortOrders
override val states: Set<MangaState>
get() = parser.availableStates
override val contentRatings: Set<ContentRating>
get() = parser.availableContentRating
override val filterCapabilities: MangaListFilterCapabilities
get() = parser.filterCapabilities
override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first()
@@ -46,15 +48,6 @@ class ParserMangaRepository(
getConfig().defaultSortOrder = value
}
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
override val isSearchSupported: Boolean
get() = parser.isSearchSupported
override val isTagsExclusionSupported: Boolean
get() = parser.isTagsExclusionSupported
var domain: String
get() = parser.domain
set(value) {
@@ -72,9 +65,9 @@ class ParserMangaRepository(
}
}
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, filter)
parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
}
}
@@ -88,13 +81,7 @@ class ParserMangaRepository(
parser.getPageUrl(page)
}
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getAvailableTags()
}
override suspend fun getLocales(): Set<Locale> {
return parser.getAvailableLocales()
}
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons()

View File

@@ -6,19 +6,18 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
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.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.util.EnumSet
import java.util.Locale
class ExternalMangaRepository(
private val contentResolver: ContentResolver,
contentResolver: ContentResolver,
override val source: ExternalMangaSource,
cache: MemoryContentCache,
) : CachingMangaRepository(cache) {
@@ -33,31 +32,23 @@ class ExternalMangaRepository(
}.getOrNull()
}
private val filterOptions = SuspendLazy(contentSource::getListFilterOptions)
override val sortOrders: Set<SortOrder>
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.POPULARITY)
override val states: Set<MangaState>
get() = capabilities?.availableStates.orEmpty()
override val contentRatings: Set<ContentRating>
get() = capabilities?.availableContentRating.orEmpty()
override val filterCapabilities: MangaListFilterCapabilities
get() = capabilities?.listFilterCapabilities ?: MangaListFilterCapabilities()
override var defaultSortOrder: SortOrder
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL
set(value) = Unit
override val isMultipleTagsSupported: Boolean
get() = capabilities?.isMultipleTagsSupported ?: true
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get()
override val isTagsExclusionSupported: Boolean
get() = capabilities?.isTagsExclusionSupported ?: false
override val isSearchSupported: Boolean
get() = capabilities?.isSearchSupported ?: true
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> =
runInterruptible(Dispatchers.IO) {
contentSource.getList(offset, filter)
contentSource.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
}
override suspend fun getDetailsImpl(manga: Manga): Manga = runInterruptible(Dispatchers.IO) {
@@ -68,13 +59,9 @@ class ExternalMangaRepository(
contentSource.getPages(chapter)
}
override suspend fun getPageUrl(page: MangaPage): String = page.url // TODO
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.IO) {
contentSource.getTags()
override suspend fun getPageUrl(page: MangaPage): String = runInterruptible(Dispatchers.IO) {
contentSource.getPageUrl(page.url)
}
override suspend fun getLocales(): Set<Locale> = emptySet() // TODO
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
}

View File

@@ -8,12 +8,14 @@ import androidx.core.net.toUri
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.toLocale
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities
import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
@@ -31,25 +33,29 @@ class ExternalPluginContentSource(
@Blocking
@WorkerThread
fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
fun getListFilterOptions() = MangaListFilterOptions(
availableTags = fetchTags(),
availableStates = fetchEnumSet(MangaState::class.java, "filter/states"),
availableContentRating = fetchEnumSet(ContentRating::class.java, "filter/content_ratings"),
availableContentTypes = fetchEnumSet(ContentType::class.java, "filter/content_types"),
availableDemographics = fetchEnumSet(Demographic::class.java, "filter/demographics"),
availableLocales = fetchLocales(),
)
@Blocking
@WorkerThread
fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> {
val uri = "content://${source.authority}/manga".toUri().buildUpon()
uri.appendQueryParameter("offset", offset.toString())
when (filter) {
is MangaListFilter.Advanced -> {
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
}
is MangaListFilter.Search -> {
uri.appendQueryParameter("query", filter.query)
}
null -> Unit
filter.tags.forEach { uri.appendQueryParameter("tags_include", "${it.key}=${it.title}") }
filter.tagsExclude.forEach { uri.appendQueryParameter("tags_exclude", "${it.key}=${it.title}") }
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
if (!filter.query.isNullOrEmpty()) {
uri.appendQueryParameter("query", filter.query)
}
return contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)
return contentResolver.query(uri.build(), null, null, null, order.name)
.safe()
.use { cursor ->
val result = ArrayList<Manga>(cursor.count)
@@ -113,8 +119,8 @@ class ExternalPluginContentSource(
@Blocking
@WorkerThread
fun getTags(): Set<MangaTag> {
val uri = "content://${source.authority}/tags".toUri()
private fun fetchTags(): Set<MangaTag> {
val uri = "content://${source.authority}/filter/tags".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
@@ -132,6 +138,40 @@ class ExternalPluginContentSource(
}
}
@Blocking
@WorkerThread
fun getPageUrl(url: String): String {
val uri = "content://${source.authority}/pages/0".toUri().buildUpon()
.appendQueryParameter("url", url)
.build()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getString(COLUMN_VALUE)
} else {
url
}
}
}
@Blocking
@WorkerThread
private fun fetchLocales(): Set<Locale> {
val uri = "content://${source.authority}/filter/locales".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = ArraySet<Locale>(cursor.count)
if (cursor.moveToFirst()) {
do {
result += Locale(cursor.getString(COLUMN_NAME))
} while (cursor.moveToNext())
}
result
}
}
fun getCapabilities(): MangaSourceCapabilities? {
val uri = "content://${source.authority}/capabilities".toUri()
return contentResolver.query(uri, null, null, null, null)
@@ -144,26 +184,18 @@ class ExternalPluginContentSource(
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
SortOrder.entries.find(it)
}.orEmpty(),
availableStates = cursor.getStringOrNull(COLUMN_STATES)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
MangaState.entries.find(it)
}.orEmpty(),
availableContentRating = cursor.getStringOrNull(COLUMN_CONTENT_RATING)
?.split(',')
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
ContentRating.entries.find(it)
}.orEmpty(),
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS_SUPPORTED, true),
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION_SUPPORTED, false),
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH_SUPPORTED, true),
contentType = cursor.getStringOrNull(COLUMN_CONTENT_TYPE)?.let {
ContentType.entries.find(it)
} ?: ContentType.OTHER,
defaultSortOrder = cursor.getStringOrNull(COLUMN_DEFAULT_SORT_ORDER)?.let {
SortOrder.entries.find(it)
} ?: SortOrder.ALPHABETICAL,
sourceLocale = cursor.getStringOrNull(COLUMN_LOCALE)?.toLocale() ?: Locale.ROOT,
listFilterCapabilities = MangaListFilterCapabilities(
isMultipleTagsSupported = cursor.getBooleanOrDefault(COLUMN_MULTIPLE_TAGS, false),
isTagsExclusionSupported = cursor.getBooleanOrDefault(COLUMN_TAGS_EXCLUSION, false),
isSearchSupported = cursor.getBooleanOrDefault(COLUMN_SEARCH, false),
isSearchWithFiltersSupported = cursor.getBooleanOrDefault(
COLUMN_SEARCH_WITH_FILTERS,
false,
),
isYearSupported = cursor.getBooleanOrDefault(COLUMN_YEAR, false),
isYearRangeSupported = cursor.getBooleanOrDefault(COLUMN_YEAR_RANGE, false),
isOriginalLocaleSupported = cursor.getBooleanOrDefault(COLUMN_ORIGINAL_LOCALE, false),
),
)
} else {
null
@@ -233,6 +265,26 @@ class ExternalPluginContentSource(
source = source,
)
private fun <E : Enum<E>> fetchEnumSet(cls: Class<E>, path: String): EnumSet<E> {
val uri = "content://${source.authority}/$path".toUri()
return contentResolver.query(uri, null, null, null, null)
.safe()
.use { cursor ->
val result = EnumSet.noneOf(cls)
val enumConstants = cls.enumConstants ?: return@use result
if (cursor.moveToFirst()) {
do {
val name = cursor.getString(COLUMN_NAME)
val enumValue = enumConstants.find { it.name == name }
if (enumValue != null) {
result.add(enumValue)
}
} while (cursor.moveToNext())
}
result
}
}
private fun Cursor?.safe() = ExternalPluginCursor(
source = source,
cursor = this ?: throw IncompatiblePluginException(source.name, null),
@@ -240,27 +292,19 @@ class ExternalPluginContentSource(
class MangaSourceCapabilities(
val availableSortOrders: Set<SortOrder>,
val availableStates: Set<MangaState>,
val availableContentRating: Set<ContentRating>,
val isMultipleTagsSupported: Boolean,
val isTagsExclusionSupported: Boolean,
val isSearchSupported: Boolean,
val contentType: ContentType,
val defaultSortOrder: SortOrder,
val sourceLocale: Locale,
val listFilterCapabilities: MangaListFilterCapabilities,
)
private companion object {
const val COLUMN_SORT_ORDERS = "sort_orders"
const val COLUMN_STATES = "states"
const val COLUMN_CONTENT_RATING = "content_rating"
const val COLUMN_MULTIPLE_TAGS_SUPPORTED = "multiple_tags_supported"
const val COLUMN_TAGS_EXCLUSION_SUPPORTED = "tags_exclusion_supported"
const val COLUMN_SEARCH_SUPPORTED = "search_supported"
const val COLUMN_CONTENT_TYPE = "content_type"
const val COLUMN_DEFAULT_SORT_ORDER = "default_sort_order"
const val COLUMN_LOCALE = "locale"
const val COLUMN_MULTIPLE_TAGS = "multiple_tags"
const val COLUMN_TAGS_EXCLUSION = "tags_exclusion"
const val COLUMN_SEARCH = "search"
const val COLUMN_SEARCH_WITH_FILTERS = "search_with_filters"
const val COLUMN_YEAR = "year"
const val COLUMN_YEAR_RANGE = "year_range"
const val COLUMN_ORIGINAL_LOCALE = "original_locale"
const val COLUMN_ID = "id"
const val COLUMN_NAME = "name"
const val COLUMN_NUMBER = "number"
@@ -282,5 +326,6 @@ class ExternalPluginContentSource(
const val COLUMN_DESCRIPTION = "description"
const val COLUMN_PREVIEW = "preview"
const val COLUMN_KEY = "key"
const val COLUMN_VALUE = "value"
}
}

View File

@@ -37,12 +37,12 @@ import org.koitharu.kotatsu.core.parser.EmptyMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.util.ext.requireBody
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.requireBody
import java.net.HttpURLConnection
import kotlin.coroutines.coroutineContext
@@ -114,6 +114,7 @@ class FaviconFetcher(
.url(url)
.get()
.tag(MangaSource::class.java, source)
request.tag(MangaSource::class.java, source)
@Suppress("UNCHECKED_CAST")
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
val response = okHttpClient.newCall(request.build()).await()

View File

@@ -160,6 +160,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isTrackerNsfwDisabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
val trackerDownloadStrategy: TrackerDownloadStrategy
get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED)
var notificationSound: Uri
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
?: Settings.System.DEFAULT_NOTIFICATION_URI
@@ -236,9 +239,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
} ?: EnumSet.allOf(SearchSuggestionType::class.java)
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
var isBiometricProtectionEnabled: Boolean
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
@@ -600,6 +600,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
const val KEY_TRACKER_DOWNLOAD = "tracker_download"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"
@@ -661,7 +662,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"
const val KEY_LOGGING_ENABLED = "logging"
const val KEY_SOURCES_GRID = "sources_grid"
const val KEY_UPDATES_UNSTABLE = "updates_unstable"
const val KEY_TIPS_CLOSED = "tips_closed"
@@ -705,9 +705,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
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"
const val KEY_LINK_WEBLATE = "about_app_translation"
const val KEY_LINK_TELEGRAM = "about_telegram"
const val KEY_LINK_GITHUB = "about_github"
const val KEY_LINK_MANUAL = "about_help"
const val PROXY_TEST = "proxy_test"
// old keys are for migration only

View File

@@ -1,11 +1,13 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import com.google.android.material.color.DynamicColors
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.find
@Keep
enum class ColorScheme(
@StyleRes val styleResId: Int,
@StringRes val titleResId: Int,

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class DownloadFormat {
AUTOMATIC,

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ListMode {
LIST, DETAILED_LIST, GRID;
}
}

View File

@@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.prefs
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.Keep
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
@Keep
enum class NavItem(
@IdRes val id: Int,
@StringRes val title: Int,

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.core.prefs
import android.net.ConnectivityManager
import androidx.annotation.Keep
@Keep
enum class NetworkPolicy(
private val key: Int,
) {

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ProgressIndicatorMode {
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ReaderAnimation {
// Do not rename this

View File

@@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.view.ContextThemeWrapper
import androidx.annotation.Keep
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import com.google.android.material.R as materialR
@Keep
enum class ReaderBackground {
DEFAULT, LIGHT, DARK, WHITE, BLACK;

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ReaderMode(val id: Int) {
STANDARD(1),

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ScreenshotsPolicy {
// Do not rename this

View File

@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
@Keep
enum class SearchSuggestionType(
@StringRes val titleResId: Int,
) {

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class TrackerDownloadStrategy {
DISABLED, DOWNLOADED;
}

View File

@@ -80,11 +80,11 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
(activity as? SettingsActivity)?.setSectionTitle(title)
}
protected fun startActivitySafe(intent: Intent) {
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
protected fun startActivitySafe(intent: Intent): Boolean = try {
startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
false
}
}

View File

@@ -65,6 +65,7 @@ abstract class BaseViewModel : ViewModel() {
}
protected fun <T> Flow<T>.withErrorHandling() = catch { error ->
error.printStackTraceDebug()
errorEvent.call(error)
}

View File

@@ -1,12 +1,21 @@
package org.koitharu.kotatsu.core.ui
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.PatternMatcher
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -20,7 +29,15 @@ abstract class CoroutineIntentService : BaseService() {
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
launchCoroutine(intent, startId)
val job = launchCoroutine(intent, startId)
val receiver = CancelReceiver(job)
ContextCompat.registerReceiver(
this,
receiver,
createIntentFilter(this, startId),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
job.invokeOnCompletion { unregisterReceiver(receiver) }
return START_REDELIVER_INTENT
}
@@ -47,8 +64,45 @@ abstract class CoroutineIntentService : BaseService() {
@AnyThread
protected abstract fun onError(startId: Int, error: Throwable)
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast(
this,
0,
createCancelIntent(this, startId),
PendingIntent.FLAG_UPDATE_CURRENT,
false,
)
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug()
onError(startId, throwable)
}
private class CancelReceiver(
private val job: Job
) : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
job.cancel()
}
}
private companion object {
private const val SCHEME = "startid"
private const val ACTION_SUFFIX_CANCEL = ".ACTION_CANCEL"
fun createIntentFilter(service: CoroutineIntentService, startId: Int): IntentFilter {
val intentFilter = IntentFilter(cancelAction(service))
intentFilter.addDataScheme(SCHEME)
intentFilter.addDataPath(startId.toString(), PatternMatcher.PATTERN_LITERAL)
return intentFilter
}
fun createCancelIntent(service: CoroutineIntentService, startId: Int): Intent {
return Intent(cancelAction(service))
.setData("$SCHEME://$startId".toUri())
}
private fun cancelAction(service: CoroutineIntentService) = service.javaClass.name + ACTION_SUFFIX_CANCEL
}
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.ui
import android.view.View
fun interface OnContextClickListenerCompat {
fun onContextClick(v: View): Boolean
}

View File

@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import java.util.Collections
import org.koitharu.kotatsu.parsers.util.move
import java.util.LinkedList
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
@@ -28,13 +28,17 @@ open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
}
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
override fun setItems(items: List<T>?) {
super.setItems(items)
}
@Deprecated(
message = "Use emit() to dispatch list updates",
level = DeprecationLevel.ERROR,
replaceWith = ReplaceWith("emit(items)"),
)
override fun setItems(items: List<T>?) = super.setItems(items)
fun reorderItems(oldPos: Int, newPos: Int) {
Collections.swap(items ?: return, oldPos, newPos)
val reordered = items?.toMutableList() ?: return
reordered.move(oldPos, newPos)
super.setItems(reordered)
notifyItemMoved(oldPos, newPos)
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import androidx.annotation.UiContext
import org.koitharu.kotatsu.R
object CommonAlertDialogs {
fun showDownloadConfirmation(
@UiContext context: Context,
onConfirmed: (startPaused: Boolean) -> Unit,
) = buildAlertDialog(context, isCentered = true) {
var startPaused = false
setTitle(R.string.save_manga)
setIcon(R.drawable.ic_download)
setMessage(R.string.save_manga_confirm)
setCheckbox(R.string.start_download, true) { _, isChecked ->
startPaused = !isChecked
}
setPositiveButton(R.string.save) { _, _ ->
onConfirmed(startPaused)
}
setNegativeButton(android.R.string.cancel, null)
}.show()
}

View File

@@ -3,18 +3,46 @@ package org.koitharu.kotatsu.core.ui.list
import android.view.View
import android.view.View.OnClickListener
import android.view.View.OnLongClickListener
import androidx.core.util.Function
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
class AdapterDelegateClickListenerAdapter<I>(
class AdapterDelegateClickListenerAdapter<I, O>(
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
private val clickListener: OnListItemClickListener<I>,
) : OnClickListener, OnLongClickListener {
private val clickListener: OnListItemClickListener<O>,
private val itemMapper: Function<I, O>,
) : OnClickListener, OnLongClickListener, OnContextClickListenerCompat {
override fun onClick(v: View) {
clickListener.onItemClick(adapterDelegate.item, v)
clickListener.onItemClick(mappedItem(), v)
}
override fun onLongClick(v: View): Boolean {
return clickListener.onItemLongClick(adapterDelegate.item, v)
return clickListener.onItemLongClick(mappedItem(), v)
}
override fun onContextClick(v: View): Boolean {
return clickListener.onItemContextClick(mappedItem(), v)
}
private fun mappedItem(): O = itemMapper.apply(adapterDelegate.item)
fun attach(itemView: View) {
itemView.setOnClickListener(this)
itemView.setOnLongClickListener(this)
itemView.setOnContextClickListenerCompat(this)
}
companion object {
operator fun <T> invoke(
adapterDelegate: AdapterDelegateViewBindingViewHolder<out T, *>,
clickListener: OnListItemClickListener<T>
): AdapterDelegateClickListenerAdapter<T, T> = AdapterDelegateClickListenerAdapter(
adapterDelegate = adapterDelegate,
clickListener = clickListener,
itemMapper = { x -> x },
)
}
}

View File

@@ -2,10 +2,14 @@ package org.koitharu.kotatsu.core.ui.list
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.collection.LongSet
import androidx.collection.longSetOf
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
@@ -29,18 +33,21 @@ class ListSelectionController(
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
private var actionMode: ActionMode? = null
private var focusedItemId: LongSet? = null
var useActionMode: Boolean = true
val count: Int
get() = decoration.checkedItemsCount
get() = if (focusedItemId != null) 1 else decoration.checkedItemsCount
init {
registryOwner.lifecycle.addObserver(StateEventObserver())
}
fun snapshot(): Set<Long> = peekCheckedIds().toSet()
fun snapshot(): Set<Long> = (focusedItemId ?: peekCheckedIds()).toSet()
fun peekCheckedIds(): LongSet {
return decoration.checkedItemsIds
return focusedItemId ?: decoration.checkedItemsIds
}
fun clear() {
@@ -52,6 +59,7 @@ class ListSelectionController(
if (ids.isEmpty()) {
return
}
startActionMode()
decoration.checkAll(ids)
notifySelectionChanged()
}
@@ -80,15 +88,42 @@ class ListSelectionController(
return false
}
fun onItemLongClick(id: Long): Boolean {
return startActionMode()?.also {
decoration.setItemIsChecked(id, true)
notifySelectionChanged()
} != null
fun onItemLongClick(view: View, id: Long): Boolean {
return if (useActionMode) {
startSelection(id)
} else {
onItemContextClick(view, id)
}
}
fun onItemContextClick(view: View, id: Long): Boolean {
focusedItemId = longSetOf(id)
val menu = PopupMenu(view.context, view)
callback.onCreateActionMode(this, menu.menuInflater, menu.menu)
callback.onPrepareActionMode(this, null, menu.menu)
menu.setForceShowIcon(true)
if (menu.menu.hasVisibleItems()) {
menu.setOnMenuItemClickListener { menuItem ->
callback.onActionItemClicked(this, null, menuItem)
}
menu.setOnDismissListener {
focusedItemId = null
}
menu.show()
return true
} else {
focusedItemId = null
return false
}
}
fun startSelection(id: Long): Boolean = startActionMode()?.also {
decoration.setItemIsChecked(id, true)
notifySelectionChanged()
} != null
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
return callback.onCreateActionMode(this, mode, menu)
return callback.onCreateActionMode(this, mode.menuInflater, menu)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
@@ -106,6 +141,7 @@ class ListSelectionController(
}
private fun startActionMode(): ActionMode? {
focusedItemId = null
return actionMode ?: appCompatDelegate.startSupportActionMode(this).also {
actionMode = it
}
@@ -134,14 +170,14 @@ class ListSelectionController(
fun onSelectionChanged(controller: ListSelectionController, count: Int)
fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean
fun onCreateActionMode(controller: ListSelectionController, menuInflater: MenuInflater, menu: Menu): Boolean
fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.title = controller.count.toString()
fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
mode?.title = controller.count.toString()
return true
}
fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean
fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean
fun onDestroyActionMode(controller: ListSelectionController, mode: ActionMode) = Unit
}

View File

@@ -6,5 +6,7 @@ fun interface OnListItemClickListener<I> {
fun onItemClick(item: I, view: View)
fun onItemLongClick(item: I, view: View) = false
fun onItemLongClick(item: I, view: View): Boolean = false
fun onItemContextClick(item: I, view: View): Boolean = onItemLongClick(item, view)
}

View File

@@ -4,14 +4,22 @@ import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.SortDirection
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED
import org.koitharu.kotatsu.parsers.model.SortOrder.ADDED_ASC
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL
import org.koitharu.kotatsu.parsers.model.SortOrder.ALPHABETICAL_DESC
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST
import org.koitharu.kotatsu.parsers.model.SortOrder.NEWEST_ASC
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_ASC
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_HOUR
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_MONTH
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_TODAY
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_WEEK
import org.koitharu.kotatsu.parsers.model.SortOrder.POPULARITY_YEAR
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING
import org.koitharu.kotatsu.parsers.model.SortOrder.RATING_ASC
import org.koitharu.kotatsu.parsers.model.SortOrder.RELEVANCE
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED
import org.koitharu.kotatsu.parsers.model.SortOrder.UPDATED_ASC
@@ -28,6 +36,14 @@ val SortOrder.titleRes: Int
POPULARITY_ASC -> R.string.unpopular
RATING_ASC -> R.string.low_rating
NEWEST_ASC -> R.string.order_oldest
ADDED -> R.string.recently_added
ADDED_ASC -> R.string.added_long_ago
RELEVANCE -> R.string.by_relevance
POPULARITY_HOUR -> R.string.popular_in_hour
POPULARITY_TODAY -> R.string.popular_today
POPULARITY_WEEK -> R.string.popular_in_week
POPULARITY_MONTH -> R.string.popular_in_month
POPULARITY_YEAR -> R.string.popular_in_year
}
val SortOrder.direction: SortDirection
@@ -36,11 +52,19 @@ val SortOrder.direction: SortDirection
POPULARITY_ASC,
RATING_ASC,
NEWEST_ASC,
ADDED_ASC,
ALPHABETICAL -> SortDirection.ASC
UPDATED,
POPULARITY,
POPULARITY_HOUR,
POPULARITY_TODAY,
POPULARITY_WEEK,
POPULARITY_MONTH,
POPULARITY_YEAR,
RATING,
NEWEST,
ADDED,
RELEVANCE,
ALPHABETICAL_DESC -> SortDirection.DESC
}

View File

@@ -4,11 +4,15 @@ import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
class PopupMenuMediator(
private val provider: MenuProvider,
) : View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
) : View.OnLongClickListener, OnContextClickListenerCompat, PopupMenu.OnMenuItemClickListener,
PopupMenu.OnDismissListener {
override fun onContextClick(v: View): Boolean = onLongClick(v)
override fun onLongClick(v: View): Boolean {
val menu = PopupMenu(v.context, v)

View File

@@ -8,18 +8,32 @@ import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.view.children
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.transform.RoundedCornersTransformation
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
import org.koitharu.kotatsu.parsers.util.ifZero
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class ChipsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
) : ChipGroup(context, attrs, defStyleAttr) {
@Inject
lateinit var coil: ImageLoader
private var isLayoutSuppressedCompat = false
private var isLayoutCalledOnSuppressed = false
private val chipOnClickListener = InternalChipClickListener()
@@ -36,11 +50,6 @@ class ChipsView @JvmOverloads constructor(
children.forEach { it.isClickable = isChipClickable }
}
var onChipCloseClickListener: OnChipCloseClickListener? = null
set(value) {
field = value
val isCloseIconVisible = value != null
children.forEach { (it as? Chip)?.isCloseIconVisible = isCloseIconVisible }
}
init {
val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0)
@@ -95,15 +104,19 @@ class ChipsView @JvmOverloads constructor(
val title: CharSequence? = null,
@StringRes val titleResId: Int = 0,
@DrawableRes val icon: Int = 0,
val iconData: Any? = null,
@ColorRes val tint: Int = 0,
val isChecked: Boolean = false,
val isLoading: Boolean = false,
val isDropdown: Boolean = false,
val isCloseable: Boolean = false,
val data: Any? = null,
)
private inner class DataChip(context: Context) : Chip(context) {
private var model: ChipModel? = null
private var imageRequest: Disposable? = null
init {
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
@@ -116,6 +129,9 @@ class ChipsView @JvmOverloads constructor(
}
fun bind(model: ChipModel) {
if (this.model == model) {
return
}
this.model = model
if (model.titleResId == 0) {
@@ -131,15 +147,9 @@ class ChipsView @JvmOverloads constructor(
isChecked = false
isCheckable = false
}
if (model.icon == 0 || model.isChecked) {
chipIcon = null
isChipIconVisible = false
} else {
setChipIconResource(model.icon)
isChipIconVisible = true
}
bindIcon(model)
isCheckedIconVisible = model.isChecked
isCloseIconVisible = if (onChipCloseClickListener != null || model.isDropdown) {
isCloseIconVisible = if (model.isCloseable || model.isDropdown) {
setCloseIconResource(
if (model.isDropdown) R.drawable.ic_expand_more else materialR.drawable.ic_m3_chip_close,
)
@@ -151,6 +161,54 @@ class ChipsView @JvmOverloads constructor(
}
override fun toggle() = Unit
private fun bindIcon(model: ChipModel) {
when {
model.isChecked -> {
imageRequest?.dispose()
imageRequest = null
chipIcon = null
isChipIconVisible = false
}
model.isLoading -> {
imageRequest?.dispose()
imageRequest = null
isChipIconVisible = true
setProgressIcon()
}
model.iconData != null -> {
val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon }
imageRequest = ImageRequest.Builder(context)
.data(model.iconData)
.crossfade(false)
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(ChipIconTarget(this))
.placeholder(placeholder)
.fallback(placeholder)
.error(placeholder)
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
.allowRgb565(true)
.enqueueWith(coil)
isChipIconVisible = true
}
model.icon != 0 -> {
imageRequest?.dispose()
imageRequest = null
setChipIconResource(model.icon)
isChipIconVisible = true
}
else -> {
imageRequest?.dispose()
imageRequest = null
chipIcon = null
isChipIconVisible = false
}
}
}
}
private inner class InternalChipClickListener : OnClickListener {

View File

@@ -0,0 +1,3 @@
package org.koitharu.kotatsu.core.util
interface CloseableSequence<T> : Sequence<T>, AutoCloseable

View File

@@ -2,12 +2,10 @@ package org.koitharu.kotatsu.core.util
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.core.model.appUrl
import org.koitharu.kotatsu.parsers.model.Manga
import java.io.File
@@ -84,25 +82,4 @@ class ShareHelper(private val context: Context) {
.setChooserTitle(R.string.share)
.startChooser()
}
fun shareLogs(loggers: Collection<FileLogger>) {
val intentBuilder = ShareCompat.IntentBuilder(context)
.setType(TYPE_TEXT)
var hasLogs = false
for (logger in loggers) {
val logFile = logger.file
if (!logFile.exists()) {
continue
}
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", logFile)
intentBuilder.addStream(uri)
hasLogs = true
}
if (hasLogs) {
intentBuilder.setChooserTitle(R.string.share_logs)
intentBuilder.startChooser()
} else {
Toast.makeText(context, R.string.nothing_here, Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -7,10 +7,12 @@ import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.ActivityOptions
import android.app.LocaleConfig
import android.content.ComponentName
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Context.POWER_SERVICE
import android.content.ContextWrapper
import android.content.Intent
import android.content.OperationApplicationException
import android.content.SharedPreferences
import android.content.SyncResult
@@ -33,6 +35,7 @@ import androidx.annotation.IntegerRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDialog
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@@ -61,6 +64,7 @@ import okio.use
import org.json.JSONException
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
@@ -274,3 +278,10 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
userAgentString = userAgentOverride
}
}
fun Context.restartApplication() {
val activity = findActivity()
val intent = Intent.makeRestartActivityTask(ComponentName(this, MainActivity::class.java))
startActivity(intent)
activity?.finishAndRemoveTask()
}

View File

@@ -12,6 +12,7 @@ import androidx.core.os.BundleCompat
import androidx.core.os.ParcelCompat
import androidx.lifecycle.SavedStateHandle
import java.io.Serializable
import java.util.EnumSet
// https://issuetracker.google.com/issues/240585930
@@ -53,6 +54,31 @@ inline fun <reified T : Serializable> Bundle.requireSerializable(key: String): T
}
}
fun <E : Enum<E>> Parcel.writeEnumSet(set: Set<E>?) {
if (set == null) {
writeValue(null)
} else {
val array = IntArray(set.size)
set.forEachIndexed { i, e -> array[i] = e.ordinal }
writeIntArray(array)
}
}
inline fun <reified E : Enum<E>> Parcel.readEnumSet(): Set<E>? = readEnumSet(E::class.java)
fun <E : Enum<E>> Parcel.readEnumSet(cls: Class<E>): Set<E>? {
val array = createIntArray() ?: return null
if (array.isEmpty()) {
return emptySet()
}
val enumValues = cls.enumConstants ?: return null
val set = EnumSet.noneOf(cls)
array.forEach { e ->
set.add(enumValues[e])
}
return set
}
fun <T> SavedStateHandle.require(key: String): T {
return checkNotNull(get(key)) {
"Value $key not found in SavedStateHandle or has a wrong type"

View File

@@ -25,6 +25,12 @@ fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
ArrayList(this)
}
fun <E : Enum<E>> Set<E>.asEnumSet(cls: Class<E>): EnumSet<E> = if (this is EnumSet<*>) {
this as EnumSet<E>
} else {
EnumSet.noneOf(cls).apply { addAll(this@asEnumSet) }
}
fun <K, V> Map<K, V>.findKeyByValue(value: V): K? {
for ((k, v) in entries) {
if (v == value) {
@@ -86,8 +92,19 @@ fun LongSet.toLongArray(): LongArray {
return result
}
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet<Long>(size))
fun LongSet.toSet(): Set<Long> = toCollection(ArraySet(size))
fun <R : MutableCollection<Long>> LongSet.toCollection(out: R): R = out.also { result ->
forEach(result::add)
}
fun <T, R> Collection<T>.mapSortedByCount(isDescending: Boolean = true, mapper: (T) -> R): List<R> {
val grouped = groupBy(mapper).toList()
val sortSelector: (Pair<R, List<T>>) -> Int = { it.second.size }
val sorted = if (isDescending) {
grouped.sortedByDescending(sortSelector)
} else {
grouped.sortedBy(sortSelector)
}
return sorted.map { it.first }
}

View File

@@ -15,7 +15,6 @@ import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence
import java.io.File
import java.io.FileFilter
import java.io.InputStream
import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
@@ -87,9 +86,13 @@ suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
walkCompat(includeDirectories = false).sumOf { it.length() }
}
fun File.children() = FileSequence(this)
inline fun <R> File.withChildren(block: (children: Sequence<File>) -> R): R = FileSequence(this).use(block)
fun Sequence<File>.filterWith(filter: FileFilter): Sequence<File> = filter { f -> filter.accept(f) }
fun FileSequence(dir: File): FileSequence = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
FileSequence.StreamImpl(dir)
} else {
FileSequence.ListImpl(dir)
}
val File.creationTime
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {

View File

@@ -17,7 +17,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@@ -132,3 +132,5 @@ suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x !
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
fun <T> SuspendLazy<T>.asFlow() = flow { emit(tryGet()) }

View File

@@ -5,7 +5,6 @@ import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.internal.closeQuietly
import okio.IOException
import org.json.JSONObject
@@ -41,8 +40,6 @@ fun Response.ensureSuccess() = apply {
}
}
fun Response.requireBody(): ResponseBody = checkNotNull(body) { "Response body is null" }
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
c.name(name)
c.value(value)

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.core.util.ext
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
fun longOf(a: Int, b: Int): Long {

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import android.database.DatabaseUtils
import androidx.annotation.FloatRange
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.ellipsize
@@ -64,3 +65,11 @@ fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transf
}
}
}
@Deprecated("",
ReplaceWith(
"sqlEscapeString(this)",
"android.database.DatabaseUtils.sqlEscapeString"
)
)
fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this)

View File

@@ -29,7 +29,7 @@ fun Context.getThemeColor(
@Px
fun Context.getThemeDimensionPixelSize(
@AttrRes resId: Int,
@ColorInt fallback: Int = 0,
@Px fallback: Int = 0,
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimensionPixelSize(0, fallback)
}
@@ -37,7 +37,7 @@ fun Context.getThemeDimensionPixelSize(
@Px
fun Context.getThemeDimensionPixelOffset(
@AttrRes resId: Int,
@ColorInt fallback: Int = 0,
@Px fallback: Int = 0,
) = obtainStyledAttributes(intArrayOf(resId)).use {
it.getDimensionPixelOffset(0, fallback)
}

View File

@@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException
import android.content.res.Resources
import androidx.annotation.DrawableRes
import coil.network.HttpException
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okio.FileNotFoundException
import okio.IOException
import okio.ProtocolException
@@ -80,6 +81,11 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is UnknownHostException,
is SocketTimeoutException -> resources.getString(R.string.network_error)
is ImageDecodeException -> resources.getString(
R.string.error_image_format,
format.ifNullOrEmpty { resources.getString(R.string.unknown) },
)
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
is IncompatiblePluginException -> resources.getString(R.string.plugin_incompatible)
is WrongPasswordException -> resources.getString(R.string.wrong_password)
@@ -89,7 +95,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
else -> getDisplayMessage(message, resources) ?: localizedMessage
else -> getDisplayMessage(message, resources) ?: message
}.ifNullOrEmpty {
resources.getString(R.string.error_occurred)
}
@@ -107,6 +113,18 @@ fun Throwable.getDisplayIcon() = when (this) {
else -> R.drawable.ic_error_large
}
fun Throwable.getCauseUrl(): String? = when (this) {
is ParseException -> url
is NotFoundException -> url
is TooManyRequestExceptions -> url
is CaughtException -> cause?.getCauseUrl()
is CloudFlareBlockedException -> url
is CloudFlareProtectedException -> url
is HttpStatusException -> url
is HttpException -> response.request.url.toString()
else -> null
}
private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String? = when (statusCode) {
404 -> resources.getString(R.string.not_found_404)
in 500..599 -> resources.getString(R.string.server_error, statusCode)

View File

@@ -18,8 +18,10 @@ import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.Chip
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import kotlin.math.roundToInt
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
@@ -88,6 +90,17 @@ fun Slider.setValueRounded(newValue: Float) {
value = roundedValue.coerceIn(valueFrom, valueTo)
}
fun RangeSlider.setValuesRounded(vararg newValues: Float) {
val step = stepSize
values = newValues.map { newValue ->
if (step <= 0f) {
newValue
} else {
(newValue / step).roundToInt() * step
}.coerceIn(valueFrom, valueTo)
}
}
fun RecyclerView.invalidateNestedItemDecorations() {
descendants.filterIsInstance<RecyclerView>().forEach {
it.invalidateItemDecorations()
@@ -141,9 +154,9 @@ fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
}
}
fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) {
fun View.setOnContextClickListenerCompat(listener: OnContextClickListenerCompat) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnContextClickListener(listener::onLongClick)
setOnContextClickListener(listener::onContextClick)
}
}

View File

@@ -1,36 +0,0 @@
package org.koitharu.kotatsu.core.util.iterator
import okhttp3.internal.closeQuietly
import okio.Closeable
class CloseableIterator<T>(
private val upstream: Iterator<T>,
private val closeable: Closeable,
) : Iterator<T>, Closeable {
private var isClosed = false
override fun hasNext(): Boolean {
val result = upstream.hasNext()
if (!result) {
close()
}
return result
}
override fun next(): T {
try {
return upstream.next()
} catch (e: NoSuchElementException) {
close()
throw e
}
}
override fun close() {
if (!isClosed) {
closeable.closeQuietly()
isClosed = true
}
}
}

View File

@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.zip
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import okio.Closeable
import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.withChildren
import java.io.File
import java.io.FileInputStream
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
@@ -17,7 +18,7 @@ class ZipOutput(
) : Closeable {
private val entryNames = ArraySet<String>()
private var isClosed = false
private val isClosed = AtomicBoolean(false)
private val output = ZipOutputStream(file.outputStream()).apply {
setLevel(compressionLevel)
}
@@ -72,9 +73,8 @@ class ZipOutput(
}
override fun close() {
if (!isClosed) {
if (isClosed.compareAndSet(false, true)) {
output.close()
isClosed = true
}
}
@@ -91,8 +91,10 @@ class ZipOutput(
}
putNextEntry(entry)
closeEntry()
fileToZip.children().forEach { childFile ->
appendFile(childFile, "$name/${childFile.name}")
fileToZip.withChildren { children ->
children.forEach { childFile ->
appendFile(childFile, "$name/${childFile.name}")
}
}
} else {
FileInputStream(fileToZip).use { fis ->

View File

@@ -54,6 +54,7 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -98,13 +99,13 @@ import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -114,7 +115,8 @@ class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
View.OnClickListener,
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark> {
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark>,
OnContextClickListenerCompat {
@Inject
lateinit var shortcutManager: AppShortcutManager
@@ -213,10 +215,10 @@ class DetailsActivity :
R.id.chip_author -> {
val manga = viewModel.manga.value ?: return
startActivity(
SearchActivity.newIntent(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
query = manga.author ?: return,
filter = MangaListFilter(query = manga.author),
),
)
}
@@ -227,6 +229,7 @@ class DetailsActivity :
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
filter = null,
),
)
}
@@ -286,9 +289,12 @@ class DetailsActivity :
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(this, setOf(tag)))
// TODO dialog
startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag))))
}
override fun onContextClick(v: View): Boolean = onLongClick(v)
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
val menu = PopupMenu(v.context, v)

View File

@@ -23,7 +23,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
class DetailsMenuProvider(
@@ -92,7 +92,7 @@ class DetailsMenuProvider(
R.id.action_related -> {
viewModel.manga.value?.let {
activity.startActivity(MultiSearchActivity.newIntent(activity, it.title))
activity.startActivity(SearchActivity.newIntent(activity, it.title))
}
}

View File

@@ -18,9 +18,7 @@ fun chapterGridItemAD(
on = { item, _, _ -> item is ChapterListItem && item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind { payloads ->
if (payloads.isEmpty()) {

View File

@@ -22,9 +22,7 @@ fun chapterListItemAD(
on = { item, _, _ -> item is ChapterListItem && !item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
binding.textViewTitle.text = item.chapter.name

View File

@@ -12,7 +12,7 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
class ChaptersAdapter(
private val onItemClickListener: OnListItemClickListener<ChapterListItem>,
onItemClickListener: OnListItemClickListener<ChapterListItem>,
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
private var hasVolumes = false

View File

@@ -166,8 +166,10 @@ abstract class ChaptersPagesViewModel(
fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) {
downloadScheduler.schedule(
requireManga(),
chaptersIds,
manga = requireManga(),
chaptersIds = chaptersIds,
isPaused = false,
isSilent = false,
)
onDownloadStarted.call(Unit)
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.pager.bookmarks
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
@@ -133,7 +134,11 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemLongClick(item.pageId) ?: false
return selectionController?.onItemLongClick(view, item.pageId) ?: false
}
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.pageId) ?: false
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
@@ -142,23 +147,23 @@ class BookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menuInflater: MenuInflater,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
mode: ActionMode?,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
viewModel.removeBookmarks(ids)
mode.finish()
mode?.finish()
true
}

View File

@@ -116,7 +116,11 @@ class ChaptersFragment :
}
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
return selectionController?.onItemLongClick(item.chapter.id) ?: false
return selectionController?.onItemLongClick(view, item.chapter.id) ?: false
}
override fun onItemContextClick(item: ChapterListItem, view: View): Boolean {
return selectionController?.onItemContextClick(view, item.chapter.id) ?: false
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
@@ -149,7 +153,7 @@ class ChaptersFragment :
items?.indexOfFirst(predicate) ?: -1
}
if (position >= 0) {
selectionController?.onItemLongClick(chapterId)
selectionController?.startSelection(chapterId)
val lm = (viewBinding?.recyclerViewChapters?.layoutManager as? LinearLayoutManager)
if (lm != null) {
val offset = resources.getDimensionPixelOffset(R.dimen.chapter_list_item_height)

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.details.ui.pager.chapters
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import androidx.recyclerview.widget.RecyclerView
@@ -19,12 +20,16 @@ class ChaptersSelectionCallback(
recyclerView: RecyclerView,
) : BaseListSelectionCallback(recyclerView) {
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_chapters, menu)
override fun onCreateActionMode(
controller: ListSelectionController,
menuInflater: MenuInflater,
menu: Menu
): Boolean {
menuInflater.inflate(R.menu.mode_chapters, menu)
return true
}
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
val selectedIds = controller.peekCheckedIds()
val allItems = viewModel.chapters.value
val items = allItems.withIndex().filter { it.value.chapter.id in selectedIds }
@@ -38,7 +43,7 @@ class ChaptersSelectionCallback(
menu.findItem(R.id.action_delete).isVisible = canDelete
menu.findItem(R.id.action_select_all).isVisible = items.size < allItems.size
menu.findItem(R.id.action_mark_current).isVisible = items.size == 1
mode.title = items.size.toString()
mode?.title = items.size.toString()
var hasGap = false
for (i in 0 until items.size - 1) {
if (items[i].index + 1 != items[i + 1].index) {
@@ -50,11 +55,11 @@ class ChaptersSelectionCallback(
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_save -> {
viewModel.download(controller.snapshot())
mode.finish()
mode?.finish()
true
}
@@ -73,7 +78,7 @@ class ChaptersSelectionCallback(
).show()
}
}
mode.finish()
mode?.finish()
true
}
@@ -112,7 +117,7 @@ class ChaptersSelectionCallback(
} else {
return false
}
mode.finish()
mode?.finish()
true
}

View File

@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.mimeType
import org.koitharu.kotatsu.parsers.util.requireBody
import org.koitharu.kotatsu.reader.domain.PageLoader
import java.util.zip.ZipFile
import javax.inject.Inject
@@ -98,9 +99,7 @@ class MangaPageFetcher(
if (!response.isSuccessful) {
throw HttpException(response)
}
val body = checkNotNull(response.body) {
"Null response"
}
val body = response.requireBody()
val mimeType = response.mimeType
val file = body.use {
pagesCache.put(pageUrl, it.source())

View File

@@ -32,9 +32,7 @@ fun pageThumbnailAD(
height = (gridWidth / 13f * 18f).toInt(),
)
val clickListenerAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
binding.root.setOnClickListener(clickListenerAdapter)
binding.root.setOnLongClickListener(clickListenerAdapter)
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage()

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.details.ui.related
import android.view.Menu
import androidx.appcompat.view.ActionMode
import android.view.MenuInflater
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -16,9 +16,13 @@ class RelatedListFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(controller, mode, menu)
override fun onCreateActionMode(
controller: ListSelectionController,
menuInflater: MenuInflater,
menu: Menu
): Boolean {
menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(controller, menuInflater, menu)
}
}

View File

@@ -124,6 +124,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = true
binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonSkipAll.isVisible = false
binding.buttonPause.isVisible = false
}
@@ -147,6 +148,7 @@ fun downloadItemAD(
binding.buttonResume.isVisible = item.isPaused
binding.buttonResume.setText(if (item.error == null) R.string.resume else R.string.retry)
binding.buttonSkip.isVisible = item.isPaused && item.error != null
binding.buttonSkipAll.isVisible = item.isPaused && item.error != null
binding.buttonPause.isVisible = item.canPause
}
@@ -169,6 +171,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonSkipAll.isVisible = false
binding.buttonPause.isVisible = false
}
@@ -182,6 +185,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonSkipAll.isVisible = false
binding.buttonPause.isVisible = false
}
@@ -195,6 +199,7 @@ fun downloadItemAD(
binding.buttonCancel.isVisible = false
binding.buttonResume.isVisible = false
binding.buttonSkip.isVisible = false
binding.buttonSkipAll.isVisible = false
binding.buttonPause.isVisible = false
}
}

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