Compare commits

..

138 Commits

Author SHA1 Message Date
Koitharu
93e8e87b03 Improve global search 2025-03-03 17:38:05 +02:00
Koitharu
09590cfab0 Update reader actions bar 2025-03-03 14:03:47 +02:00
Koitharu
5d91e20844 Update parsers and fix compatibility 2025-03-02 19:31:01 +02:00
Koitharu
d918b1e274 Fix database migration 2025-03-02 13:35:09 +02:00
Koitharu
7fc2d2f36f Fixes 2025-03-01 17:07:02 +02:00
Koitharu
7a01fdd04c Update parsers and adjust database 2025-02-28 15:35:56 +02:00
Koitharu
8724f5b30c Fixes 2025-02-27 09:36:32 +02:00
Koitharu
fcc05e5e5d Merge pull request #1312 from dragonx943/patch-2 2025-02-26 19:34:23 +02:00
Draken
f6284e7107 Use domain for sync server 2025-02-27 00:19:29 +07:00
Koitharu
5e9dc87470 Fixes and improvements batch 2025-02-26 16:51:33 +02:00
Koitharu
b2f0da9245 Fix "Deflater has been closed" error 2025-02-26 08:12:52 +02:00
Koitharu
b27d6dbe9a Refactor: change window insets handling approach 2025-02-26 08:06:57 +02:00
Koitharu
a7caf9848e Dynamic peek height for BS in details 2025-02-25 13:59:41 +02:00
Koitharu
8d44ad8866 Safe way to getQuantityString 2025-02-25 08:53:45 +02:00
Koitharu
e98f5b9d54 Fix locale changing 2025-02-24 19:58:44 +02:00
Koitharu
30d1d47cdc Fixes 2025-02-24 19:42:18 +02:00
Koitharu
1fa470fd00 Option to disable captcha notification for specific source (#1253 #1300) 2025-02-24 14:57:45 +02:00
Koitharu
c835ebff3f Fix scrobbling bs ui (close #1304) 2025-02-24 13:58:14 +02:00
Koitharu
0e76e69aab Update parsers 2025-02-24 13:21:31 +02:00
Koitharu
1857d9f4e9 Merge pull request #1296 from weblate/weblate-kotatsu-strings
Translations update from Hosted Weblate
2025-02-24 12:55:59 +02:00
Макар Разин
34f13ebd52 Translated using Weblate (Russian)
Currently translated at 100.0% (793 of 793 strings)

Translated using Weblate (Belarusian)

Currently translated at 94.7% (751 of 793 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
2025-02-24 11:53:10 +01:00
Draken
2c4a71cbaa Translated using Weblate (Vietnamese)
Currently translated at 100.0% (793 of 793 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (793 of 793 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-02-24 11:53:10 +01:00
Anon
48515a13da Translated using Weblate (Serbian)
Currently translated at 98.6% (782 of 793 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-02-24 11:53:10 +01:00
Infy's Tagalog Translations
f42cda1584 Translated using Weblate (Filipino)
Currently translated at 99.7% (791 of 793 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
2025-02-24 11:53:10 +01:00
Sixten Lund
3fbbb01e27 Translated using Weblate (Swedish)
Currently translated at 94.4% (749 of 793 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Swedish)

Currently translated at 92.5% (734 of 793 strings)

Translated using Weblate (Swedish)

Currently translated at 68.8% (546 of 793 strings)

Translated using Weblate (Swedish)

Currently translated at 66.8% (530 of 793 strings)

Co-authored-by: Sixten Lund <arbitraryindices@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sv/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2025-02-24 11:53:10 +01:00
Frosted
182b8abd7a Translated using Weblate (Turkish)
Currently translated at 100.0% (793 of 793 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-02-24 11:53:10 +01:00
Nicola Bortoletto
dd0445ee79 Translated using Weblate (Italian)
Currently translated at 100.0% (793 of 793 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-02-24 11:53:10 +01:00
Koitharu
d10b64c17f Merge pull request #1297 from dragonx943/patch-2
Add new sync server (Singapore)
2025-02-24 12:53:06 +02:00
Koitharu
ea13c7dbd8 Use WakeLock for background operations 2025-02-23 19:10:05 +02:00
Koitharu
fc5ad9ff90 Include reader tap settings into backups 2025-02-23 18:48:37 +02:00
Koitharu
4cee432a82 Dim navbar in details 2025-02-23 18:06:35 +02:00
Draken
d7c8b12d66 Update constants.xml 2025-02-22 15:13:40 +07:00
Koitharu
87a05ed28a Fix author search 2025-02-20 18:01:16 +02:00
Koitharu
87c242e2bb Dim navbar in details screen 2025-02-20 17:51:47 +02:00
Koitharu
1a7b9c6969 Fix nightly launcher icon 2025-02-20 16:39:12 +02:00
Koitharu
2786e1a2d5 Revert readme updates 2025-02-18 20:18:48 +02:00
Koitharu
05c37da667 Fix build 2025-02-18 20:14:52 +02:00
Koitharu
ed9ed8e964 Fix warnings 2025-02-18 20:08:03 +02:00
Johan Eliott Liebwert
cb36772fd9 Translated using Weblate (Czech)
Currently translated at 100.0% (789 of 789 strings)

Co-authored-by: Johan Eliott Liebwert <joelli@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-02-18 20:05:00 +02:00
Sixten Lund
cc8e31995b Translated using Weblate (Swedish)
Currently translated at 66.0% (521 of 789 strings)

Translated using Weblate (Swedish)

Currently translated at 44.4% (351 of 789 strings)

Co-authored-by: Sixten Lund <arbitraryindices@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sv/
Translation: Kotatsu/Strings
2025-02-18 20:05:00 +02:00
Priit Jõerüüt
82cd8024e9 Translated using Weblate (Estonian)
Currently translated at 69.7% (550 of 789 strings)

Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/et/
Translation: Kotatsu/Strings
2025-02-18 20:05:00 +02:00
Anon
ffc7222b3f Translated using Weblate (Serbian)
Currently translated at 98.7% (779 of 789 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-02-18 20:05:00 +02:00
Master Dwarf
08c48e9997 Translated using Weblate (Portuguese)
Currently translated at 99.8% (788 of 789 strings)

Co-authored-by: Master Dwarf <aoc.anao78@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-02-18 20:05:00 +02:00
Draken
32b6db7343 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (792 of 792 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (790 of 790 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (789 of 789 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-02-18 20:05:00 +02:00
Infy's Tagalog Translations
4dfe0f0d88 Translated using Weblate (Filipino)
Currently translated at 99.8% (788 of 789 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
2025-02-18 20:05:00 +02:00
gekka
0e2224eaf7 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (789 of 789 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-02-18 20:05:00 +02:00
Koitharu
151777cf61 UI improvements and author search support 2025-02-18 19:29:14 +02:00
Koitharu
47a22064a5 Merge pull request #1295 from MAPKOBKA135/devel
fix 100% problem
2025-02-18 15:29:09 +02:00
Koitharu
1b8d35d424 Fix checking for removed manga updates (close #1064) 2025-02-18 15:27:10 +02:00
Koitharu
604efef832 Improve global search 2025-02-18 15:20:25 +02:00
Koitharu
6f67bd7542 Bring back reader info bar outline 2025-02-18 14:37:34 +02:00
Mac135135
52592ba765 correct display of a fully read manga
shows 100% in the details menu if the manga has actually been read in full.
2025-02-18 01:24:48 +03:00
Mac135135
9bb97c72a1 "Check mark" fix on cover
if the manga has been read in full, the "check mark" will be displayed correctly on the cover
2025-02-18 00:46:03 +03:00
Koitharu
b44cf370aa Fix favorite categories visibility (close #1281) 2025-02-17 18:06:20 +02:00
Koitharu
74900970e1 Advanced global search 2025-02-17 17:56:45 +02:00
Koitharu
4ee52e149e Configurable manga lists badges 2025-02-16 19:20:01 +02:00
Koitharu
4148f4a4b9 Update dependencies 2025-02-16 16:26:41 +02:00
Koitharu
a59b6a418d Merge pull request #1291 from TheBestF22/FutureProofing-Local-indexes
Future-Proofing Local Storage by Updating Local Archive indexes/Padding for Pages and Chapters - devel branch
2025-02-16 16:21:18 +02:00
TheBest_F-22!.
d6ae67ba07 Merge branch 'devel' into FutureProofing-Local-indexes 2025-02-16 07:57:35 +00:00
TheBest_F-22!.
be455bc897 Future-Proofing Chapters and Pages Local Storage indexes.
Closes #1289 .
2025-02-16 07:22:44 +00:00
Koitharu
129035bda3 Fix picking directory on some devices 2025-02-15 11:24:31 +02:00
Koitharu
d558c2fcc0 Saved and favorites indicators in manga lists (Draft implementation)(#1286) 2025-02-15 10:53:29 +02:00
Koitharu
cb5df0d73f UI fixes 2025-02-11 14:39:35 +02:00
Koitharu
19e8e3a618 Update kotlin to 2.1 2025-02-11 14:23:18 +02:00
Koitharu
5f0514638a Fix DateTimeAgo formatting 2025-02-09 17:40:28 +02:00
Koitharu
28ae785142 Fix bottom navigation height 2025-02-09 17:03:46 +02:00
Koitharu
8c59f97b02 Merge branch 'master' into devel 2025-02-09 10:55:55 +02:00
Koitharu
8a26587250 Database migration for downgrade from 24 to 23 (#1269, #1270) 2025-02-09 10:15:24 +02:00
Koitharu
bb68869fe1 Fix crashes 2025-02-09 10:11:24 +02:00
Koitharu
e60ca7115a Update parsers 2025-02-09 10:01:28 +02:00
Koitharu
ee4a780acf Update parsers 2025-02-08 17:04:22 +02:00
gekka
8dd6ce2739 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.8% (788 of 789 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Deleted User
41ad7e90d3 Translated using Weblate (Italian)
Currently translated at 100.0% (789 of 789 strings)

Co-authored-by: Deleted User <noreply+104791@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
大王叫我来巡山
31be55d67a Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (784 of 784 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Dragibus Noir
a50b83d876 Translated using Weblate (French)
Currently translated at 99.8% (783 of 784 strings)

Co-authored-by: Dragibus Noir <dragibusnoir@pm.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
gekka
68ce789db1 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (782 of 782 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
urfv ksnph
4702862af3 Translated using Weblate (Indonesian)
Currently translated at 98.2% (768 of 782 strings)

Co-authored-by: urfv ksnph <urfvksnph@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Frosted
c403705b48 Translated using Weblate (Turkish)
Currently translated at 100.0% (789 of 789 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (784 of 784 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (782 of 782 strings)

Co-authored-by: Frosted <frosted@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Nicola Bortoletto
22a5f2d5ee Translated using Weblate (Italian)
Currently translated at 100.0% (782 of 782 strings)

Co-authored-by: Nicola Bortoletto <nicola.bortoletto@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Infy's Tagalog Translations
0f1d8d2835 Translated using Weblate (Filipino)
Currently translated at 99.7% (787 of 789 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (781 of 781 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
2025-02-08 16:44:06 +02:00
Bruno Fragoso
1a6b1672b3 Translated using Weblate (Portuguese)
Currently translated at 100.0% (782 of 782 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: Bruno Fragoso <darth_signa@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Juan Rubin
8e6006177b Translated using Weblate (Portuguese)
Currently translated at 99.7% (779 of 781 strings)

Co-authored-by: Juan Rubin <juancrubin08@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Draken
4e2f010260 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (789 of 789 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (782 of 782 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: Draken <premieregirl26@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Nizar Yazidi
8a191f8f04 Translated using Weblate (Italian)
Currently translated at 100.0% (781 of 781 strings)

Co-authored-by: Nizar Yazidi <fedekikamakeup@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Milan Bhandari
88aca02234 Translated using Weblate (Nepali)
Currently translated at 31.6% (247 of 781 strings)

Co-authored-by: Milan Bhandari <milanbhandari604@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ne/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Hosted Weblate
9189307d00 Update translation files
Updated by "Remove blank strings" hook in Weblate.

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
2025-02-08 16:44:06 +02:00
zmni
00fc579824 Translated using Weblate (Indonesian)
Currently translated at 97.9% (765 of 781 strings)

Translated using Weblate (Indonesian)

Currently translated at 97.9% (765 of 781 strings)

Co-authored-by: zmni <zmni@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Anon
7f8d78e0c9 Translated using Weblate (Serbian)
Currently translated at 98.6% (778 of 789 strings)

Translated using Weblate (Serbian)

Currently translated at 99.2% (775 of 781 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Alvoracz
13bd3e918e Translated using Weblate (Czech)
Currently translated at 98.2% (767 of 781 strings)

Translated using Weblate (Czech)

Currently translated at 97.1% (759 of 781 strings)

Co-authored-by: Alvoracz <sedlor@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/cs/
Translation: Kotatsu/Strings
2025-02-08 16:44:06 +02:00
Koitharu
e1c46f0604 Fix closing welcome bottom sheet 2025-02-05 17:43:02 +02:00
Koitharu
79f3fe196c Update emoji flags mapping 2025-02-05 16:57:18 +02:00
Koitharu
87a47207ab Save page button in reader 2025-02-05 16:06:53 +02:00
Koitharu
772fd5cc6a Screen rotation button in reader 2025-02-05 15:54:54 +02:00
Koitharu
26a2ffcbb1 Reader bottom bar actions configurable 2025-02-05 14:56:41 +02:00
Koitharu
cfecfce51e Update parsers 2025-02-03 18:56:05 +02:00
Koitharu
23c814bf53 Allow to keep user data on app removal 2025-02-03 18:56:04 +02:00
Koitharu
8ca11b214c Reorganize storage usage settings 2025-02-03 18:56:04 +02:00
Koitharu
008f2d705a Restore backups in background 2025-02-03 18:56:00 +02:00
ViAnh
c37f795dac Fix scroller handle being cut off 2025-02-03 18:21:03 +02:00
Koitharu
5d74bdd3b4 Fix reader info bar background settings 2025-02-02 11:56:08 +02:00
Koitharu
382b44accc Transparent reader info bar option 2025-02-01 09:33:11 +02:00
Koitharu
78cd0eff09 Fix reader info bar icon color 2025-02-01 09:33:11 +02:00
Koitharu
12c954600f Merge pull request #1256 from dragonx943/telegram-feature 2025-01-30 09:58:23 +02:00
Talkc0n
a7d4f3b784 Add Telegram backup feature 2025-01-30 14:09:16 +07:00
Koitharu
91f9feba59 Fix details ui 2025-01-30 08:44:01 +02:00
Koitharu
e03a200c32 Fix build 2025-01-25 12:51:13 +02:00
Koitharu
8713faa487 Update parsers 2025-01-25 12:14:01 +02:00
Koitharu
15e99c03a9 Update parsers and adjust imports
(cherry picked from commit 5e8aa4cec7)
2025-01-22 20:21:43 +02:00
Koitharu
b3933848e9 Local manga parsing fixes 2025-01-22 10:15:12 +02:00
Koitharu
e39e5bf9c4 Remove Telegram backup functionality 2025-01-22 09:18:13 +02:00
Koitharu
3a42dce45f Support nested cbz covers 2025-01-19 16:26:00 +02:00
Koitharu
169539f42f Support closing Okio FileSystem 2025-01-19 15:18:07 +02:00
Koitharu
6360731f34 Update reader info bar 2025-01-19 15:02:34 +02:00
Koitharu
914dd9670a Option to disable LeakCanary in debug builds 2025-01-19 13:48:55 +02:00
Koitharu
498b9aed26 Track services using LeakCanary 2025-01-19 13:18:19 +02:00
Koitharu
b425f3e779 Fix nullability for ParcelableManga
(cherry picked from commit b8b601821a)
2025-01-19 12:00:38 +02:00
Koitharu
c6a51d4d08 Increase version 2025-01-19 11:56:26 +02:00
Koitharu
b8b601821a Fix nullability for ParcelableManga 2025-01-19 11:55:13 +02:00
Koitharu
5e8aa4cec7 Update parsers and adjust imports 2025-01-19 11:53:38 +02:00
Koitharu
54bb02937d Merge branch 'master' into devel 2025-01-19 10:33:34 +02:00
Koitharu
74fe786c00 Detect miui 2025-01-19 09:45:51 +02:00
Koitharu
503bff292c Made SyncAuthActivity exported
(cherry picked from commit 663602282a)
2025-01-19 08:28:02 +02:00
Koitharu
0aa78c0d7e Adjust manga fields nullability 2025-01-19 08:24:25 +02:00
Koitharu
8e1d02f356 Update parsers 2025-01-19 08:01:09 +02:00
Koitharu
e8f9d22128 Update dependencies 2025-01-18 15:24:45 +02:00
Draken
fa60ae2947 Add new resources (#1242) 2025-01-17 15:55:35 +02:00
Koitharu
663602282a Made SyncAuthActivity exported 2025-01-13 20:01:22 +02:00
Koitharu
98dbc20cb0 Update parsers and adjust fields nullability 2025-01-13 20:01:22 +02:00
Draken
cc28293bed Update docs (README file) (#1238)
* Update README + Add new images

* [Docs] Tweak + Fixes

* [Docs] Fix image size

* Add some informations + Docs

* [vi] Update screenshots

* Add new counter

* Fix color

* [ru] Add new screenshots

* Update readme

* Update Readme

* Update Readme

* Update Readme

* Refactor images

* Refactor images

* Fix refactor images

* Update descriptions

* Fix themes

* Fix themes

* Final ?

---------

Co-authored-by: Draken <dragonx943@users.noreply.github.com>
2025-01-11 15:36:36 +02:00
Koitharu
1e90d5541b Update parsers 2025-01-11 15:09:21 +02:00
Koitharu
04c7ca7291 Improve local manga chapter names
(cherry picked from commit dddb00d5ef)
2025-01-11 14:59:38 +02:00
Koitharu
8d52cab6d8 Fix manga importing
(cherry picked from commit dcb92ed1af)
2025-01-11 14:59:33 +02:00
Koitharu
efa13df106 Fix crashes 2025-01-11 14:59:25 +02:00
Koitharu
8bc29ac331 Fix local chapters deletion
(cherry picked from commit 25eb05d305)
2025-01-11 14:58:53 +02:00
Koitharu
7991f9ca97 Skip description for ParcelableManga
(cherry picked from commit bf217b3cbf)
2025-01-11 14:58:44 +02:00
Koitharu
eb1eee1681 Fix pages cache usage
(cherry picked from commit 9e2b60e15e)
2025-01-11 14:57:56 +02:00
Koitharu
b3f748c000 Fix crashes
(cherry picked from commit 4dba90361c)
2025-01-11 14:57:49 +02:00
Koitharu
58a9f7b25a Fix settings menu
(cherry picked from commit c51218240e)
2025-01-11 14:56:13 +02:00
401 changed files with 7120 additions and 4451 deletions

BIN
.github/assets/vtuber.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

2
.gitignore vendored
View File

@@ -26,4 +26,4 @@
.cxx
/.idea/deviceManager.xml
/.kotlin/
/.idea/AndroidProjectSystem.xml
/.idea/AndroidProjectSystem.xml

3
.idea/gradle.xml generated
View File

@@ -6,14 +6,13 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="gradleJvm" value="jbr-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

106
README.md
View File

@@ -1,57 +1,107 @@
# Kotatsu
<div align="center">
Kotatsu is a free and open-source manga reader for Android with built-in online content sources.
<a href="https://kotatsu.app">
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
</a>
[![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) ![F-Droid Version](https://img.shields.io/f-droid/v/org.koitharu.kotatsu) ![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)
# [Kotatsu](https://kotatsu.app)
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
![Downloads count](https://img.shields.io/github/downloads/KotatsuApp/Kotatsu/total?color=1976d2) ![Latest Stable version](https://img.shields.io/github/v/release/KotatsuApp/Kotatsu?color=2596be&label=latest) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![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) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
### Download
- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature.
- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing.
- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
<div align="left">
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk).
</div>
### Main Features
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers)
<div align="left">
* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources)
* Search manga by name, genres, and more filters
* Reading history and bookmarks
* 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 customizable reader
* Notifications about new chapters with updates feed
* Reading history, bookmarks, and incognito mode support
* Download manga and read it offline. Third-party CBZ archives are also supported
* Clean and convenient Material You UI, optimized for phones, tablets, and desktop
* Standard and Webtoon-optimized customizable reader, gesture support on reading interface
* Notifications about new chapters with updates feed, manga recommendations (with filters)
* Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu
* Password/fingerprint-protected access to the app
* Password / fingerprint-protected access to the app
* Automatically sync app data with other devices on the same account
* Support for older devices running Android 5+
### Screenshots
</div>
| ![Screenshot_20200226-210337](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) |
|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| ![Screenshot_20200226-210405](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.png) |
### In-App Screenshots
| ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) |
|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
<div align="center">
<img src="./metadata/en-US/images/phoneScreenshots/1.png" alt="Mobile view" width="250"/>
<img src="./metadata/en-US/images/phoneScreenshots/2.png" alt="Mobile view" width="250"/>
<img src="./metadata/en-US/images/phoneScreenshots/3.png" alt="Mobile view" width="250"/>
<img src="./metadata/en-US/images/phoneScreenshots/4.png" alt="Mobile view" width="250"/>
<img src="./metadata/en-US/images/phoneScreenshots/5.png" alt="Mobile view" width="250"/>
<img src="./metadata/en-US/images/phoneScreenshots/6.png" alt="Mobile view" width="250"/>
</div>
<br>
<div align="center">
<img src="./metadata/en-US/images/tenInchScreenshots/1.png" alt="Tablet view" width="400"/>
<img src="./metadata/en-US/images/tenInchScreenshots/2.png" alt="Tablet view" width="400"/>
</div>
### Localization
[<img src="https://hosted.weblate.org/widgets/kotatsu/-/287x66-white.png" alt="Translation status">](https://hosted.weblate.org/engage/kotatsu/)
<a href="https://hosted.weblate.org/engage/kotatsu/">
<img src="https://hosted.weblate.org/widget/kotatsu/horizontal-auto.png" alt="Translation status" />
</a>
Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages,
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**<br>
**📌 If you would like to help improve these or add new languages,
please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)**
### Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines.
<br>
<a href="https://github.com/KotatsuApp/Kotatsu">
<picture>
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu GitHub Repository">
</picture>
</a>
<a href="https://github.com/KotatsuApp/Kotatsu-parsers">
<picture>
<source srcset="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&bg_color=0d1117&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" media="(prefers-color-scheme: dark)">
<img src="https://github-readme-stats.vercel.app/api/pin/?username=KotatsuApp&repo=Kotatsu-parsers&text_color=1976d2&title_color=1976d2&icon_color=0877d2&border_radius=10&description_lines_count=2&show_owner=true" alt="Kotatsu-parsers GitHub Repository">
</picture>
</a><br></br>
</br>
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
### License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
install instructions.
<div align="left">
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
</div>
### DMCA disclaimer
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
<div align="left">
The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser.
</div>

View File

@@ -7,6 +7,7 @@ plugins {
id 'com.google.devtools.ksp'
id 'kotlin-parcelize'
id 'dagger.hilt.android.plugin'
id 'androidx.room'
}
android {
@@ -18,13 +19,12 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
versionCode = 700
versionName = '8.0-a1'
versionCode = 1003
versionName = '8.0-b3'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
arg('room.generateKotlin', 'true')
arg('room.schemaLocation', "$projectDir/schemas")
}
androidResources {
generateLocaleConfig true
@@ -77,6 +77,9 @@ android {
'-opt-in=coil3.annotation.InternalCoilApi',
]
}
room {
schemaDirectory "$projectDir/schemas"
}
lint {
abortOnError true
disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat'

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.os.StrictMode
import androidx.core.content.edit
import androidx.fragment.app.strictmode.FragmentStrictMode
import leakcanary.LeakCanary
import org.koitharu.kotatsu.core.BaseApp
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
@@ -13,9 +16,23 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel
class KotatsuApp : BaseApp() {
var isLeakCanaryEnabled: Boolean
get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true)
set(value) {
getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) }
configureLeakCanary()
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
enableStrictMode()
configureLeakCanary()
}
private fun configureLeakCanary() {
LeakCanary.config = LeakCanary.config.copy(
dumpHeap = isLeakCanaryEnabled,
)
}
private fun enableStrictMode() {
@@ -55,7 +72,7 @@ class KotatsuApp : BaseApp() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) {
penaltyListener(notifier.executor, notifier)
}
}.build()
}.build(),
)
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply {
detectWrongFragmentContainer()
@@ -70,4 +87,13 @@ class KotatsuApp : BaseApp() {
}
}.build()
}
private companion object {
const val PREFS_DEBUG = "_debug"
const val KEY_LEAK_CANARY = "leak_canary"
fun getDebugPreferences(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE)
}
}

View File

@@ -55,7 +55,7 @@ class StrictModeNotifier(
.setContentIntent(
PendingIntentCompat.getActivity(
context,
0,
violation.hashCode(),
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0,
false,

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.core.ui
import android.content.Context
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import leakcanary.AppWatcher
abstract class BaseService : LifecycleService() {
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(ContextCompat.getContextForLanguage(newBase))
}
override fun onDestroy() {
super.onDestroy()
AppWatcher.objectWatcher.watch(
watchedObject = this,
description = "${javaClass.simpleName} service received Service#onDestroy() callback",
)
}
}

View File

@@ -6,6 +6,7 @@ import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import leakcanary.LeakCanary
import org.koitharu.kotatsu.KotatsuApp
import org.koitharu.kotatsu.R
import org.koitharu.workinspector.WorkInspector
@@ -13,10 +14,18 @@ class SettingsMenuProvider(
private val context: Context,
) : MenuProvider {
private val application: KotatsuApp
get() = context.applicationContext as KotatsuApp
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_settings, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_leaks -> {
context.startActivity(LeakCanary.newLeakDisplayActivityIntent())
@@ -28,6 +37,13 @@ class SettingsMenuProvider(
true
}
R.id.action_leakcanary -> {
val checked = !menuItem.isChecked
menuItem.isChecked = checked
application.isLeakCanaryEnabled = checked
true
}
else -> false
}
}

View File

@@ -1,15 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<item
android:id="@id/action_leaks"
android:id="@+id/action_leakcanary"
android:checkable="true"
android:title="LeakCanary"
app:showAsAction="never"
tools:ignore="HardcodedText" />
<item
android:id="@+id/action_leaks"
android:title="@string/leak_canary_display_activity_label"
app:showAsAction="never" />
<item
android:id="@id/action_works"
android:id="@+id/action_works"
android:title="@string/wi_lib_name"
app:showAsAction="never" />

View File

@@ -49,6 +49,7 @@
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:fullBackupContent="@xml/backup_content"
android:fullBackupOnly="true"
android:hasFragileUserData="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
@@ -209,6 +210,7 @@
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
<activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:exported="true"
android:label="@string/sync" />
<activity
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
@@ -279,6 +281,10 @@
<service
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.settings.backup.RestoreService"
android:foregroundServiceType="dataSync"
android:label="@string/restore_backup" />
<service
android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync"

View File

@@ -3,32 +3,28 @@ package org.koitharu.kotatsu.alternatives.domain
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
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 org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.domain.SearchV2Helper
import javax.inject.Inject
private const val MAX_PARALLELISM = 4
private const val MATCH_THRESHOLD_DEFAULT = 0.2f
class AlternativesUseCase @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
private val searchHelperFactory: SearchV2Helper.Factory,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga): Flow<Manga> = invoke(manga, MATCH_THRESHOLD_DEFAULT)
suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow<Manga> {
suspend operator fun invoke(manga: Manga): Flow<Manga> {
val sources = getSources(manga.source)
if (sources.isEmpty()) {
return emptyFlow()
@@ -36,27 +32,23 @@ class AlternativesUseCase @Inject constructor(
val semaphore = Semaphore(MAX_PARALLELISM)
return channelFlow {
for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.filterCapabilities.isSearchSupported) {
continue
}
launch {
val searchHelper = searchHelperFactory.create(source)
val list = runCatchingCancellable {
semaphore.withPermit {
repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title))
searchHelper(manga.title, SearchKind.TITLE)?.manga
}
}.getOrDefault(emptyList())
for (item in list) {
if (item.matches(manga, matchThreshold)) {
send(item)
}.getOrNull()
list?.forEach {
launch {
val details = runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
send(details)
}
}
}
}
}.map {
runCatchingCancellable {
mangaRepositoryFactory.create(it.source).getDetails(it)
}.getOrDefault(it)
}
}
@@ -68,18 +60,6 @@ class AlternativesUseCase @Inject constructor(
return result
}
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?, threshold: Float): Boolean {
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold)
}
private fun MangaSource.priority(ref: MangaSource): Int {
var res = 0
if (this is MangaParserSource && ref is MangaParserSource) {

View File

@@ -35,7 +35,7 @@ class AutoFixUseCase @Inject constructor(
if (seed.isHealthy()) {
return seed to null // no fix required
}
val replacement = alternativesUseCase(seed, matchThreshold = 0.02f)
val replacement = alternativesUseCase(seed)
.filter { it.isHealthy() }
.runningFold<Manga, Manga?>(null) { best, candidate ->
if (best == null || best < candidate) {

View File

@@ -4,6 +4,7 @@ import android.text.style.ForegroundColorSpan
import androidx.core.content.ContextCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.ImageRequest
@@ -26,6 +27,7 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.mangaExtra
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.newImageRequest
@@ -51,10 +53,22 @@ fun alternativeAD(
binding.chipSource.setOnClickListener(clickListener)
bind { payloads ->
binding.textViewTitle.text = item.manga.title
binding.textViewTitle.text = item.mangaModel.title
with(binding.iconsView) {
clearIcons()
if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage)
if (item.mangaModel.isFavorite) addIcon(R.drawable.ic_heart_outline)
isVisible = iconsCount > 0
}
binding.textViewSubtitle.text = buildSpannedString {
if (item.chaptersCount > 0) {
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
append(
context.resources.getQuantityStringSafe(
R.plurals.chapters,
item.chaptersCount,
item.chaptersCount,
),
)
} else {
append(context.getString(R.string.no_chapters))
}
@@ -70,7 +84,10 @@ fun alternativeAD(
}
}
}
binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
binding.progressView.setProgress(
item.mangaModel.progress,
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads,
)
binding.chipSource.also { chip ->
chip.text = item.manga.source.getTitle(chip.context)
ImageRequest.Builder(context)

View File

@@ -1,11 +1,10 @@
package org.koitharu.kotatsu.alternatives.ui
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -16,6 +15,7 @@ import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
@@ -50,6 +50,7 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
with(viewBinding.recyclerView) {
consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
setHasFixedSize(true)
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
adapter = listAdapter
@@ -64,16 +65,6 @@ class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
}
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
viewBinding.recyclerView.updatePadding(
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
)
}
override fun onItemClick(item: MangaAlternativeModel, view: View) {
when (view.id) {
R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title)

View File

@@ -15,17 +15,17 @@ import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -36,8 +36,7 @@ class AlternativesViewModel @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val alternativesUseCase: AlternativesUseCase,
private val migrateUseCase: MigrateUseCase,
private val historyRepository: HistoryRepository,
private val settings: AppSettings,
private val mangaListMapper: MangaListMapper,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
@@ -55,8 +54,7 @@ class AlternativesViewModel @Inject constructor(
alternativesUseCase(ref)
.map {
MangaAlternativeModel(
manga = it,
progress = getProgress(it.id),
mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel,
referenceChapters = refCount,
)
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
@@ -88,8 +86,4 @@ class AlternativesViewModel @Inject constructor(
onMigrated.call(target)
}
}
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
}
}

View File

@@ -24,8 +24,10 @@ 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.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.powerManager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -51,12 +53,14 @@ class AutoFixService : CoroutineIntentService() {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(this)
for (mangaId in ids) {
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(result)
notificationManager.notify(TAG, startId, notification)
powerManager.withPartialWakeLock(TAG) {
val result = runCatchingCancellable {
autoFixUseCase.invoke(mangaId)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(result)
notificationManager.notify(TAG, startId, notification)
}
}
}
}

View File

@@ -1,16 +1,18 @@
package org.koitharu.kotatsu.alternatives.ui
import org.koitharu.kotatsu.core.model.chaptersCount
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.parsers.model.Manga
data class MangaAlternativeModel(
val manga: Manga,
val progress: ReadingProgress?,
val mangaModel: MangaGridModel,
private val referenceChapters: Int,
) : ListModel {
val manga: Manga
get() = mangaModel.manga
val chaptersCount = manga.chaptersCount()
val chaptersDiff: Int
@@ -19,4 +21,10 @@ data class MangaAlternativeModel(
override fun areItemsTheSame(other: ListModel): Boolean {
return other is MangaAlternativeModel && other.manga.id == manga.id
}
override fun getChangePayload(previousState: ListModel): Any? = if (previousState is MangaAlternativeModel) {
mangaModel.getChangePayload(previousState.mangaModel)
} else {
null
}
}

View File

@@ -1,47 +1,5 @@
package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.core.ui.FragmentContainerActivity
@AndroidEntryPoint
class AllBookmarksActivity :
BaseActivity<ActivityContainerBinding>(),
AppBarOwner,
SnackbarOwner {
override val appBar: AppBarLayout
get() = viewBinding.appbar
override val snackbarHost: CoordinatorLayout
get() = viewBinding.root
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
setReorderingAllowed(true)
replace(R.id.container, AllBookmarksFragment::class.java, null)
}
}
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
}
}
class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java)

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.bookmarks.ui
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
@@ -9,9 +10,6 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil3.ImageLoader
@@ -28,6 +26,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
@@ -86,6 +85,7 @@ class AllBookmarksFragment :
)
val spanSizeLookup = SpanSizeLookup()
with(binding.recyclerView) {
consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
setHasFixedSize(true)
val spanResolver = GridSpanResolver(resources)
addItemDecoration(TypedListSpacingDecoration(context, false))
@@ -177,16 +177,6 @@ class AllBookmarksFragment :
}
}
override fun onWindowInsetsChanged(insets: Insets) {
val rv = requireViewBinding().recyclerView
rv.updatePadding(
bottom = insets.bottom + rv.paddingTop,
)
rv.fastScroller.updateLayoutParams<ViewGroup.MarginLayoutParams> {
bottomMargin = insets.bottom
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
init {

View File

@@ -1,38 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.allowRgb565
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemBookmarkBinding
// TODO check usages
fun bookmarkListAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Bookmark>,
) = adapterDelegateViewBinding<Bookmark, Bookmark, ItemBookmarkBinding>(
{ inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) },
) {
AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView)
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
defaultPlaceholders(context)
allowRgb565(true)
bookmarkExtra(item)
decodeRegion(item.scroll)
enqueueWith(coil)
}
}
}

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
@@ -25,6 +26,7 @@ class BookmarksAdapter(
init {
addDelegate(ListItemType.PAGE_THUMB, bookmarkLargeAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(headerClickListener))
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(null))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))

View File

@@ -1,11 +1,10 @@
package org.koitharu.kotatsu.browser
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
@@ -17,6 +16,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -42,6 +42,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository
val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT)
viewBinding.webView.configureForParser(userAgent)
viewBinding.webView.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
viewBinding.webView.webViewClient = BrowserClient(this)
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView)
@@ -114,15 +115,4 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onHistoryChanged() {
onBackPressedCallback.onHistoryChanged()
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
}

View File

@@ -18,6 +18,7 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -29,6 +30,9 @@ class CaptchaNotifier(
if (!context.checkNotificationPermission(CHANNEL_ID)) {
return
}
if (exception.source != null && SourceSettings(context, exception.source).isCaptchaNotificationsDisabled) {
return
}
val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(R.string.captcha_required))

View File

@@ -3,13 +3,12 @@ package org.koitharu.kotatsu.browser.cloudflare
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.graphics.Insets
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
@@ -26,6 +25,7 @@ import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.configureForParser
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
import javax.inject.Inject
@@ -58,6 +58,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
}
cfClient = CloudFlareClient(cookieJar, this, url)
viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT))
viewBinding.webView.consumeInsetsAsPadding(Gravity.START or Gravity.END or Gravity.BOTTOM)
viewBinding.webView.webViewClient = cfClient
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
onBackPressedDispatcher.addCallback(it)
@@ -83,17 +84,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
return super.onCreateOptionsMenu(menu)
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.appbar.updatePadding(
top = insets.top,
)
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom,
)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> {
viewBinding.webView.stopLoading()

View File

@@ -6,6 +6,7 @@ import android.os.Build
import android.provider.SearchRecentSuggestions
import android.text.Html
import androidx.collection.arraySetOf
import androidx.core.content.ContextCompat
import androidx.room.InvalidationTracker
import androidx.work.WorkManager
import coil3.ImageLoader
@@ -76,6 +77,12 @@ interface AppModule {
companion object {
@Provides
@LocalizedAppContext
fun provideLocalizedContext(
@ApplicationContext context: Context,
): Context = ContextCompat.getContextForLanguage(context)
@Provides
@Singleton
fun provideNetworkState(

View File

@@ -13,7 +13,6 @@ 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
import org.acra.ReportField
import org.acra.config.dialog
@@ -26,12 +25,14 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.os.AppValidator
import org.koitharu.kotatsu.core.os.RomCompat
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.WorkServiceStopHelper
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
import java.security.Security
import javax.inject.Inject
@@ -82,17 +83,14 @@ open class BaseApp : Application(), Configuration.Provider {
return
}
AppCompatDelegate.setDefaultNightMode(settings.theme)
AppCompatDelegate.setApplicationLocales(settings.appLocales)
// TLS 1.3 support for Android < 10
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
Security.insertProviderAt(Conscrypt.newProvider(), 1)
}
setupActivityLifecycleCallbacks()
processLifecycleScope.launch {
val isOriginalApp = withContext(Dispatchers.Default) {
appValidator.isOriginalApp
}
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.getOrNull().toString())
ACRA.errorReporter.putCustomData("isMiui", RomCompat.isMiui.getOrNull().toString())
}
processLifecycleScope.launch(Dispatchers.Default) {
setupDatabaseObservers()

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core
import javax.inject.Qualifier
@Qualifier
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.PROPERTY_SETTER,
AnnotationTarget.VALUE_PARAMETER,
AnnotationTarget.FIELD,
)
annotation class LocalizedAppContext

View File

@@ -16,6 +16,7 @@ class BackupEntry(
CATEGORIES("categories"),
FAVOURITES("favourites"),
SETTINGS("settings"),
SETTINGS_READER_GRID("reader_grid"),
BOOKMARKS("bookmarks"),
SOURCES("sources"),
}

View File

@@ -1,15 +1,18 @@
package org.koitharu.kotatsu.core.backup
import androidx.room.withTransaction
import kotlinx.coroutines.flow.FlowCollector
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.util.Date
import javax.inject.Inject
@@ -18,6 +21,7 @@ private const val PAGE_SIZE = 10
class BackupRepository @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
private val tapGridSettings: TapGridSettings,
) {
suspend fun dumpHistory(): BackupEntry {
@@ -103,6 +107,14 @@ class BackupRepository @Inject constructor(
return entry
}
fun dumpReaderGridSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
val settingsDump = tapGridSettings.getAllValues()
val json = JsonSerializer(settingsDump).toJson()
entry.data.put(json)
return entry
}
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
@@ -128,9 +140,11 @@ class BackupRepository @Inject constructor(
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
val list = entry.data.asTypedList<JSONObject>()
outProgress?.emit(Progress(progress = 0, total = list.size))
for ((index, item) in list.withIndex()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
@@ -144,6 +158,7 @@ class BackupRepository @Inject constructor(
db.getHistoryDao().upsert(history)
}
}
outProgress?.emit(Progress(progress = index, total = list.size))
}
return result
}
@@ -159,9 +174,11 @@ class BackupRepository @Inject constructor(
return result
}
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult {
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
val list = entry.data.asTypedList<JSONObject>()
outProgress?.emit(Progress(progress = 0, total = list.size))
for ((index, item) in list.withIndex()) {
val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON {
@@ -175,6 +192,7 @@ class BackupRepository @Inject constructor(
db.getFavouritesDao().upsert(favourite)
}
}
outProgress?.emit(Progress(progress = index, total = list.size))
}
return result
}
@@ -221,4 +239,14 @@ class BackupRepository @Inject constructor(
}
return result
}
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
result += runCatchingCancellable {
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
}
}
return result
}
}

View File

@@ -27,6 +27,10 @@ class CompositeResult {
}
}
operator fun plusAssign(error: Throwable) {
errors.add(error)
}
operator fun plusAssign(other: CompositeResult) {
this.successCount += other.successCount
this.errors += other.errors

View File

@@ -28,15 +28,16 @@ class JsonDeserializer(private val json: JSONObject) {
fun toMangaEntity() = MangaEntity(
id = json.getLong("id"),
title = json.getString("title"),
altTitle = json.getStringOrNull("alt_title"),
altTitles = json.getStringOrNull("alt_title"),
url = json.getString("url"),
publicUrl = json.getStringOrNull("public_url").orEmpty(),
rating = json.getDouble("rating").toFloat(),
isNsfw = json.getBooleanOrDefault("nsfw", false),
contentRating = json.getStringOrNull("content_rating"),
coverUrl = json.getString("cover_url"),
largeCoverUrl = json.getStringOrNull("large_cover_url"),
state = json.getStringOrNull("state"),
author = json.getStringOrNull("author"),
authors = json.getStringOrNull("author"),
source = json.getString("source"),
)

View File

@@ -58,15 +58,16 @@ class JsonSerializer private constructor(private val json: JSONObject) {
JSONObject().apply {
put("id", e.id)
put("title", e.title)
put("alt_title", e.altTitle)
put("alt_title", e.altTitles)
put("url", e.url)
put("public_url", e.publicUrl)
put("rating", e.rating)
put("nsfw", e.isNsfw)
put("content_rating", e.contentRating)
put("cover_url", e.coverUrl)
put("large_cover_url", e.largeCoverUrl)
put("state", e.state)
put("author", e.author)
put("author", e.authors)
put("source", e.source)
},
)

View File

@@ -90,4 +90,4 @@ class TelegramBackupUploader @Inject constructor(
.host("api.telegram.org")
.addPathSegment("bot$botToken")
.addPathSegment(method)
}
}

View File

@@ -39,6 +39,8 @@ 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.Migration23To24
import org.koitharu.kotatsu.core.db.migrations.Migration24To23
import org.koitharu.kotatsu.core.db.migrations.Migration24To25
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
import org.koitharu.kotatsu.core.db.migrations.Migration4To5
@@ -66,7 +68,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 24
const val DATABASE_VERSION = 25
@Database(
entities = [
@@ -134,6 +136,8 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration21To22(),
Migration22To23(),
Migration23To24(),
Migration24To23(),
Migration24To25(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -20,7 +20,7 @@ import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS
data class ChapterEntity(
@ColumnInfo(name = "chapter_id") val chapterId: Long,
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "name") val title: String,
@ColumnInfo(name = "number") val number: Float,
@ColumnInfo(name = "volume") val volume: Int,
@ColumnInfo(name = "url") val url: String,

View File

@@ -1,15 +1,20 @@
package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.longHashCode
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.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.toArraySet
import org.koitharu.kotatsu.parsers.util.toTitleCase
private const val VALUES_DIVIDER = '\n'
// Entity to model
fun TagEntity.toMangaTag() = MangaTag(
@@ -25,15 +30,16 @@ fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>, chapters: List<ChapterEntity>?) = Manga(
id = this.id,
title = this.title,
altTitle = this.altTitle,
altTitles = this.altTitles?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
state = this.state?.let { MangaState(it) },
rating = this.rating,
isNsfw = this.isNsfw,
contentRating = ContentRating(this.contentRating)
?: if (isNsfw) ContentRating.ADULT else null,
url = this.url,
publicUrl = this.publicUrl,
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
authors = this.authors?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(),
source = MangaSource(this.source),
tags = tags,
chapters = chapters?.toMangaChapters(),
@@ -45,7 +51,7 @@ fun Collection<MangaWithTags>.toMangaList() = map { it.toManga() }
fun ChapterEntity.toMangaChapter() = MangaChapter(
id = chapterId,
name = name,
title = title.nullIfEmpty(),
number = number,
volume = volume,
url = url,
@@ -65,13 +71,14 @@ fun Manga.toEntity() = MangaEntity(
publicUrl = publicUrl,
source = source.name,
largeCoverUrl = largeCoverUrl,
coverUrl = coverUrl,
altTitle = altTitle,
coverUrl = coverUrl.orEmpty(),
altTitles = altTitles.joinToString(VALUES_DIVIDER.toString()),
rating = rating,
isNsfw = isNsfw,
contentRating = contentRating?.name,
state = state?.name,
title = title,
author = author,
authors = authors.joinToString(VALUES_DIVIDER.toString()),
)
fun MangaTag.toEntity() = TagEntity(
@@ -87,7 +94,7 @@ fun Iterable<IndexedValue<MangaChapter>>.toEntities(mangaId: Long) = map { (inde
ChapterEntity(
chapterId = chapter.id,
mangaId = mangaId,
name = chapter.name,
title = chapter.title.orEmpty(),
number = chapter.number,
volume = chapter.volume,
url = chapter.url,
@@ -108,3 +115,7 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching {
fun MangaState(name: String): MangaState? = runCatching {
MangaState.valueOf(name)
}.getOrNull()
fun ContentRating(name: String?): ContentRating? = runCatching {
ContentRating.valueOf(name ?: return@runCatching null)
}.getOrNull()

View File

@@ -10,14 +10,15 @@ data class MangaEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "alt_title") val altTitle: String?,
@ColumnInfo(name = "alt_title") val altTitles: String?,
@ColumnInfo(name = "url") val url: String,
@ColumnInfo(name = "public_url") val publicUrl: String,
@ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1
@ColumnInfo(name = "nsfw") val isNsfw: Boolean,
@ColumnInfo(name = "content_rating") val contentRating: String?,
@ColumnInfo(name = "cover_url") val coverUrl: String,
@ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?,
@ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "author") val authors: String?,
@ColumnInfo(name = "source") val source: String,
)

View File

@@ -0,0 +1,11 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration24To23 : Migration(24, 23) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("DROP TABLE IF EXISTS `chapters`")
}
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration24To25 : Migration(24, 25) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE manga ADD COLUMN content_rating TEXT DEFAULT NULL")
db.execSQL("UPDATE manga SET content_rating = 'ADULT' WHERE nsfw = 1")
}
}

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull
import org.koitharu.kotatsu.parsers.util.parseJsonArray
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import javax.inject.Inject
import javax.inject.Singleton
@@ -85,8 +86,8 @@ class AppUpdateRepository @Inject constructor(
}
@Suppress("KotlinConstantConditions")
fun isUpdateSupported(): Boolean {
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp
suspend fun isUpdateSupported(): Boolean {
return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp.getOrNull() == true
}
suspend fun getCurrentVersionChangelog(): String? {

View File

@@ -1,6 +1,6 @@
package org.koitharu.kotatsu.core.github
import org.koitharu.kotatsu.core.util.ext.digits
import org.koitharu.kotatsu.parsers.util.digits
import java.util.Locale
data class VersionId(

View File

@@ -23,8 +23,9 @@ class CbzFetcher(
override suspend fun fetch() = runInterruptible {
val filePath = uri.schemeSpecificPart.toPath()
val entryName = requireNotNull(uri.fragment)
val fs = options.fileSystem.openZip(filePath)
SourceFetchResult(
source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)),
source = ImageSource(entryName.toPath(), fs, closeable = fs),
mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(),
dataSource = DataSource.DISK,
)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.model
import android.content.res.Resources
import android.net.Uri
import android.text.SpannableStringBuilder
import androidx.annotation.DrawableRes
@@ -168,3 +169,24 @@ private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) {
}
}
}
fun MangaChapter.getLocalizedTitle(resources: Resources, index: Int = -1): String {
title?.let {
if (it.isNotBlank()) {
return it
}
}
val num = numberString()
val vol = volumeString()
return when {
num != null && vol != null -> resources.getString(R.string.chapter_volume_number, vol, num)
num != null -> resources.getString(R.string.chapter_number, num)
index > 0 -> resources.getString(
R.string.chapters_time_pattern,
resources.getString(R.string.unnamed_chapter),
index.toString(),
)
else -> resources.getString(R.string.unnamed_chapter)
}
}

View File

@@ -17,7 +17,7 @@ data class ParcelableChapter(
override fun create(parcel: Parcel) = ParcelableChapter(
MangaChapter(
id = parcel.readLong(),
name = parcel.readString().orEmpty(),
title = parcel.readString(),
number = parcel.readFloat(),
volume = parcel.readInt(),
url = parcel.readString().orEmpty(),
@@ -30,7 +30,7 @@ data class ParcelableChapter(
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeString(title)
parcel.writeFloat(number)
parcel.writeInt(volume)
parcel.writeString(url)

View File

@@ -2,12 +2,13 @@ package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import androidx.core.os.ParcelCompat
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.core.util.ext.readStringSet
import org.koitharu.kotatsu.core.util.ext.writeStringSet
import org.koitharu.kotatsu.parsers.model.Manga
@Parcelize
@@ -21,17 +22,17 @@ data class ParcelableManga(
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeString(altTitle)
parcel.writeStringSet(altTitles)
parcel.writeString(url)
parcel.writeString(publicUrl)
parcel.writeFloat(rating)
ParcelCompat.writeBoolean(parcel, isNsfw)
parcel.writeSerializable(contentRating)
parcel.writeString(coverUrl)
parcel.writeString(largeCoverUrl)
parcel.writeString(description.takeIf { withDescription })
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
parcel.writeStringSet(authors)
parcel.writeString(source.name)
}
@@ -39,17 +40,17 @@ data class ParcelableManga(
Manga(
id = parcel.readLong(),
title = requireNotNull(parcel.readString()),
altTitle = parcel.readString(),
altTitles = parcel.readStringSet(),
url = requireNotNull(parcel.readString()),
publicUrl = requireNotNull(parcel.readString()),
rating = parcel.readFloat(),
isNsfw = ParcelCompat.readBoolean(parcel),
coverUrl = requireNotNull(parcel.readString()),
contentRating = parcel.readSerializableCompat(),
coverUrl = parcel.readString(),
largeCoverUrl = parcel.readString(),
description = parcel.readString(),
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(),
author = parcel.readString(),
authors = parcel.readStringSet(),
chapters = null,
source = MangaSource(parcel.readString()),
),

View File

@@ -30,6 +30,7 @@ object MangaListFilterParceler : Parceler<MangaListFilter> {
parcel.writeInt(year)
parcel.writeInt(yearFrom)
parcel.writeInt(yearTo)
parcel.writeString(author)
}
override fun create(parcel: Parcel) = MangaListFilter(
@@ -45,6 +46,7 @@ object MangaListFilterParceler : Parceler<MangaListFilter> {
year = parcel.readInt(),
yearFrom = parcel.readInt(),
yearTo = parcel.readInt(),
author = parcel.readString(),
)
}

View File

@@ -4,12 +4,14 @@ import android.accounts.Account
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.annotation.CheckResult
import androidx.annotation.UiContext
import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@@ -17,7 +19,9 @@ import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.findFragment
import androidx.lifecycle.LifecycleOwner
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.browser.BrowserActivity
@@ -25,15 +29,20 @@ import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.findActivity
import org.koitharu.kotatsu.core.util.ext.mapToArray
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -55,18 +64,22 @@ import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.isNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.domain.SearchKind
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.settings.SettingsActivity
@@ -93,22 +106,27 @@ class AppRouter private constructor(
constructor(fragment: Fragment) : this(null, fragment)
/** Activities **/
fun openList(source: MangaSource, filter: MangaListFilter?) {
startActivity(listIntent(contextOrNull() ?: return, source, filter))
private val settings: AppSettings by lazy {
EntryPointAccessors.fromApplication<AppRouterEntryPoint>(checkNotNull(contextOrNull())).settings
}
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)))
/** Activities **/
fun openSearch(query: String) {
fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) {
startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder))
}
fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null)
fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) {
startActivity(
Intent(contextOrNull() ?: return, SearchActivity::class.java)
.putExtra(KEY_QUERY, query),
.putExtra(KEY_QUERY, query)
.putExtra(KEY_KIND, kind),
)
}
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query))
fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null)
fun openDetails(manga: Manga) {
startActivity(detailsIntent(contextOrNull() ?: return, manga))
@@ -118,6 +136,13 @@ class AppRouter private constructor(
startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
}
fun openDetails(link: Uri) {
startActivity(
Intent(contextOrNull() ?: return, DetailsActivity::class.java)
.setData(link),
)
}
fun openReader(manga: Manga, anchor: View? = null) {
openReader(
ReaderIntent.Builder(contextOrNull() ?: return)
@@ -326,6 +351,44 @@ class AppRouter private constructor(
}.showDistinct()
}
fun showTagDialog(tag: MangaTag) {
buildAlertDialog(contextOrNull() ?: return) {
setTitle(tag.title)
setItems(
arrayOf(
context.getString(R.string.search_on_s, tag.source.getTitle(context)),
context.getString(R.string.search_everywhere),
),
) { _, which ->
when (which) {
0 -> openList(tag)
1 -> openSearch(tag.title, SearchKind.TAG)
}
}
setNegativeButton(R.string.close, null)
setCancelable(true)
}.show()
}
fun showAuthorDialog(author: String, source: MangaSource) {
buildAlertDialog(contextOrNull() ?: return) {
setTitle(author)
setItems(
arrayOf(
context.getString(R.string.search_on_s, source.getTitle(context)),
context.getString(R.string.search_everywhere),
),
) { _, which ->
when (which) {
0 -> openList(source, MangaListFilter(author = author), null)
1 -> openSearch(author, SearchKind.AUTHOR)
}
}
setNegativeButton(R.string.close, null)
setCancelable(true)
}.show()
}
fun showErrorDialog(error: Throwable, url: String? = null) {
ErrorDetailsDialog().withArgs(2) {
putSerializable(KEY_ERROR, error)
@@ -413,6 +476,45 @@ class AppRouter private constructor(
TrackerCategoriesConfigSheet().showDistinct()
}
fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) {
val context = contextOrNull() ?: return
when (settings.allowDownloadOnMeteredNetwork) {
TriStateOption.ENABLED -> onConfirmed(true)
TriStateOption.DISABLED -> onConfirmed(false)
TriStateOption.ASK -> {
if (!context.connectivityManager.isActiveNetworkMetered) {
onConfirmed(true)
return
}
val listener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
onConfirmed(true)
}
DialogInterface.BUTTON_NEUTRAL -> {
onConfirmed(true)
}
DialogInterface.BUTTON_NEGATIVE -> {
settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
onConfirmed(false)
}
}
}
BigButtonsAlertDialog.Builder(context)
.setIcon(R.drawable.ic_network_cellular)
.setTitle(R.string.download_cellular_confirm)
.setPositiveButton(R.string.allow_always, listener)
.setNeutralButton(R.string.allow_once, listener)
.setNegativeButton(R.string.dont_allow, listener)
.create()
.show()
}
}
}
/** Public utils **/
fun isFilterSupported(): Boolean = when {
@@ -427,10 +529,16 @@ class AppRouter private constructor(
}
fun closeWelcomeSheet(): Boolean {
val fm = fragment?.parentFragmentManager ?: activity?.supportFragmentManager ?: return false
val sheet = fm.findFragmentByTag(fragmentTag<WelcomeSheet>()) as? WelcomeSheet ?: return false
sheet.dismissAllowingStateLoss()
return true
val tag = fragmentTag<WelcomeSheet>()
val sheet = fragment?.findFragmentByTagRecursive(tag)
?: activity?.supportFragmentManager?.findFragmentByTag(tag)
?: return false
return if (sheet is WelcomeSheet) {
sheet.dismissAllowingStateLoss()
true
} else {
false
}
}
/** Private utils **/
@@ -455,6 +563,7 @@ class AppRouter private constructor(
return fragment?.childFragmentManager ?: activity?.supportFragmentManager
}
@UiContext
private fun contextOrNull(): Context? = activity ?: fragment?.context
private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner
@@ -477,6 +586,18 @@ class AppRouter private constructor(
)
}
private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? {
childFragmentManager.findFragmentByTag(fragmentTag)?.let {
return it
}
val parent = parentFragment
return if (parent != null) {
parent.findFragmentByTagRecursive(fragmentTag)
} else {
parentFragmentManager.findFragmentByTag(fragmentTag)
}
}
companion object {
fun from(view: View): AppRouter? = runCatching {
@@ -491,7 +612,7 @@ class AppRouter private constructor(
fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java)
.putExtra(KEY_ID, mangaId)
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent =
fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent =
Intent(context, MangaListActivity::class.java)
.setAction(ACTION_MANGA_EXPLORE)
.putExtra(KEY_SOURCE, source.name)
@@ -499,19 +620,24 @@ class AppRouter private constructor(
if (!filter.isNullOrEmpty()) {
putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
}
if (sortOrder != null) {
putExtra(KEY_SORT_ORDER, sortOrder)
}
}
fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent =
Intent(context, CloudFlareActivity::class.java).apply {
data = exception.url.toUri()
putExtra(KEY_SOURCE, exception.source?.name)
exception.headers.get(CommonHeaders.USER_AGENT)?.let {
exception.headers[CommonHeaders.USER_AGENT]?.let {
putExtra(KEY_USER_AGENT, it)
}
}
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
fun readerSettingsIntent(context: Context) =
@@ -561,24 +687,27 @@ class AppRouter private constructor(
.putExtra(KEY_SOURCE, source.name)
}
const val KEY_DATA = "data"
const val KEY_ENTRIES = "entries"
const val KEY_ERROR = "error"
const val KEY_EXCLUDE = "exclude"
const val KEY_FILE = "file"
const val KEY_FILTER = "filter"
const val KEY_ID = "id"
const val KEY_INDEX = "index"
const val KEY_KIND = "kind"
const val KEY_LIST_SECTION = "list_section"
const val KEY_MANGA = "manga"
const val KEY_MANGA_LIST = "manga_list"
const val KEY_PAGES = "pages"
const val KEY_QUERY = "query"
const val KEY_READER_MODE = "reader_mode"
const val KEY_SORT_ORDER = "sort_order"
const val KEY_SOURCE = "source"
const val KEY_TAB = "tab"
const val KEY_TITLE = "title"
const val KEY_USER_AGENT = "user_agent"
const val KEY_URL = "url"
const val KEY_ERROR = "error"
const val KEY_FILE = "file"
const val KEY_INDEX = "index"
const val KEY_DATA = "data"
const val KEY_USER_AGENT = "user_agent"
const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.nav
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.prefs.AppSettings
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AppRouterEntryPoint {
val settings: AppSettings
}

View File

@@ -5,7 +5,7 @@ import androidx.annotation.WorkerThread
import androidx.core.util.Predicate
import okhttp3.Cookie
import okhttp3.HttpUrl
import org.koitharu.kotatsu.core.util.ext.newBuilder
import org.koitharu.kotatsu.parsers.util.newBuilder
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

View File

@@ -17,8 +17,8 @@ import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.isHttpOrHttps
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.net.HttpURLConnection
import java.util.Collections

View File

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

View File

@@ -1,9 +1,12 @@
package org.koitharu.kotatsu.core.os
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.pm.PackageInfoCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import javax.inject.Inject
import javax.inject.Singleton
@@ -11,8 +14,8 @@ import javax.inject.Singleton
class AppValidator @Inject constructor(
@ApplicationContext private val context: Context,
) {
@Suppress("NewApi")
val isOriginalApp by lazy {
@SuppressLint("InlinedApi")
val isOriginalApp = suspendLazy(Dispatchers.Default) {
val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256)
PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false)
}

View File

@@ -69,7 +69,7 @@ class NetworkState(
return true
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activeNetwork?.let { isOnline(it) } ?: false
activeNetwork?.let { isOnline(it) } == true
} else {
@Suppress("DEPRECATION")
activeNetworkInfo?.isConnected == true

View File

@@ -0,0 +1,92 @@
package org.koitharu.kotatsu.core.os
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultCaller
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityOptionsCompat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr
class OpenDocumentTreeHelper(
activityResultCaller: ActivityResultCaller,
flags: Int,
callback: ActivityResultCallback<Uri?>
) : ActivityResultLauncher<Uri?>() {
constructor(activityResultCaller: ActivityResultCaller, callback: ActivityResultCallback<Uri?>) : this(
activityResultCaller,
0,
callback,
)
private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback)
} else {
null
}
private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult(
contract = OpenDocumentTreeContractLegacy(flags),
callback = callback,
)
override fun launch(input: Uri?, options: ActivityOptionsCompat?) {
if (pickFileTreeLauncherQ == null) {
pickFileTreeLauncherLegacy.launch(input, options)
return
}
try {
pickFileTreeLauncherQ.launch(input, options)
} catch (e: Exception) {
e.printStackTraceDebug()
pickFileTreeLauncherLegacy.launch(input, options)
}
}
override fun unregister() {
pickFileTreeLauncherQ?.unregister()
pickFileTreeLauncherLegacy.unregister()
}
override val contract: ActivityResultContract<Uri?, *>
get() = pickFileTreeLauncherQ?.contract ?: pickFileTreeLauncherLegacy.contract
private open class OpenDocumentTreeContractLegacy(
private val flags: Int,
) : ActivityResultContracts.OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent {
val intent = super.createIntent(context, input)
intent.addFlags(flags)
return intent
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private class OpenDocumentTreeContractQ(
private val flags: Int,
) : OpenDocumentTreeContractLegacy(flags) {
override fun createIntent(context: Context, input: Uri?): Intent {
val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager)
?.primaryStorageVolume
?.createOpenDocumentTreeIntent()
if (intent == null) { // fallback
return super.createIntent(context, input)
}
intent.addFlags(flags)
if (input != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input)
}
return intent
}
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.core.os
import kotlinx.coroutines.Dispatchers
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.io.InputStreamReader
object RomCompat {
val isMiui = suspendLazy<Boolean>(Dispatchers.IO) {
getProp("ro.miui.ui.version.name").isNotEmpty()
}
@Blocking
private fun getProp(propName: String) = Runtime.getRuntime().exec("getprop $propName").inputStream.use {
it.reader().use(InputStreamReader::readText).trim()
}
}

View File

@@ -2,22 +2,22 @@ package org.koitharu.kotatsu.core.parser
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.core.AbstractMangaParser
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.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.SortOrder
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery
import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities
import java.util.EnumSet
/**
* This parser is just for parser development, it should not be used in releases
*/
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) {
class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.DUMMY) {
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost")
@@ -25,14 +25,14 @@ 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 val searchQueryCapabilities: MangaSearchQueryCapabilities
get() = MangaSearchQueryCapabilities()
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List<Manga> = stub(null)
override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null)
override suspend fun getList(query: MangaSearchQuery): List<Manga> = stub(null)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)

View File

@@ -6,13 +6,14 @@ import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -109,4 +110,11 @@ class MangaLinkResolver @Inject constructor(
chapters = null,
source = source,
)
companion object {
fun isValidLink(str: String): Boolean {
return str.isHttpUrl() || str.startsWith("kotatsu://", ignoreCase = true)
}
}
}

View File

@@ -7,8 +7,6 @@ import androidx.collection.ArraySet
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.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
@@ -22,6 +20,7 @@ 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.find
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.splitTwoParts
import java.util.EnumSet
@@ -82,7 +81,7 @@ class ExternalPluginContentSource(
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
rating = maxOf(details.rating, manga.rating),
isNsfw = details.isNsfw,
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
coverUrl = details.coverUrl.ifNullOrEmpty { manga.coverUrl },
tags = details.tags + manga.tags,
state = details.state ?: manga.state,
author = details.author.ifNullOrEmpty { manga.author },
@@ -230,7 +229,7 @@ class ExternalPluginContentSource(
do {
result += MangaChapter(
id = cursor.getLong(COLUMN_ID),
name = cursor.getString(COLUMN_NAME),
title = cursor.getStringOrNull(COLUMN_NAME),
number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f),
volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0),
url = cursor.getString(COLUMN_URL),
@@ -253,7 +252,7 @@ class ExternalPluginContentSource(
publicUrl = getString(COLUMN_PUBLIC_URL),
rating = getFloat(COLUMN_RATING),
isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false),
coverUrl = getString(COLUMN_COVER_URL),
coverUrl = getStringOrNull(COLUMN_COVER_URL),
tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet {
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
MangaTag(key = parts.first, title = parts.second, source = source)

View File

@@ -20,7 +20,7 @@ class ExternalPluginCursor(private val source: ExternalMangaSource, cursor: Curs
return when {
columnIndex < 0 -> null
isNull(columnIndex) -> null
else -> getString(columnIndex)
else -> getString(columnIndex).takeUnless { it == "null" }
}
}

View File

@@ -15,12 +15,13 @@ import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.putAll
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
@@ -44,6 +45,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val connectivityManager = context.connectivityManager
private val mangaListBadgesDefault = ArraySet(context.resources.getStringArray(R.array.values_list_badges))
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
@@ -141,6 +143,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
val readerControls: Set<ReaderControl>
get() = prefs.getStringSet(KEY_READER_CONTROLS, null)?.mapNotNullTo(EnumSet.noneOf(ReaderControl::class.java)) {
ReaderControl.entries.find(it)
} ?: ReaderControl.DEFAULT
val isOfflineCheckDisabled: Boolean
get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false)
@@ -367,6 +374,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderBarEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_BAR, true)
val isReaderBarTransparent: Boolean
get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true)
val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
@@ -538,6 +548,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) }
}
fun getMangaListBadges(): Int {
val raw = prefs.getStringSet(KEY_MANGA_LIST_BADGES, mangaListBadgesDefault).orEmpty()
var result = 0
for (item in raw) {
result = result or item.toInt()
}
return result
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
@@ -550,20 +569,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
fun getAllValues(): Map<String, *> = prefs.all
fun upsertAll(m: Map<String, *>) {
prefs.edit {
m.forEach { e ->
when (val v = e.value) {
is Boolean -> putBoolean(e.key, v)
is Int -> putInt(e.key, v)
is Long -> putLong(e.key, v)
is Float -> putFloat(e.key, v)
is String -> putString(e.key, v)
is JSONArray -> putStringSet(e.key, v.toStringSet())
}
}
}
}
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
private fun isBackgroundNetworkRestricted(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -573,15 +579,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}
private fun JSONArray.toStringSet(): Set<String> {
val len = length()
val result = ArraySet<String>(len)
for (i in 0 until len) {
result.add(getString(i))
}
return result
}
companion object {
const val TRACK_HISTORY = "history"
@@ -628,6 +625,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_NOTIFICATIONS_LIGHT = "notifications_light"
const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info"
const val KEY_READER_ANIMATION = "reader_animation2"
const val KEY_READER_CONTROLS = "reader_controls"
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_READER_CROP = "reader_crop"
@@ -671,6 +669,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SYNC = "sync"
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent"
const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
@@ -725,12 +724,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_QUICK_FILTER = "quick_filter"
const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled"
const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id"
const val KEY_MANGA_LIST_BADGES = "manga_list_badges"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_TRACKER_DEBUG = "tracker_debug"
const val KEY_APP_UPDATE = "app_update"
const val KEY_LINK_WEBLATE = "about_app_translation"
const val KEY_LINK_TELEGRAM = "about_telegram"
const val KEY_LINK_GITHUB = "about_github"
@@ -741,6 +740,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
const val KEY_STORAGE_USAGE = "storage_usage"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -1,11 +1,13 @@
package org.koitharu.kotatsu.core.prefs
import android.content.Context
import android.graphics.drawable.Drawable
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 org.koitharu.kotatsu.core.util.ext.isNightMode
import com.google.android.material.R as materialR
@Keep
@@ -13,7 +15,7 @@ enum class ReaderBackground {
DEFAULT, LIGHT, DARK, WHITE, BLACK;
fun resolve(context: Context) = when (this) {
fun resolve(context: Context): Drawable? = when (this) {
DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground)
LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light)
.getThemeDrawable(android.R.attr.windowBackground)
@@ -24,4 +26,14 @@ enum class ReaderBackground {
WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable()
BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable()
}
fun isLight(context: Context): Boolean = when (this) {
DEFAULT -> !context.resources.isNightMode
LIGHT,
WHITE -> true
DARK,
BLACK -> false
}
}

View File

@@ -0,0 +1,15 @@
package org.koitharu.kotatsu.core.prefs
import java.util.EnumSet
enum class ReaderControl {
PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET, SCREEN_ROTATION, SAVE_PAGE;
companion object {
val DEFAULT: Set<ReaderControl> = EnumSet.of(
PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET,
)
}
}

View File

@@ -4,13 +4,13 @@ import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.core.content.edit
import org.koitharu.kotatsu.core.util.ext.getEnumValue
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.putEnumValue
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
@@ -25,6 +25,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
val isSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
val isCaptchaNotificationsDisabled: Boolean
get() = prefs.getBoolean(KEY_NO_CAPTCHA, false)
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T {
return when (key) {
@@ -65,5 +68,6 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
const val KEY_SORT_ORDER = "sort_order"
const val KEY_SLOWDOWN = "slowdown"
const val KEY_NO_CAPTCHA = "no_captcha"
}
}

View File

@@ -1,18 +1,20 @@
package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityCompat
import androidx.core.view.WindowCompat
import androidx.fragment.app.FragmentManager
import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
@@ -23,15 +25,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper
abstract class BaseActivity<B : ViewBinding> :
AppCompatActivity(),
ExceptionResolver.Host,
ScreenshotPolicyHelper.ContentContainer,
WindowInsetsDelegate.WindowInsetsListener {
ScreenshotPolicyHelper.ContentContainer {
private var isAmoledTheme = false
@@ -41,16 +41,20 @@ abstract class BaseActivity<B : ViewBinding> :
protected lateinit var exceptionResolver: ExceptionResolver
private set
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
@JvmField
val actionModeDelegate = ActionModeDelegate()
private var defaultStatusBarColor = Color.TRANSPARENT
private lateinit var entryPoint: BaseActivityEntryPoint
override fun attachBaseContext(newBase: Context) {
entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(newBase.applicationContext)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
AppCompatDelegate.setApplicationLocales(entryPoint.settings.appLocales)
}
super.attachBaseContext(newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
val entryPoint = EntryPointAccessors.fromApplication<BaseActivityEntryPoint>(this)
val settings = entryPoint.settings
isAmoledTheme = settings.isAmoledTheme
setTheme(settings.colorScheme.styleResId)
@@ -59,10 +63,8 @@ abstract class BaseActivity<B : ViewBinding> :
}
putDataToExtras(intent)
exceptionResolver = entryPoint.exceptionResolverFactory.create(this)
enableEdgeToEdge()
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsDelegate.handleImeInsets = true
insetsDelegate.addInsetsListener(this)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
@@ -96,7 +98,6 @@ abstract class BaseActivity<B : ViewBinding> :
super.setContentView(binding.root)
val toolbar = (binding.root.findViewById<View>(R.id.toolbar) as? Toolbar)
toolbar?.let(this::setSupportActionBar)
insetsDelegate.onViewCreated(binding.root)
}
override fun onSupportNavigateUp(): Boolean {

View File

@@ -10,12 +10,10 @@ import androidx.viewbinding.ViewBinding
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
abstract class BaseFragment<B : ViewBinding> :
Fragment(),
ExceptionResolver.Host,
WindowInsetsDelegate.WindowInsetsListener {
ExceptionResolver.Host {
var viewBinding: B? = null
private set
@@ -23,9 +21,6 @@ abstract class BaseFragment<B : ViewBinding> :
protected lateinit var exceptionResolver: ExceptionResolver
private set
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
protected val actionModeDelegate: ActionModeDelegate
get() = (requireActivity() as BaseActivity<*>).actionModeDelegate
@@ -47,15 +42,11 @@ abstract class BaseFragment<B : ViewBinding> :
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
onViewBindingCreated(requireViewBinding(), savedInstanceState)
}
override fun onDestroyView() {
viewBinding = null
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView()
super.onDestroyView()
}

View File

@@ -2,11 +2,9 @@ package org.koitharu.kotatsu.core.ui
import android.content.Context
import android.os.Bundle
import android.view.Gravity
import android.view.View
import androidx.annotation.CallSuper
import androidx.annotation.StringRes
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceScreen
@@ -17,7 +15,7 @@ import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.consumeInsetsAsPadding
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import org.koitharu.kotatsu.core.util.ext.parentView
@@ -28,7 +26,6 @@ import com.google.android.material.R as materialR
@AndroidEntryPoint
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
PreferenceFragmentCompat(),
WindowInsetsDelegate.WindowInsetsListener,
RecyclerViewOwner,
ExceptionResolver.Host {
@@ -38,10 +35,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
@Inject
lateinit var settings: AppSettings
@JvmField
protected val insetsDelegate = WindowInsetsDelegate()
override val recyclerView: RecyclerView
override val recyclerView: RecyclerView?
get() = listView
override fun onAttach(context: Context) {
@@ -55,14 +49,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
val themedContext = (view.parentView ?: view).context
view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground))
listView.clipToPadding = false
insetsDelegate.onViewCreated(view)
insetsDelegate.addInsetsListener(this)
}
override fun onDestroyView() {
insetsDelegate.removeInsetsListener(this)
insetsDelegate.onDestroyView()
super.onDestroyView()
listView.consumeInsetsAsPadding(Gravity.BOTTOM or Gravity.START or Gravity.END)
}
override fun onResume() {
@@ -74,13 +61,6 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
}
}
@CallSuper
override fun onWindowInsetsChanged(insets: Insets) {
listView.updatePadding(
bottom = insets.bottom,
)
}
protected open fun setTitle(title: CharSequence?) {
(activity as? SettingsActivity)?.setSectionTitle(title)
}

View File

@@ -1,5 +0,0 @@
package org.koitharu.kotatsu.core.ui
import androidx.lifecycle.LifecycleService
abstract class BaseService : LifecycleService()

View File

@@ -14,6 +14,7 @@ import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -42,6 +43,8 @@ abstract class CoroutineIntentService : BaseService() {
intentJobContext.processIntent(intent)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
intentJobContext.onError(e)

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.ui
import android.os.Bundle
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
@AndroidEntryPoint
abstract class FragmentContainerActivity(private val fragmentClass: Class<out Fragment>) :
BaseActivity<ActivityContainerBinding>(),
AppBarOwner,
SnackbarOwner {
override val appBar: AppBarLayout
get() = viewBinding.appbar
override val snackbarHost: CoordinatorLayout
get() = viewBinding.root
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
setReorderingAllowed(true)
replace(R.id.container, fragmentClass, getFragmentExtras())
}
}
}
protected open fun getFragmentExtras(): Bundle? = intent.extras
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.view.LayoutInflater
import android.widget.CompoundButton.OnCheckedChangeListener
import androidx.annotation.StringRes
import androidx.annotation.UiContext
import androidx.appcompat.app.AlertDialog
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.LinearLayoutManager
@@ -17,7 +18,7 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding
import com.google.android.material.R as materialR
inline fun buildAlertDialog(
context: Context,
@UiContext context: Context,
isCentered: Boolean = false,
block: MaterialAlertDialogBuilder.() -> Unit,
): AlertDialog = MaterialAlertDialogBuilder(

View File

@@ -1,58 +0,0 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.Context
import android.content.DialogInterface
import androidx.annotation.UiContext
import androidx.core.net.ConnectivityManagerCompat
import dagger.Lazy
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.TriStateOption
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import javax.inject.Inject
class CommonAlertDialogs @Inject constructor(
private val settings: Lazy<AppSettings>,
) {
fun askForDownloadOverMeteredNetwork(
@UiContext context: Context,
onConfirmed: (allow: Boolean) -> Unit
) {
when (settings.get().allowDownloadOnMeteredNetwork) {
TriStateOption.ENABLED -> onConfirmed(true)
TriStateOption.DISABLED -> onConfirmed(false)
TriStateOption.ASK -> {
if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) {
onConfirmed(true)
return
}
val listener = DialogInterface.OnClickListener { _, which ->
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED
onConfirmed(true)
}
DialogInterface.BUTTON_NEUTRAL -> {
onConfirmed(true)
}
DialogInterface.BUTTON_NEGATIVE -> {
settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED
onConfirmed(false)
}
}
}
BigButtonsAlertDialog.Builder(context)
.setIcon(R.drawable.ic_network_cellular)
.setTitle(R.string.download_cellular_confirm)
.setPositiveButton(R.string.allow_always, listener)
.setNeutralButton(R.string.allow_once, listener)
.setNegativeButton(R.string.dont_allow, listener)
.create()
.show()
}
}
}
}

View File

@@ -1,8 +1,5 @@
package org.koitharu.kotatsu.core.ui.dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
@@ -14,6 +11,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.copyToClipboard
import org.koitharu.kotatsu.core.util.ext.getCauseUrl
import org.koitharu.kotatsu.core.util.ext.isReportable
import org.koitharu.kotatsu.core.util.ext.report
@@ -53,7 +51,7 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
.setNegativeButton(android.R.string.cancel, null)
.setTitle(R.string.error_occurred)
.setNeutralButton(androidx.preference.R.string.copy) { _, _ ->
copyToClipboard()
context?.copyToClipboard(getString(R.string.error), exception.stackTraceToString())
}
if (exception.isReportable()) {
builder.setPositiveButton(R.string.report) { _, _ ->
@@ -63,12 +61,4 @@ class ErrorDetailsDialog : AlertDialogFragment<DialogErrorDetailsBinding>() {
}
return builder
}
private fun copyToClipboard() {
val clipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return
clipboardManager.setPrimaryClip(
ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
)
}
}

View File

@@ -1,13 +1,12 @@
package org.koitharu.kotatsu.core.ui.image
import android.view.View
import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.ViewTreeObserver.OnPreDrawListener
import android.widget.ImageView
import coil3.size.Dimension
import coil3.size.Size
import coil3.size.ViewSizeResolver
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.math.roundToInt
@@ -20,31 +19,67 @@ class CoverSizeResolver(
) : ViewSizeResolver<ImageView> {
override suspend fun size(): Size {
// Fast path: the view is already measured.
getSize()?.let { return it }
return suspendCancellableCoroutine { cont ->
val layoutListener = LayoutListener(cont)
view.addOnLayoutChangeListener(layoutListener)
cont.invokeOnCancellation {
view.removeOnLayoutChangeListener(layoutListener)
// Slow path: wait for the view to be measured.
return suspendCancellableCoroutine { continuation ->
val viewTreeObserver = view.viewTreeObserver
val preDrawListener = object : OnPreDrawListener {
private var isResumed = false
override fun onPreDraw(): Boolean {
val size = getSize()
if (size != null) {
viewTreeObserver.removePreDrawListenerSafe(this)
if (!isResumed) {
isResumed = true
continuation.resume(size)
}
}
return true
}
}
viewTreeObserver.addOnPreDrawListener(preDrawListener)
continuation.invokeOnCancellation {
viewTreeObserver.removePreDrawListenerSafe(preDrawListener)
}
}
}
private fun getSize(): Size? {
val lp = view.layoutParams
var width = getDimension(lp.width, view.width, view.paddingLeft + view.paddingRight)
var height = getDimension(lp.height, view.height, view.paddingTop + view.paddingBottom)
if (width == null && height == null) {
return null
}
if (height == null && width != null) {
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
} else if (width == null && height != null) {
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
var width = getWidth()
var height = getHeight()
when {
width == null && height == null -> {
return null
}
height == null && width != null -> {
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
}
width == null && height != null -> {
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
}
}
return Size(checkNotNull(width), checkNotNull(height))
}
private fun getWidth() = getDimension(
paramSize = view.layoutParams?.width ?: -1,
viewSize = view.width,
paddingSize = if (subtractPadding) view.paddingLeft + view.paddingRight else 0
)
private fun getHeight() = getDimension(
paramSize = view.layoutParams?.height ?: -1,
viewSize = view.height,
paddingSize = if (subtractPadding) view.paddingTop + view.paddingBottom else 0
)
private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? {
if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
return null
@@ -60,24 +95,11 @@ class CoverSizeResolver(
return null
}
private inner class LayoutListener(
private val continuation: CancellableContinuation<Size>,
) : OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
val size = getSize() ?: return
v.removeOnLayoutChangeListener(this)
continuation.resume(size)
private fun ViewTreeObserver.removePreDrawListenerSafe(victim: OnPreDrawListener) {
if (isAlive) {
removeOnPreDrawListener(victim)
} else {
view.viewTreeObserver.removeOnPreDrawListener(victim)
}
}
}

View File

@@ -37,8 +37,8 @@ class FastScrollRecyclerView @JvmOverloads constructor(
init {
fastScroller.id = R.id.fast_scroller
fastScroller.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT,
LayoutParams.MATCH_PARENT,
)
}

View File

@@ -11,7 +11,9 @@ import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.*
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.RelativeLayout
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.DimenRes
@@ -162,7 +164,7 @@ class FastScroller @JvmOverloads constructor(
override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) {
super.onSizeChanged(w, h, oldW, oldH)
viewHeight = h
viewHeight = h - paddingTop - paddingBottom
}
@SuppressLint("ClickableViewAccessibility")
@@ -229,6 +231,7 @@ class FastScroller @JvmOverloads constructor(
*
* @param params The [ViewGroup.LayoutParams] for this view, cannot be null
*/
@Suppress("RemoveRedundantQualifierName")
override fun setLayoutParams(params: ViewGroup.LayoutParams) {
params.width = LayoutParams.WRAP_CONTENT
super.setLayoutParams(params)
@@ -529,7 +532,7 @@ class FastScroller @JvmOverloads constructor(
private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p as ViewGroup
p
} else {
null
}

View File

@@ -1,17 +1,19 @@
package org.koitharu.kotatsu.core.ui.model
import android.content.res.Resources
import android.content.Context
import android.text.format.DateUtils
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe
import org.koitharu.kotatsu.core.util.ext.toMillis
import java.time.LocalDate
import java.time.format.DateTimeFormatter
sealed class DateTimeAgo {
abstract fun format(resources: Resources): String
abstract fun format(context: Context): String
object JustNow : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.just_now)
override fun format(context: Context): String {
return context.getString(R.string.just_now)
}
override fun toString() = "just_now"
@@ -20,24 +22,32 @@ sealed class DateTimeAgo {
}
data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
override fun format(context: Context): String {
return context.resources.getQuantityStringSafe(
R.plurals.minutes_ago,
minutes,
minutes,
)
}
override fun toString() = "minutes_ago_$minutes"
}
data class HoursAgo(val hours: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
override fun format(context: Context): String {
return context.resources.getQuantityStringSafe(
R.plurals.hours_ago,
hours,
hours,
)
}
override fun toString() = "hours_ago_$hours"
}
object Today : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.today)
override fun format(context: Context): String {
return context.getString(R.string.today)
}
override fun toString() = "today"
@@ -46,8 +56,8 @@ sealed class DateTimeAgo {
}
object Yesterday : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.yesterday)
override fun format(context: Context): String {
return context.getString(R.string.yesterday)
}
override fun toString() = "yesterday"
@@ -56,44 +66,46 @@ sealed class DateTimeAgo {
}
data class DaysAgo(val days: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getQuantityString(R.plurals.days_ago, days, days)
override fun format(context: Context): String {
return context.resources.getQuantityStringSafe(R.plurals.days_ago, days, days)
}
override fun toString() = "days_ago_$days"
}
data class MonthsAgo(val months: Int) : DateTimeAgo() {
override fun format(resources: Resources): String {
override fun format(context: Context): String {
return if (months == 0) {
resources.getString(R.string.this_month)
context.getString(R.string.this_month)
} else {
resources.getQuantityString(R.plurals.months_ago, months, months)
context.resources.getQuantityStringSafe(
R.plurals.months_ago,
months,
months,
)
}
}
}
data class Absolute(private val date: LocalDate) : DateTimeAgo() {
override fun format(resources: Resources): String {
override fun format(context: Context): String {
return if (date == EPOCH_DATE) {
resources.getString(R.string.unknown)
context.getString(R.string.unknown)
} else {
date.format(formatter)
DateUtils.formatDateTime(context, date.toMillis(), DateUtils.FORMAT_SHOW_DATE)
}
}
override fun toString() = "abs_${date.toEpochDay()}"
companion object {
// TODO: Use Java 9's LocalDate.EPOCH.
private val EPOCH_DATE = LocalDate.of(1970, 1, 1)
private val formatter = DateTimeFormatter.ofPattern("d MMMM")
private companion object {
val EPOCH_DATE: LocalDate = LocalDate.of(1970, 1, 1)
}
}
object LongAgo : DateTimeAgo() {
override fun format(resources: Resources): String {
return resources.getString(R.string.long_ago)
override fun format(context: Context): String {
return context.getString(R.string.long_ago)
}
override fun toString() = "long_ago"

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import androidx.annotation.GravityInt
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import org.koitharu.kotatsu.core.util.ext.consumeRelative
import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.start
class InsetsToMarginsListener(
@GravityInt
private val sides: Int,
private val baseMargins: Insets,
) : OnApplyWindowInsetsListener {
private val insetType = WindowInsetsCompat.Type.systemBars()
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.getInsets(insetType)
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
if (sides and Gravity.START == Gravity.START) {
marginStart = barsInsets.start(v) + baseMargins.start(v)
}
if (sides and Gravity.TOP == Gravity.TOP) {
topMargin = barsInsets.top + baseMargins.top
}
if (sides and Gravity.END == Gravity.END) {
marginEnd = barsInsets.end(v) + baseMargins.end(v)
}
if (sides and Gravity.BOTTOM == Gravity.BOTTOM) {
bottomMargin = barsInsets.bottom + baseMargins.bottom
}
}
return WindowInsetsCompat.Builder(insets)
.setInsets(
insetType,
barsInsets.consumeRelative(
v,
start = sides and Gravity.START == Gravity.START,
top = sides and Gravity.TOP == Gravity.TOP,
end = sides and Gravity.END == Gravity.END,
bottom = sides and Gravity.BOTTOM == Gravity.BOTTOM,
),
).build()
}
}

View File

@@ -0,0 +1,61 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.Gravity
import android.view.View
import androidx.annotation.GravityInt
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsCompat
import org.koitharu.kotatsu.core.util.ext.consumeRelative
import org.koitharu.kotatsu.core.util.ext.end
import org.koitharu.kotatsu.core.util.ext.start
class InsetsToPaddingListener(
@GravityInt
private val sides: Int,
private val basePaddings: Insets,
) : OnApplyWindowInsetsListener {
private val insetType = WindowInsetsCompat.Type.systemBars()
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.getInsets(insetType)
v.setPaddingRelative(
/* start = */
if (sides and Gravity.START == Gravity.START) {
barsInsets.start(v) + basePaddings.start(v)
} else {
v.paddingStart
},
/* top = */
if (sides and Gravity.TOP == Gravity.TOP) {
barsInsets.top + basePaddings.top
} else {
v.paddingTop
},
/* end = */
if (sides and Gravity.END == Gravity.END) {
barsInsets.end(v) + basePaddings.end(v)
} else {
v.paddingEnd
},
/* bottom = */
if (sides and Gravity.BOTTOM == Gravity.BOTTOM) {
barsInsets.bottom + basePaddings.bottom
} else {
v.paddingBottom
},
)
return WindowInsetsCompat.Builder(insets)
.setInsets(
insetType,
barsInsets.consumeRelative(
v,
start = sides and Gravity.START == Gravity.START,
top = sides and Gravity.TOP == Gravity.TOP,
end = sides and Gravity.END == Gravity.END,
bottom = sides and Gravity.BOTTOM == Gravity.BOTTOM,
),
).build()
}
}

View File

@@ -4,5 +4,5 @@ import androidx.recyclerview.widget.RecyclerView
interface RecyclerViewOwner {
val recyclerView: RecyclerView
val recyclerView: RecyclerView?
}

View File

@@ -1,77 +0,0 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.View
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import java.util.LinkedList
class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener {
@JvmField
var handleImeInsets: Boolean = false
@JvmField
var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null
private val listeners = LinkedList<WindowInsetsListener>()
private var lastInsets: Insets? = null
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val handledInsets = interceptingWindowInsetsListener?.onApplyWindowInsets(v, insets) ?: insets
val newInsets = if (handleImeInsets) {
Insets.max(
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()),
handledInsets.getInsets(WindowInsetsCompat.Type.ime()),
)
} else {
handledInsets.getInsets(WindowInsetsCompat.Type.systemBars())
}
if (newInsets != lastInsets) {
listeners.forEach { it.onWindowInsetsChanged(newInsets) }
lastInsets = newInsets
}
return handledInsets
}
override fun onLayoutChange(
view: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
view.removeOnLayoutChangeListener(this)
if (lastInsets == null) { // Listener may not be called
onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view) ?: return)
}
}
fun addInsetsListener(listener: WindowInsetsListener) {
listeners.add(listener)
lastInsets?.let { listener.onWindowInsetsChanged(it) }
}
fun removeInsetsListener(listener: WindowInsetsListener) {
listeners.remove(listener)
}
fun onViewCreated(view: View) {
ViewCompat.setOnApplyWindowInsetsListener(view, this)
view.addOnLayoutChangeListener(this)
}
fun onDestroyView() {
lastInsets = null
}
fun interface WindowInsetsListener {
fun onWindowInsetsChanged(insets: Insets)
}
}

View File

@@ -0,0 +1,90 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.annotation.DrawableRes
import androidx.core.content.withStyledAttributes
import androidx.core.view.isVisible
import org.koitharu.kotatsu.R
class IconsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : LinearLayout(context, attrs) {
private var iconSize = LayoutParams.WRAP_CONTENT
private var iconSpacing = 0
val iconsCount: Int
get() {
var count = 0
repeat(childCount) { i ->
if (getChildAt(i).isVisible) {
count++
}
}
return count
}
init {
context.withStyledAttributes(attrs, R.styleable.IconsView) {
iconSize = getDimensionPixelSize(R.styleable.IconsView_iconSize, iconSize)
iconSpacing = getDimensionPixelOffset(R.styleable.IconsView_iconSpacing, iconSpacing)
}
}
fun setIcons(icons: Iterable<Drawable>) {
var index = 0
for (icon in icons) {
val imageView = (getChildAt(index) as ImageView?) ?: addImageView()
imageView.setImageDrawable(icon)
imageView.isVisible = true
index++
}
for (i in index until childCount) {
val imageView = getChildAt(i) as? ImageView ?: continue
imageView.setImageDrawable(null)
imageView.isVisible = false
}
}
fun clearIcons() {
repeat(childCount) { i ->
getChildAt(i).isVisible = false
}
}
fun addIcon(drawable: Drawable) {
val imageView = getNextImageView()
imageView.setImageDrawable(drawable)
imageView.isVisible = true
}
fun addIcon(@DrawableRes resId: Int) {
val imageView = getNextImageView()
imageView.setImageResource(resId)
imageView.isVisible = true
}
private fun getNextImageView(): ImageView {
repeat(childCount) { i ->
val child = getChildAt(i)
if (child is ImageView && !child.isVisible) {
return child
}
}
return addImageView()
}
private fun addImageView() = ImageView(context).also {
it.scaleType = ImageView.ScaleType.FIT_CENTER
val lp = LayoutParams(iconSize, iconSize)
if (childCount != 0) {
lp.marginStart = iconSpacing
}
addView(it, lp)
}
}

View File

@@ -1,183 +0,0 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Outline
import android.graphics.Paint
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.view.ViewOutlineProvider
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils
import androidx.core.view.children
import androidx.core.widget.TextViewCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import com.google.android.material.R as materialR
@Deprecated("")
class ProgressButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : LinearLayoutCompat(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener {
private val textViewTitle = TextView(context)
private val textViewSubtitle = TextView(context)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var progress = 0f
private var targetProgress = 0f
private var colorBase: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT)
private var colorProgress: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT)
private var progressAnimator: ValueAnimator? = null
private var colorBaseCurrent = colorProgress.defaultColor
private var colorProgressCurrent = colorProgress.defaultColor
var title: CharSequence?
get() = textViewTitle.textAndVisible
set(value) {
textViewTitle.textAndVisible = value
}
var subtitle: CharSequence?
get() = textViewSubtitle.textAndVisible
set(value) {
textViewSubtitle.textAndVisible = value
}
init {
orientation = VERTICAL
outlineProvider = OutlineProvider()
clipToOutline = true
context.withStyledAttributes(attrs, R.styleable.ProgressButton, defStyleAttr) {
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
TextViewCompat.setTextAppearance(
textViewTitle,
getResourceId(R.styleable.ProgressButton_titleTextAppearance, textAppearanceFallback),
)
TextViewCompat.setTextAppearance(
textViewSubtitle,
getResourceId(R.styleable.ProgressButton_subtitleTextAppearance, textAppearanceFallback),
)
textViewTitle.text = getText(R.styleable.ProgressButton_title)
textViewSubtitle.text = getText(R.styleable.ProgressButton_subtitle)
colorBase = getColorStateList(R.styleable.ProgressButton_baseColor)
?: context.getThemeColorStateList(materialR.attr.colorPrimaryContainer) ?: colorBase
colorProgress = getColorStateList(R.styleable.ProgressButton_progressColor)
?: context.getThemeColorStateList(materialR.attr.colorPrimary) ?: colorProgress
getColorStateList(R.styleable.ProgressButton_android_textColor)?.let { colorText ->
textViewTitle.setTextColor(colorText)
textViewSubtitle.setTextColor(colorText)
}
progress = getInt(R.styleable.ProgressButton_android_progress, 0).toFloat() /
getInt(R.styleable.ProgressButton_android_max, 100).toFloat()
}
addView(textViewTitle, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
addView(
textViewSubtitle,
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).also { lp ->
lp.topMargin = context.resources.resolveDp(2)
},
)
paint.style = Paint.Style.FILL
applyGravity()
setWillNotDraw(false)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(colorBaseCurrent)
if (progress > 0f) {
canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint)
}
}
override fun drawableStateChanged() {
super.drawableStateChanged()
val state = drawableState
colorBaseCurrent = colorBase.getColorForState(state, colorBase.defaultColor)
colorProgressCurrent = colorProgress.getColorForState(state, colorProgress.defaultColor)
colorProgressCurrent = ColorUtils.setAlphaComponent(colorProgressCurrent, 84 /* 255 * 0.33F */)
paint.color = colorProgressCurrent
}
override fun setGravity(gravity: Int) {
super.setGravity(gravity)
if (childCount != 0) {
applyGravity()
}
}
override fun setEnabled(enabled: Boolean) {
super.setEnabled(enabled)
children.forEach { it.isEnabled = enabled }
}
override fun onAnimationUpdate(animation: ValueAnimator) {
if (animation === progressAnimator) {
progress = animation.animatedValue as Float
invalidate()
}
}
fun setTitle(@StringRes titleResId: Int) {
textViewTitle.setTextAndVisible(titleResId)
}
fun setSubtitle(@StringRes titleResId: Int) {
textViewSubtitle.setTextAndVisible(titleResId)
}
fun setProgress(value: Float, animate: Boolean) {
val prevAnimator = progressAnimator
if (animate && context.isAnimationsEnabled) {
if (value == targetProgress) {
return
}
targetProgress = value
progressAnimator = ValueAnimator.ofFloat(progress, value).apply {
duration = context.getAnimationDuration(android.R.integer.config_mediumAnimTime)
interpolator = AccelerateDecelerateInterpolator()
addUpdateListener(this@ProgressButton)
}
progressAnimator?.start()
} else {
progressAnimator = null
progress = value
targetProgress = value
invalidate()
}
prevAnimator?.cancel()
}
private fun applyGravity() {
val value = (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) or Gravity.CENTER_VERTICAL
textViewTitle.gravity = value
textViewSubtitle.gravity = value
}
private class OutlineProvider : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f)
}
}
}

View File

@@ -77,6 +77,11 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
}
}
override fun setMinimumHeight(minHeight: Int) {
super.setMinimumHeight(minHeight)
getChildAt(0)?.minimumHeight = minHeight
}
fun show() {
if (currentState == STATE_UP) {
return

View File

@@ -13,6 +13,7 @@ import kotlin.math.absoluteValue
object KotatsuColors {
@ColorInt
@Deprecated("")
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
val colorHex = String.format("%06x", context.getThemeColor(resId))
val hue = getHue(colorHex)
@@ -21,6 +22,13 @@ object KotatsuColors {
return MaterialColors.harmonize(color, backgroundColor)
}
@ColorInt
fun segmentColorRandom(context: Context, seed: Any): Int {
val color = random(seed)
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
@ColorInt
fun random(seed: Any): Int {
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.util
import android.graphics.Paint
import androidx.core.graphics.PaintCompat
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import java.util.Locale
object LocaleUtils {
@@ -13,6 +13,8 @@ object LocaleUtils {
val code = when (val c = locale.country.ifNullOrEmpty { locale.toLanguageTag() }.uppercase(Locale.ENGLISH)) {
"EN" -> "GB"
"JA" -> "JP"
"VI" -> "VN"
"ZH" -> "CN"
else -> c
}
val emoji = countryCodeToEmojiFlag(code)

View File

@@ -6,6 +6,8 @@ import android.app.Activity
import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.LocaleConfig
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ComponentName
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
@@ -13,19 +15,16 @@ 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
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.content.pm.ResolveInfo
import android.database.SQLException
import android.graphics.Bitmap
import android.graphics.Color
import android.net.ConnectivityManager
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.view.ViewPropertyAnimator
import android.view.Window
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.activity.result.ActivityResultLauncher
@@ -37,7 +36,6 @@ import androidx.appcompat.app.AppCompatDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@@ -46,15 +44,8 @@ import androidx.lifecycle.coroutineScope
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import androidx.work.CoroutineWorker
import com.google.android.material.elevation.ElevationOverlayProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.IOException
@@ -67,8 +58,8 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.math.roundToLong
import com.google.android.material.R as materialR
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
@@ -101,25 +92,6 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
e.printStackTraceDebug()
}.isSuccess
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
emit(valueProducer())
observe().collect { upstreamKey ->
if (upstreamKey == key) {
emit(valueProducer())
}
}
}.distinctUntilChanged()
fun Lifecycle.postDelayed(delay: Long, runnable: Runnable) {
coroutineScope.launch {
delay(delay)
@@ -139,23 +111,6 @@ fun SyncResult.onError(error: Throwable) {
error.printStackTraceDebug()
}
fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float, alphaFactor: Float = 0.7f) {
navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
!context.getSystemBoolean("config_navBarNeedsScrim", true)
) {
Color.TRANSPARENT
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) {
val baseColor = context.getThemeColor(android.R.attr.navigationBarColor)
ColorUtils.setAlphaComponent(baseColor, (Color.alpha(baseColor) * alphaFactor).toInt())
} else {
// Set navbar scrim 70% of navigationBarColor
ElevationOverlayProvider(context).compositeOverlayIfNeeded(
context.getThemeColor(materialR.attr.colorSurfaceContainer, alphaFactor),
elevation,
)
}
}
val Context.animatorDurationScale: Float
get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f)
@@ -276,3 +231,26 @@ fun Context.restartApplication() {
startActivity(intent)
activity?.finishAndRemoveTask()
}
internal inline fun <R> PowerManager?.withPartialWakeLock(tag: String, body: (PowerManager.WakeLock?) -> R): R {
val wakeLock = newPartialWakeLock(tag)
return try {
wakeLock?.acquire(TimeUnit.HOURS.toMillis(1))
body(wakeLock)
} finally {
wakeLock?.release()
}
}
private fun PowerManager?.newPartialWakeLock(tag: String): PowerManager.WakeLock? {
return if (this != null && isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK)) {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag)
} else {
null
}
}
fun Context.copyToClipboard(label: String, content: String) {
val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
clipboardManager.setPrimaryClip(ClipData.newPlainText(label, content))
}

View File

@@ -11,6 +11,7 @@ import androidx.core.content.IntentCompat
import androidx.core.os.BundleCompat
import androidx.core.os.ParcelCompat
import androidx.lifecycle.SavedStateHandle
import org.koitharu.kotatsu.parsers.util.toArraySet
import java.io.Serializable
import java.util.EnumSet
@@ -84,6 +85,14 @@ fun <E : Enum<E>> Parcel.readEnumSet(cls: Class<E>): Set<E>? {
return set
}
fun Parcel.writeStringSet(set: Set<String>?) {
writeStringArray(set?.toTypedArray().orEmpty())
}
fun Parcel.readStringSet(): Set<String> {
return this.createStringArray()?.toArraySet().orEmpty()
}
fun <T> SavedStateHandle.require(key: String): T {
return checkNotNull(get(key)) {
"Value $key not found in SavedStateHandle or has a wrong type"

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.widget.ImageView
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.LifecycleOwner
import coil3.Extras
import coil3.ImageLoader
@@ -116,8 +116,8 @@ fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Bui
0.25f,
)
return placeholder(AnimatedPlaceholderDrawable(context))
.fallback(ColorDrawable(context.getThemeColor(materialR.attr.colorSurfaceContainer)))
.error(ColorDrawable(errorColor))
.fallback(context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable())
.error(errorColor.toDrawable())
}
private fun ImageView.ScaleType.toCoilScale(): Scale = if (this == ImageView.ScaleType.CENTER_CROP) {

View File

@@ -4,21 +4,8 @@ import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import androidx.collection.LongSet
import org.koitharu.kotatsu.BuildConfig
import java.util.Collections
import java.util.EnumSet
inline fun <T> MutableSet(size: Int, init: (index: Int) -> T): MutableSet<T> {
val set = ArraySet<T>(size)
repeat(size) { index -> set.add(init(index)) }
return set
}
inline fun <T> Set(size: Int, init: (index: Int) -> T): Set<T> = when (size) {
0 -> emptySet()
1 -> Collections.singleton(init(0))
else -> MutableSet(size, init)
}
fun <T> Collection<T>.asArrayList(): ArrayList<T> = if (this is ArrayList<*>) {
this as ArrayList<T>
} else {
@@ -76,15 +63,6 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
}
}
fun Collection<*>?.sizeOrZero() = this?.size ?: 0
@Suppress("UNCHECKED_CAST")
inline fun <T, reified R> Collection<T>.mapToArray(transform: (T) -> R): Array<R> {
val result = arrayOfNulls<R>(size)
forEachIndexed { index, t -> result[index] = transform(t) }
return result as Array<R>
}
fun LongSet.toLongArray(): LongArray {
val result = LongArray(size)
var i = 0

View File

@@ -1,11 +1,14 @@
package org.koitharu.kotatsu.core.util.ext
import android.annotation.TargetApi
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.annotation.RequiresApi
import androidx.core.net.toFile
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.removeSuffix
import java.io.File
@@ -31,6 +34,21 @@ fun Uri.resolveFile(context: Context): File? {
)
}
fun ContentResolver.getFileDisplayName(uri: Uri): String? = runCatching {
if (uri.isFileUri()) {
return@runCatching uri.toFile().name
}
query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
} else {
null
}
}
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
private fun getVolumePath(volumeId: String, context: Context): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getVolumePathForAndroid11AndAbove(volumeId, context)
@@ -63,7 +81,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St
it.printStackTraceDebug()
}.getOrNull()
@TargetApi(Build.VERSION_CODES.R)
@RequiresApi(Build.VERSION_CODES.R)
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
storageManager.storageVolumes.firstNotNullOfOrNull { volume ->

View File

@@ -35,6 +35,7 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
}
}
@Suppress("KotlinConstantConditions")
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
fun Resources.formatDurationShort(millis: Long): String? {
@@ -50,3 +51,5 @@ fun Resources.formatDurationShort(millis: Long): String? {
else -> getString(R.string.seconds_short, seconds)
}
}
fun LocalDate.toMillis(): Long = atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.util.ext
import android.os.SystemClock
import kotlinx.coroutines.channels.SendChannel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -135,3 +136,9 @@ suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x !
fun <T> Flow<Flow<T>>.flattenLatest() = flatMapLatest { it }
fun <T> SuspendLazy<T>.asFlow() = flow { emit(runCatchingCancellable { get() }) }
suspend fun <T> SendChannel<T>.sendNotNull(item: T?) {
if (item != null) {
send(item)
}
}

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.core.util.ext
import okhttp3.Cookie
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
@@ -27,12 +25,6 @@ fun Response.parseJsonOrNull(): JSONObject? {
}
}
val HttpUrl.isHttpOrHttps: Boolean
get() {
val s = scheme.lowercase()
return s == "https" || s == "http"
}
fun Response.ensureSuccess() = apply {
if (!isSuccessful || code == HttpURLConnection.HTTP_NO_CONTENT) {
closeQuietly()
@@ -40,26 +32,6 @@ fun Response.ensureSuccess() = apply {
}
}
fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
c.name(name)
c.value(value)
if (persistent) {
c.expiresAt(expiresAt)
}
if (hostOnly) {
c.hostOnlyDomain(domain)
} else {
c.domain(domain)
}
c.path(path)
if (secure) {
c.secure()
}
if (httpOnly) {
c.httpOnly()
}
}
fun String.sanitizeHeaderValue(): String {
return if (all(Char::isValidForHeaderValue)) {
this // fast path

View File

@@ -10,3 +10,29 @@ fun Insets.end(view: View): Int {
fun Insets.start(view: View): Int {
return if (view.isRtl) right else left
}
fun Insets.consume(
left: Boolean = false,
top: Boolean = false,
right: Boolean = false,
bottom: Boolean = false,
): Insets = Insets.of(
/* left = */ if (left) 0 else this.left,
/* top = */ if (top) 0 else this.top,
/* right = */ if (right) 0 else this.right,
/* bottom = */ if (bottom) 0 else this.bottom,
)
fun Insets.consumeRelative(
view: View,
start: Boolean = false,
top: Boolean = false,
end: Boolean = false,
bottom: Boolean = false,
): Insets = Insets.of(
/* left = */ if (if (view.isRtl) end else start) 0 else this.left,
/* top = */ if (top) 0 else this.top,
/* right = */ if (if (view.isRtl) start else end) 0 else this.right,
/* bottom = */ if (bottom) 0 else this.bottom,
)

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.Set
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale

View File

@@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.util.ext
import org.koitharu.kotatsu.core.io.NullOutputStream
import java.io.ObjectOutputStream
@Suppress("UNCHECKED_CAST")
fun <T> Class<T>.castOrNull(obj: Any?): T? {
if (obj == null || !isInstance(obj)) {
@@ -7,3 +10,9 @@ fun <T> Class<T>.castOrNull(obj: Any?): T? {
}
return obj as T
}
fun Any.isSerializable() = runCatching {
val oos = ObjectOutputStream(NullOutputStream())
oos.writeObject(this)
oos.flush()
}.isSuccess

View File

@@ -1,7 +1,16 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.SharedPreferences
import androidx.collection.ArraySet
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import org.json.JSONArray
fun ListPreference.setDefaultValueCompat(defaultValue: String) {
if (value == null) {
@@ -9,6 +18,10 @@ fun ListPreference.setDefaultValueCompat(defaultValue: String) {
}
}
fun MultiSelectListPreference.setDefaultValueCompat(defaultValue: Set<String>) {
setDefaultValue(defaultValue) // FIXME not working
}
fun <E : Enum<E>> SharedPreferences.getEnumValue(key: String, enumClass: Class<E>): E? {
val stringValue = getString(key, null) ?: return null
return enumClass.enumConstants?.find {
@@ -23,3 +36,44 @@ fun <E : Enum<E>> SharedPreferences.getEnumValue(key: String, defaultValue: E):
fun <E : Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
putString(key, value?.name)
}
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key)
}
registerOnSharedPreferenceChangeListener(listener)
awaitClose {
unregisterOnSharedPreferenceChangeListener(listener)
}
}
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
emit(valueProducer())
observe().collect { upstreamKey ->
if (upstreamKey == key) {
emit(valueProducer())
}
}
}.distinctUntilChanged()
fun SharedPreferences.Editor.putAll(values: Map<String, *>) {
values.forEach { e ->
when (val v = e.value) {
is Boolean -> putBoolean(e.key, v)
is Int -> putInt(e.key, v)
is Long -> putLong(e.key, v)
is Float -> putFloat(e.key, v)
is String -> putString(e.key, v)
is JSONArray -> putStringSet(e.key, v.toStringSet())
}
}
}
private fun JSONArray.toStringSet(): Set<String> {
val len = length()
val result = ArraySet<String>(len)
for (i in 0 until len) {
result.add(getString(i))
}
return result
}

View File

@@ -1,7 +1,2 @@
package org.koitharu.kotatsu.core.util.ext
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
fun longOf(a: Int, b: Int): Long {
return a.toLong() shl 32 or (b.toLong() and 0xffffffffL)
}

View File

@@ -1,6 +1,10 @@
package org.koitharu.kotatsu.core.util.ext
import android.util.DisplayMetrics
import androidx.core.view.doOnNextLayout
import androidx.core.view.isEmpty
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
@@ -52,7 +56,7 @@ fun <T> RecyclerView.ViewHolder.getItem(clazz: Class<T>): T? {
val RecyclerView.isScrolledToTop: Boolean
get() {
if (childCount == 0) {
if (isEmpty()) {
return true
}
val holder = findViewHolderForAdapterPosition(0)
@@ -72,3 +76,35 @@ val RecyclerView.LayoutManager?.isLayoutReversed
is StaggeredGridLayoutManager -> reverseLayout
else -> false
}
// https://medium.com/flat-pack-tech/quickly-scroll-to-the-top-of-a-recyclerview-da15b717f3c4
fun RecyclerView.smoothScrollToTop() {
val layoutManager = layoutManager as? LinearLayoutManager ?: return
if (!context.isAnimationsEnabled) {
layoutManager.scrollToPositionWithOffset(0, 0)
return
}
val smoothScroller = object : LinearSmoothScroller(context) {
init {
targetPosition = 0
}
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?) =
super.calculateSpeedPerPixel(displayMetrics) / DEFAULT_SPEED_FACTOR
}
val jumpBeforeScroll = layoutManager.findFirstVisibleItemPosition() > DEFAULT_JUMP_THRESHOLD
if (jumpBeforeScroll) {
layoutManager.scrollToPositionWithOffset(DEFAULT_JUMP_THRESHOLD, 0)
doOnNextLayout {
layoutManager.startSmoothScroll(smoothScroller)
}
} else {
layoutManager.startSmoothScroll(smoothScroller)
}
}
private const val DEFAULT_JUMP_THRESHOLD = 30
private const val DEFAULT_SPEED_FACTOR = 1f

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