Compare commits

..

230 Commits

Author SHA1 Message Date
tom5079
5f79c11303 tag suggestion 2025-03-09 12:09:33 -07:00
tom5079
a9cd3db27e prevent adding two empty tags 2025-03-08 15:34:25 -08:00
tom5079
47d96a6ba9 dependency update 2025-03-08 15:27:57 -08:00
tom5079
3ee5e683f4 fix 2025-03-08 15:16:58 -08:00
tom5079
71e8cebff4 wip 2024-11-18 19:29:51 -08:00
tom5079
fd3f1454c5 fixed tag overflow, detail page padding 2024-07-02 11:43:24 -07:00
tom5079
4028739e70 complete migration to compose 2024-07-01 11:20:53 -07:00
tom5079
067a263336 wip 2024-04-06 18:08:22 -07:00
tom5079
62948abf75 Basic Navigation 2024-03-27 00:21:16 -07:00
tom5079
e8ba5c4881 Fix oom 2024-03-24 22:56:24 -07:00
tom5079
e648b6dfee DetailedGalleryInfo card 2024-03-24 17:50:31 -07:00
tom5079
d1381b8700 DetailedGalleryInfo card 2024-03-24 11:54:54 -07:00
tom5079
f8df28311e Fix Certificate 2024-03-24 00:21:03 -07:00
tom5079
59afa04744 Search bar offset / GalleryInfo card 2024-03-23 23:46:33 -07:00
tom5079
7a5c3ae2ed Paging 2024-03-20 19:28:27 -07:00
tom5079
9e9a5998cd Search 2024-03-17 15:02:35 -07:00
tom5079
f34876ca93 add room dependency 2024-03-04 19:45:59 -08:00
tom5079
48752a323f Dependency update, tag suggestion 2024-03-04 19:22:22 -08:00
tom5079
ab3e6466d5 Autofocus, Inset handling 2024-03-03 22:18:26 -08:00
tom5079
419c8fc644 Tag editor 2024-03-03 12:30:23 -08:00
tom5079
69078ac42e Suggestion 2024-03-02 17:51:18 -08:00
tom5079
91b6baaf1c Better icon / Black query 2024-03-02 17:11:44 -08:00
tom5079
3f3774a0cd QueryEditor 2024-03-02 15:56:27 -08:00
tom5079
efc40ce458 queryeditor wip 2024-02-25 23:27:23 -08:00
tom5079
39b8bbc725 Search bar skeleton 2024-02-24 10:24:36 -08:00
tom5079
b0fedd78fb Navigation bars 2024-02-20 11:52:52 -08:00
tom5079
72b0fa78bb Migrated networking to ktor 2024-02-19 23:19:29 -08:00
tom5079
114158cf73 fix backup file selection, support hasha link 2024-01-15 19:01:30 -08:00
tom5079
6d108dd7ff fix backup, notification for android 33+ 2024-01-14 14:30:17 -08:00
tom5079
f36b7f1dbe Update README.md 2022-07-20 09:05:47 -07:00
tom5079
0a22ebd8e9 Merge remote-tracking branch 'origin/master' 2022-07-20 09:04:35 -07:00
tom5079
3682eeaf94 Fix image not retrying 2022-07-20 09:04:23 -07:00
tom5079
7df2ae4ba7 Update README.md 2022-07-19 20:31:42 -07:00
tom5079
c9519ec681 Fix image not retrying 2022-07-19 20:29:39 -07:00
tom5079
b146ed684d Fix app crashing when recovering metadata is corrupt 2022-05-31 08:06:48 +09:00
tom5079
d2787c36d7 Update README.md 2022-04-24 20:39:17 +09:00
tom5079
3ff663114a 5.3.7 2022-04-24 20:39:01 +09:00
tom5079
573e62f310 5.3.7 2022-04-24 20:36:56 +09:00
tom5079
f9af670b82 Update README.md 2022-04-24 20:21:41 +09:00
tom5079
bf461475c6 Merge remote-tracking branch 'origin/master' 2022-04-24 20:20:55 +09:00
tom5079
bdea6e0cc1 Use System.currentTimeMillis() instead of Instant 2022-04-24 20:20:45 +09:00
tom5079
57f0ec4e5d Update README.md 2022-04-24 18:44:10 +09:00
tom5079
d663092363 v5.3.5 2022-04-24 18:27:56 +09:00
tom5079
edf6188e36 Merge pull request #126 from tom5079/Pupil-116
Pupil 116 Favorite tag backup
2022-04-24 18:08:13 +09:00
tom5079
f3f3395e68 Merge pull request #128 from tom5079/Pupil-127
Pupil-127 Use gg.js directly
2022-04-24 18:08:04 +09:00
tom5079
ac9dc347e3 Pupil-127 Use gg.js directly 2022-04-22 16:39:13 +09:00
tom5079
8721d85946 Show ProgressDrawable when backup 2022-04-21 17:41:06 +09:00
tom5079
a0bd1a8738 Pupil-116 Add favorite tags backup 2022-04-21 17:26:58 +09:00
tom5079
35fdf3e3b0 Update PreferenceFragments to comply with updated library 2022-04-21 16:49:13 +09:00
tom5079
aced8293f1 Dependency Update 2022-04-21 16:45:13 +09:00
tom5079
3f516faad8 AGP update 2022-04-21 16:42:34 +09:00
tom5079
824f7b9602 Merge remote-tracking branch 'origin/master' 2022-04-16 06:30:31 +09:00
tom5079
95aeeaa16f updated .gitignore 2022-04-16 06:30:10 +09:00
tom5079
63f08f0230 Fixed Downloaded folder gets deleted when opened with no network 2022-03-25 16:48:38 +09:00
tom5079
3b241fe857 Delete watchdiff.yml 2022-02-02 06:38:50 +09:00
tom5079
75bc104f43 Update watchdiff.yml 2022-02-02 05:57:55 +09:00
tom5079
30afd56324 Update README.md 2022-02-01 19:11:55 +09:00
tom5079
5ee1bb11a0 Merge remote-tracking branch 'origin/master' 2022-02-01 19:11:25 +09:00
tom5079
c1de45abce use webp by default 2022-02-01 19:10:54 +09:00
tom5079
8805033c8d Update README.md 2022-02-01 17:47:46 +09:00
tom5079
0ed59bb8a9 Merge remote-tracking branch 'origin/master' 2022-02-01 17:46:44 +09:00
tom5079
8163f2fd28 Bug fix 2022-02-01 17:46:35 +09:00
tom5079
521a65c9d2 Update README.md 2022-02-01 17:37:50 +09:00
tom5079
eb98424668 Bug fix 2022-02-01 17:36:44 +09:00
tom5079
961c731743 Merge remote-tracking branch 'origin/master' 2022-02-01 11:45:54 +09:00
tom5079
5188769fb6 Fuck hitomi 2022-02-01 11:45:45 +09:00
tom5079
8f27d9e30f Update README.md 2022-02-01 11:45:35 +09:00
tom5079
b58566999e Update README.md 2022-02-01 11:40:23 +09:00
tom5079
117d6dcd2b Fuck hitomi 2022-02-01 11:39:26 +09:00
tom5079
2608796929 Remove dumb code 2022-01-31 13:28:31 +09:00
tom5079
792f5b5a7f Fixed downloading after revisiting cached manga 2022-01-31 13:27:23 +09:00
tom5079
a77b1db749 Merge remote-tracking branch 'origin/master' 2022-01-31 13:18:28 +09:00
tom5079
9d984d92af Fixed downloading after revisiting cached manga 2022-01-31 13:17:44 +09:00
tom5079
e303f25991 Update README.md 2022-01-31 11:10:02 +09:00
tom5079
85973d2305 Merge remote-tracking branch 'origin/master' 2022-01-31 11:05:05 +09:00
tom5079
13f8d7b747 Added download database recovery 2022-01-31 11:04:52 +09:00
tom5079
e198860edb Update README.md 2022-01-31 07:39:58 +09:00
tom5079
fc8355467b Fixed autoupdate for Android 5 and 6 2022-01-31 01:46:22 +09:00
tom5079
67abc15442 Merge remote-tracking branch 'origin/master' 2022-01-31 01:03:26 +09:00
tom5079
e94cddb86a Hitomi is stupid enough to block user agent for chrome... holy shit
Added self-test and reload
Reduced update ignoring time to 1d from 1w
2022-01-31 01:02:47 +09:00
tom5079
700f7a33a5 Update README.md 2022-01-25 05:00:04 +09:00
tom5079
41e952144d Merge remote-tracking branch 'origin/master' 2022-01-25 04:59:37 +09:00
tom5079
910ed65937 Improve startup speed 2022-01-25 04:59:25 +09:00
tom5079
e06701a2fb Update README.md 2022-01-25 04:30:24 +09:00
tom5079
62dce26c73 Merge remote-tracking branch 'origin/master' 2022-01-25 04:28:16 +09:00
tom5079
ac0cff62d4 Ask user to update WebView when es2020 is not supported 2022-01-25 04:28:04 +09:00
tom5079
655c060814 Drop Guava from dependency 2022-01-22 10:00:28 +09:00
tom5079
36d27895e7 Update README.md 2022-01-21 17:11:03 +09:00
tom5079
803481f74c Merge remote-tracking branch 'origin/master' 2022-01-21 17:08:57 +09:00
tom5079
b3ca1686e3 5.2.19
Improved error report
Lenient JSON decoding
2022-01-21 17:08:49 +09:00
tom5079
8f220eb0cb Update README.md 2022-01-20 19:42:41 +09:00
tom5079
51d5f42e8b Merge remote-tracking branch 'origin/master' 2022-01-20 19:41:22 +09:00
tom5079
8d8c5ace61 Fixed {} 2022-01-20 19:41:10 +09:00
tom5079
4bb6b8ccc9 Update README.md 2022-01-20 18:06:28 +09:00
tom5079
6bebd36e83 Merge remote-tracking branch 'origin/master' 2022-01-20 18:05:27 +09:00
tom5079
edc7053e50 Optimize Firebase 2022-01-20 18:05:18 +09:00
tom5079
55e6ef5f78 Update README.md 2022-01-20 16:06:26 +09:00
tom5079
9781d7a5dc Merge remote-tracking branch 'origin/master' 2022-01-20 16:05:31 +09:00
tom5079
b83cf87cd8 Updated proguard-rules.pro 2022-01-20 16:05:22 +09:00
tom5079
430864512d Update README.md 2022-01-20 15:58:17 +09:00
tom5079
16eeef1878 Merge remote-tracking branch 'origin/master' 2022-01-20 15:57:54 +09:00
tom5079
994d4b589b 5.2.15 Fixed thumbnail not loading 2022-01-20 15:57:43 +09:00
tom5079
43adba6f13 Update README.md 2022-01-18 18:26:14 +09:00
tom5079
e4fbd21731 Update README.md 2022-01-17 00:46:20 +09:00
tom5079
8be64745fc Fix thumbnail 2022-01-17 00:45:20 +09:00
tom5079
b66f376729 Merge remote-tracking branch 'origin/master' 2022-01-17 00:45:13 +09:00
tom5079
cc40416e1e Improved loading speed
Fixed images not loading
2022-01-17 00:35:15 +09:00
tom5079
5073352366 Update README.md 2022-01-16 11:29:36 +09:00
tom5079
9ae12a2c4c Merge remote-tracking branch 'origin/master' 2022-01-16 11:29:21 +09:00
tom5079
843b8412a9 5.2.13 Fixed thumbnails not loading 2022-01-16 11:29:06 +09:00
tom5079
4f67578371 Update README.md 2022-01-11 17:16:20 +09:00
tom5079
37f2227093 Merge remote-tracking branch 'origin/master' 2022-01-11 17:12:10 +09:00
tom5079
1833c0bde5 5.1.12 Improved suggestion loading speed / Fixed images not loading 2022-01-11 17:11:59 +09:00
tom5079
aa3aeca3f2 Update README.md 2022-01-11 12:25:43 +09:00
tom5079
152d4e248f Removed runBlocking from codebase 2022-01-11 12:21:43 +09:00
tom5079
7461c8d201 Merge remote-tracking branch 'origin/master' 2022-01-09 00:34:38 +09:00
tom5079
0902fdf981 Improved search speed 2022-01-09 00:34:29 +09:00
tom5079
0fd2cf4fd7 Removed logs 2022-01-08 18:33:48 +09:00
tom5079
679558106f Update README.md 2022-01-08 10:20:00 +09:00
tom5079
e498efc493 Fixed Download location dialog keep popping up 2022-01-08 10:13:20 +09:00
tom5079
74bbc71741 Fixed thumbnail not loading 2022-01-08 10:06:45 +09:00
tom5079
502b4890e3 5.2.8 Fix for loading not finishing 2022-01-07 18:47:07 +09:00
tom5079
dfb60461e4 Merge remote-tracking branch 'origin/master' 2022-01-05 20:20:08 +09:00
tom5079
bd6bc418e6 5.2.8-BETA01 potential fix for loading not finishing 2022-01-05 20:19:00 +09:00
tom5079
a284143ce1 Update README.md 2022-01-04 23:18:16 +09:00
tom5079
1f1c782772 5.2.7 Fix app crashing on Android 12 2022-01-04 23:15:34 +09:00
tom5079
5c0f5fe333 5.2.6 Dependency update & Fixes the bug (The problem was kotlinx.serialization!!!) 2022-01-04 22:50:16 +09:00
tom5079
748e023fde 5.2.5 Added logging to fix app crashing 2022-01-04 20:30:45 +09:00
tom5079
30104bacd2 Update README.md 2022-01-04 20:16:41 +09:00
tom5079
f33d1a1bfa 5.2.4 Added logging to fix app crashing 2022-01-04 20:16:04 +09:00
tom5079
3c08331441 5.2.3 Added logging to fix app crashing 2022-01-04 19:57:00 +09:00
tom5079
3eaa38247b 5.2.2 Fixed app crashing 2022-01-04 19:10:58 +09:00
tom5079
304ce643f9 Update README.md 2022-01-03 17:15:56 +09:00
tom5079
b4ad994f95 Create watchdiff.yml 2022-01-03 15:36:00 +09:00
tom5079
03c5cfa791 Fixed image not loading 2022-01-03 14:46:22 +09:00
tom5079
e8056072b8 Merge remote-tracking branch 'origin/master' 2022-01-03 14:31:50 +09:00
tom5079
d134639a5f Update README.md 2022-01-03 11:45:55 +09:00
tom5079
b4745d76b8 Update README.md 2022-01-03 09:18:38 +09:00
tom5079
c5fd674020 User-Agent 2022-01-03 00:00:37 +09:00
tom5079
9b821dd7cb Update README.md 2022-01-02 23:49:58 +09:00
tom5079
1b441f6aea Migrate to coroutine 2022-01-02 20:32:00 +09:00
tom5079
213902c854 Update README.md 2022-01-02 16:46:54 +09:00
tom5079
2054922586 Update README.md 2022-01-02 16:46:43 +09:00
tom5079
a17b7355f5 Merge remote-tracking branch 'origin/master' 2022-01-02 15:30:20 +09:00
tom5079
066a1e1f3a use WebView(!) as a js engine 2022-01-02 15:30:03 +09:00
tom5079
b10cbfbd63 Update README.md 2022-01-02 15:18:11 +09:00
tom5079
fcd72bb8bd Revert back to quickjs-android (quickjs stackoverflows) 2022-01-02 09:16:28 +09:00
tom5079
37cd99731c Fixed images not loading 2022-01-02 09:08:53 +09:00
tom5079
ed97773f24 Update README.md 2022-01-01 16:58:31 +09:00
tom5079
0424ba3e87 Merge remote-tracking branch 'origin/master' 2022-01-01 16:58:08 +09:00
tom5079
9539c4e7bf Fixed some images not loading 2022-01-01 16:57:55 +09:00
tom5079
248b378f01 Fixed some images not loading 2022-01-01 16:56:15 +09:00
tom5079
1c40575665 Fixed images not loading 2022-01-01 08:34:47 +09:00
tom5079
ac67c648be Update README.md 2022-01-01 02:07:12 +09:00
tom5079
42cc026acc Merge remote-tracking branch 'origin/master' 2021-12-31 15:37:37 +09:00
tom5079
23a74edfad Forgot to change version 2021-12-31 15:37:29 +09:00
tom5079
5da1804f17 Update README.md 2021-12-31 15:33:21 +09:00
tom5079
75f0c35017 Merge remote-tracking branch 'origin/master' 2021-12-31 15:29:05 +09:00
tom5079
0e6b02d260 Dependency update 2021-12-31 15:28:44 +09:00
tom5079
d5a0ce55f0 Update README.md 2021-12-31 14:47:28 +09:00
tom5079
09fc6fe8ef Merge remote-tracking branch 'origin/master' 2021-12-31 14:40:45 +09:00
tom5079
ff30be879a Dependency update 2021-12-31 14:40:31 +09:00
tom5079
309fe4d831 Update README.md 2021-12-29 20:28:52 +09:00
tom5079
dff0c817a7 Merge remote-tracking branch 'origin/master' 2021-12-29 20:28:38 +09:00
tom5079
04313981d4 5.1.22 Fixed gallery thumbnail not visible 2021-12-29 20:28:18 +09:00
tom5079
810cb4d13a Update README.md 2021-12-29 11:37:02 +09:00
tom5079
969e32e744 Dependency Update 2021-12-29 11:36:16 +09:00
tom5079
980909df9b Update README.md 2021-12-17 01:21:29 +09:00
tom5079
e6753088a4 User-Agent hack
Fixes unable to download some images
2021-12-13 10:46:57 +09:00
tom5079
cbdb6cb63a Update README.md 2021-12-12 20:08:20 +09:00
tom5079
3cdf1a899e Potential Image load fail fix 2021-12-12 20:06:23 +09:00
tom5079
c796be5de5 nvm 2021-11-24 16:05:11 +09:00
tom5079
db301cb0c3 Merge branch 'master' of github.com:tom5079/Pupil 2021-11-24 16:03:52 +09:00
tom5079
f00421ef23 state.jpg 2021-11-24 16:03:42 +09:00
tom5079
b324654967 Update README.md 2021-11-03 09:50:07 +09:00
tom5079
aa10ada3ee Dependency update 2021-11-03 09:42:12 +09:00
tom5079
10c97987fb Aligned with new hitomi.la image servers 2021-10-30 08:52:10 +09:00
tom5079
b532615bbd Aligned with new hitomi.la image servers 2021-10-29 16:55:12 +09:00
tom5079
3066f41af3 Update README.md 2021-10-28 08:33:35 +09:00
tom5079
0c401c6741 Merge remote-tracking branch 'origin/master' 2021-10-28 08:32:45 +09:00
tom5079
1a21d1c937 Aligned with new hitomi.la image servers 2021-10-28 08:30:59 +09:00
tom5079
525b49a5c9 Update README.md 2021-10-25 22:07:12 +09:00
tom5079
34c074bf7b Built APK 2021-10-25 09:33:25 +09:00
tom5079
b4dc961cdc Aligned with new hitomi.la image servers 2021-10-25 09:32:05 +09:00
tom5079
93374d2cfe Updated gradlew permission 2021-09-14 00:39:35 +09:00
tom5079
4009b10549 Align with hitomi image server 2021-08-11 22:22:35 +09:00
tom5079
db1864205f Merge remote-tracking branch 'origin/master' 2021-07-23 22:11:36 +09:00
tom5079
bf39ccabbd Fixed images not showing up 2021-07-23 22:11:28 +09:00
tom5079
0e8e7767ee Update README.md 2021-07-23 22:10:02 +09:00
tom5079
5b6c86e34f Fixed images not showing up 2021-07-23 22:07:18 +09:00
tom5079
6bbaca3686 Update README.md 2021-07-23 21:52:35 +09:00
tom5079
35eae90df1 Updated README.md 2021-07-23 21:51:38 +09:00
tom5079
488d43e076 Merge remote-tracking branch 'origin/master' 2021-07-23 21:50:25 +09:00
tom5079
7c5e93c171 Merge branch 'dev' 2021-07-23 21:49:18 +09:00
tom5079
a20ef783e1 Fixed thumbnail not visible 2021-07-23 21:36:41 +09:00
tom5079
8ae0dce0ed Update README.md 2021-07-10 12:36:42 +09:00
tom5079
44aea606b7 resigned apk 2021-07-09 18:22:53 +09:00
tom5079
a05dc8c661 Alignment with changed hitomi.la image server 2021-07-09 18:03:57 +09:00
tom5079
1f80e36017 Check update onResume() instead of onCreate() 2021-07-03 16:25:08 +09:00
tom5079
1efca40744 Dependency update & report savedset io exception 2021-07-03 16:22:52 +09:00
tom5079
86e3131afa Update README.md 2021-06-18 07:43:58 +09:00
tom5079
4910b4a4b0 Update README.md 2021-06-14 08:28:58 +09:00
tom5079
9c7320c0a0 Fix app crashing 2021-06-12 16:02:38 +09:00
tom5079
02c17c3b75 Potential fix for UpdateBroadcastReceiver 2021-06-12 15:47:23 +09:00
tom5079
49a47f4b4f 5.1.9-hotfix1 2021-06-08 20:05:16 +09:00
tom5079
68280f4a62 Update README.md 2021-06-08 20:02:03 +09:00
tom5079
0e3669b247 Update README.md 2021-06-08 14:03:02 +09:00
tom5079
4c9aa29d46 Fixed Downloaded folder showing up as not downloaded 2021-06-08 12:01:16 +09:00
tom5079
66fbf10f2d Update README.md 2021-06-08 09:19:49 +09:00
tom5079
15ad806eb8 Update README.md 2021-06-08 09:19:35 +09:00
tom5079
b7f80b9c82 5.1.9 2021-06-08 09:18:20 +09:00
tom5079
9b511d2f8f Fixed radio button acting up 2021-06-08 09:08:24 +09:00
tom5079
6ebce2deb3 Dependency update 2021-06-08 08:48:05 +09:00
tom5079
95dade13f4 Dependency update 2021-05-18 10:57:36 +09:00
tom5079
ba4449d003 Fixed Proxy dialog 2021-04-04 08:22:55 +09:00
tom5079
7632fe5e86 Dependency update 2021-02-18 10:03:51 +09:00
tom5079
2c56bcacee Dependency update & Apk build 2021-02-17 18:09:13 +09:00
tom5079
c8202db3c6 Merge remote-tracking branch 'origin/dev' into dev 2021-02-17 17:44:54 +09:00
tom5079
223d689b0c Merge pull request #115 from tom5079/issue-112
Pupil-112 [Feature request] Add the ability to manage the maximum parallel downloads
2021-02-17 17:44:32 +09:00
tom5079
4f0e7d9696 Dependency update 2021-02-17 17:44:14 +09:00
tom5079
f4ce911de9 Pupil-112 [Feature request] Add the ability to manage the maximum parallel downloads
Dependency update
2021-02-16 16:57:23 +09:00
tom5079
d0ad7effa0 Updated README.md 2021-02-13 18:14:18 +09:00
tom5079
a032beecbf Merge remote-tracking branch 'origin/master' 2021-02-13 18:13:52 +09:00
tom5079
46ec9e48d9 (Android 11) Show warning when the download folder is set to app internal space 2021-02-13 18:12:45 +09:00
tom5079
26bcef1cc0 Fixed Related gallery not showing up on GalleryDialog 2021-02-13 17:51:18 +09:00
tom5079
bfb2f44f8f Fixed favorite tag duplication 2021-02-13 17:43:44 +09:00
tom5079
28b19b6774 migration bug fixed 2021-01-13 09:49:49 +09:00
tom5079
8d72f4a3aa Update README.md 2021-01-12 12:55:50 +09:00
tom5079
9c62e0399d Update README.md 2021-01-12 12:43:09 +09:00
tom5079
65ea09854e Fixed Bug occuring on Android 11 2021-01-12 12:40:21 +09:00
111 changed files with 5693 additions and 3815 deletions

View File

@@ -2,7 +2,7 @@
*Pupil, Hitomi.la viewer for Android*
![](https://img.shields.io/github/downloads/tom5079/Pupil/total)
[![](https://img.shields.io/github/downloads/tom5079/Pupil/5.1.6-hotfix7/Pupil-v5.1.6-hotfix7.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.1.6-hotfix7/Pupil-v5.1.6-hotfix7.apk)
[![](https://img.shields.io/github/downloads/tom5079/Pupil/5.3.8-hotfix1/Pupil-v5.3.8-hotfix1.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.3.8-hotfix1/Pupil-v5.3.8-hotfix1.apk)
[![](https://discordapp.com/api/guilds/610452916612104194/embed.png?style=banner2)](https://discord.gg/Stj4b5v)
# Features
@@ -20,7 +20,7 @@ or Build app yourself
# Contribution
Any kind of contribution is appriciated. Feel free to leave PR!
Any kind of contribution is appreciated. Feel free to leave PR!
## Tag Translation
Head over to [tags branch](https://github.com/tom5079/Pupil/tree/tags)

View File

@@ -1,183 +1,95 @@
import com.google.protobuf.gradle.*
plugins {
id("com.android.application")
kotlin("android")
kotlin("kapt")
id("kotlin-parcelize")
id("kotlinx-serialization")
id("com.google.android.gms.oss-licenses-plugin")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("com.google.firebase.firebase-perf")
id("com.google.protobuf")
alias(libs.plugins.org.jetbrains.kotlin.android)
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.googleServices)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlinx.serialization)
alias(libs.plugins.crashlytics)
}
android {
compileSdk = 33
signingConfigs {
create("release") {
storeFile = File(System.getenv("SIGNING_STORE_FILE"))
storePassword = System.getenv("SIGNING_STORE_PASSWORD")
keyAlias = System.getenv("SIGNING_KEY_ALIAS")
keyPassword = System.getenv("SIGNING_KEY_PASSWORD")
}
}
namespace = "xyz.quaver.pupil"
defaultConfig {
applicationId = "xyz.quaver.pupil"
minSdk = 21
targetSdk = 33
versionCode = 600
versionName = VERSION
minSdk = libs.versions.android.minSdk.get().toInt()
compileSdk = libs.versions.android.compileSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 69
versionName = "6.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
getByName("debug") {
isMinifyEnabled = false
isShrinkResources = false
isDebuggable = true
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
extra.set("enableCrashlytics", false)
extra.set("alwaysUpdateBuildId", false)
ext.set("enableCrashlytics", false)
ext.set("alwaysUpdateBuildId", false)
}
getByName("release") {
isMinifyEnabled = false
applicationIdSuffix = ".beta"
isCrunchPngs = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = Versions.JETPACK_COMPOSE
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
jvmTarget = "17"
}
packagingOptions {
resources.excludes.addAll(
listOf(
"META-INF/AL2.0",
"META-INF/LGPL2.1"
)
)
}
namespace = "xyz.quaver.pupil"
}
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation(Kotlin.SERIALIZATION)
implementation(Kotlin.COROUTINE)
coreLibraryDesugaring(libs.android.desugaring)
implementation("androidx.activity:activity-compose:1.6.1")
implementation("androidx.navigation:navigation-compose:2.5.3")
implementation(libs.kotlinx.serialization)
implementation(libs.kotlinx.coroutines)
implementation(libs.kotlinx.datetime)
implementation(JetpackCompose.FOUNDATION)
implementation(JetpackCompose.UI)
implementation(JetpackCompose.UI_UTIL)
implementation(JetpackCompose.UI_TOOLING)
implementation(JetpackCompose.ANIMATION)
implementation(JetpackCompose.MATERIAL)
implementation(JetpackCompose.MATERIAL_ICONS)
implementation(JetpackCompose.RUNTIME_LIVEDATA)
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
// implementation(JetpackCompose.MARKDOWN)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.compose.foundation)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material.icons.extended)
implementation(Accompanist.INSETS)
implementation(Accompanist.INSETS_UI)
implementation(Accompanist.FLOW_LAYOUT)
implementation(Accompanist.SYSTEM_UI_CONTROLLER)
implementation(Accompanist.DRAWABLE_PAINTER)
implementation(Accompanist.APPCOMPAT_THEME)
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation("io.coil-kt:coil-compose:2.0.0-rc03")
implementation(libs.accompanist.adaptive)
implementation(KtorClient.CORE)
implementation(KtorClient.OKHTTP)
implementation(KtorClient.CONTENT_NEGOTIATION)
implementation(KtorClient.SERIALIZATION)
implementation(libs.coil)
implementation("androidx.room:room-runtime:2.4.3")
annotationProcessor("androidx.room:room-compiler:2.4.3")
kapt("androidx.room:room-compiler:2.4.3")
implementation("androidx.room:room-ktx:2.4.3")
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.perf)
implementation("androidx.datastore:datastore:1.0.0")
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation("org.kodein.di:kodein-di-framework-compose:7.11.0")
implementation(libs.ktor.client)
implementation(libs.ktor.client.okhttp)
implementation(platform("com.google.firebase:firebase-bom:29.0.3"))
implementation("com.google.firebase:firebase-analytics-ktx")
implementation("com.google.firebase:firebase-crashlytics-ktx")
implementation("com.google.firebase:firebase-perf-ktx")
implementation("com.google.protobuf:protobuf-javalite:3.19.1")
implementation("com.google.android.gms:play-services-oss-licenses:17.0.0")
implementation("org.jsoup:jsoup:1.14.3")
implementation("xyz.quaver.pupil.sources:core:0.0.1-alpha01-DEV29")
implementation("xyz.quaver:documentfilex:0.7.2")
implementation("xyz.quaver:subsampledimage:0.0.1-alpha22-SNAPSHOT")
implementation("org.kodein.log:kodein-log:0.12.0")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.8.1")
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-inline:4.4.0")
testImplementation(KtorClient.TEST)
testImplementation(Kotlin.COROUTINE_TEST)
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test:rules:1.4.0")
androidTestImplementation("androidx.test:runner:1.4.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
androidTestImplementation(KtorClient.TEST)
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.1.1")
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.19.1"
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
id("java") {
option("lite")
}
}
}
}
}
task<Exec>("clearAppCache") {
commandLine("adb", "shell", "pm", "clear", "xyz.quaver.pupil.debug")
implementation(libs.documentFileX)
}

View File

@@ -1 +0,0 @@
{"installed":{"client_id":"644157827114-rnbcmlqiaqgg295o45kavchnvi3dedbo.apps.googleusercontent.com","project_id":"pupil-1598439316578","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"]}}

Binary file not shown.

Binary file not shown.

View File

@@ -23,23 +23,13 @@
-dontobfuscate
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
-keepclassmembers class kotlinx.serialization.json.** {
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; } # <-- change package name to your app's
-keepclassmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; }
-keepclassmembers class xyz.quaver.** {
*** Companion;
}
-keepclasseswithmembers class xyz.quaver.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
-keep class xyz.quaver.pupil.** { *; }
-dontwarn org.slf4j.impl.StaticLoggerBinder

View File

@@ -4,15 +4,15 @@
"type": "APK",
"kind": "Directory"
},
"applicationId": "xyz.quaver.pupil.beta",
"applicationId": "xyz.quaver.pupil",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 600,
"versionName": "6.0.0-alpha02",
"versionCode": 69,
"versionName": "6.0.0",
"outputFile": "app-release.apk"
}
],

View File

@@ -22,18 +22,16 @@ package xyz.quaver.pupil
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.api.Http
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import xyz.quaver.pupil.hitomi.*
import java.util.*
import java.util.concurrent.TimeUnit
/**
* Instrumented test, which will execute on an Android device.
@@ -42,10 +40,144 @@ import org.junit.runner.RunWith
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
// @Before
// fun init() {
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
// }
@Before
fun init() {
clientBuilder = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.SECONDS)
.writeTimeout(0, TimeUnit.SECONDS)
.callTimeout(0, TimeUnit.SECONDS)
.connectTimeout(0, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("Referer", "https://hitomi.la/")
.build()
chain.proceed(request)
}
}
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
fun test_empty() {
print(
"".trim()
.replace(Regex("""^\?"""), "")
.lowercase(Locale.getDefault())
.split(Regex("\\s+"))
.map {
it.replace('_', ' ')
})
}
@Test
fun test_nozomi() {
val nozomi = getGalleryIDsFromNozomi(null, "index", "all")
Log.d("PUPILD", nozomi.size.toString())
}
@Test
fun test_search() {
val ids = getGalleryIDsForQuery("language:korean").reversed()
print(ids.size)
}
@Test
fun test_suggestions() {
val suggestions = getSuggestionsForQuery("language:g")
print(suggestions)
}
@Test
fun test_doSearch() {
val r = runBlocking {
doSearch("language:korean")
}
Log.d("PUPILD", r.take(10).toString())
}
// @Test
// fun test_getBlock() {
// val galleryBlock = getGalleryBlock(2097576)
//
// print(galleryBlock)
// }
//
// @Test
// fun test_getGallery() {
// val gallery = getGallery(2097751)
//
// print(gallery)
// }
@Test
fun test_getGalleryInfo() {
val info = getGalleryInfo(1469394)
print(info)
}
@Test
fun test_getReader() {
val reader = getGalleryInfo(2128654)
Log.d("PUPILD", reader.toString())
}
@Test
fun test_getImages() { runBlocking {
val galleryID = 2128654
val images = getGalleryInfo(galleryID).files.map {
imageUrlFromImage(galleryID, it,false)
}
Log.d("PUPILD", images.toString())
// images.forEachIndexed { index, image ->
// println("Testing $index/${images.size}: $image")
// val response = client.newCall(
// Request.Builder()
// .url(image)
// .header("Referer", "https://hitomi.la/")
// .build()
// ).execute()
//
// assertEquals(200, response.code())
//
// println("$index/${images.size} Passed")
// }
} }
// @Test
// fun test_urlFromUrlFromHash() {
// val url = urlFromUrlFromHash(1531795, GalleryFiles(
// 212, "719d46a7556be0d0021c5105878507129b5b3308b02cf67f18901b69dbb3b5ef", 1, "00.jpg", 300
// ), "webp")
//
// print(url)
// }
// @Test
// suspend fun test_doSearch_extreme() {
// val query = "language:korean -tag:sample -female:humiliation -female:diaper -female:strap-on -female:squirting -female:lizard_girl -female:voyeurism -type:artistcg -female:blood -female:ryona -male:blood -male:ryona -female:crotch_tattoo -male:urethra_insertion -female:living_clothes -male:tentacles -female:slave -female:gag -male:gag -female:wooden_horse -male:exhibitionism -male:miniguy -female:mind_break -male:mind_break -male:unbirth -tag:scanmark -tag:no_penetration -tag:nudity_only -female:enema -female:brain_fuck -female:navel_fuck -tag:novel -tag:mosaic_censorship -tag:webtoon -male:rape -female:rape -female:yuri -male:anal -female:anal -female:futanari -female:huge_breasts -female:big_areolae -male:torture -male:stuck_in_wall -female:stuck_in_wall -female:torture -female:birth -female:pregnant -female:drugs -female:bdsm -female:body_writing -female:cbt -male:dark_skin -male:insect -female:insect -male:vore -female:vore -female:vomit -female:urination -female:urethra_insertion -tag:mmf_threesome -female:sex_toys -female:double_penetration -female:eggs -female:prolapse -male:smell -male:bestiality -female:bestiality -female:big_ass -female:milf -female:mother -male:dilf -male:netorare -female:netorare -female:cosplaying -female:filming -female:armpit_sex -female:armpit_licking -female:tickling -female:lactation -male:skinsuit -female:skinsuit -male:bbm -female:prostitution -female:double_penetration -female:females_only -male:males_only -female:tentacles -female:tentacles -female:stomach_deformation -female:hairy_armpits -female:large_insertions -female:mind_control -male:orc -female:dark_skin -male:yandere -female:yandere -female:scat -female:toddlercon -female:bbw -female:hairy -male:cuntboy -male:lactation -male:drugs -female:body_modification -female:monoeye -female:chikan -female:long_tongue -female:harness -female:fisting -female:glory_hole -female:latex -male:latex -female:unbirth -female:giantess -female:sole_dickgirl -female:robot -female:doll_joints -female:machine -tag:artbook -male:cbt -female:farting -male:farting -male:midget -female:midget -female:exhibitionism -male:monster -female:big_nipples -female:big_clit -female:gyaru -female:piercing -female:necrophilia -female:snuff -female:smell -male:cheating -female:cheating -male:snuff -female:harem -male:harem"
// print(doSearch(query).size)
// }
// @Test
// suspend fun test_parse() {
// print(doSearch("-male:yaoi -female:yaoi -female:loli").size)
// }
// @Test
// fun test_subdomainFromUrl() {
// val galleryInfo = getGalleryInfo(1929109).files[2]
// print(urlFromUrlFromHash(1929109, galleryInfo, "webp", null, "a"))
// }
}

View File

@@ -1,15 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools" >
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application
android:name=".Pupil"
@@ -19,14 +24,15 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config"
android:windowSoftInputMode="adjustResize"
tools:replace="android:theme"
tools:ignore="UnusedAttribute">
tools:ignore="UnusedAttribute" >
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="face" />
<provider
android:authorities="${applicationId}.fileprovider"
android:authorities="${applicationId}.provider"
android:name="androidx.core.content.FileProvider"
android:exported="false"
android:grantUriPermissions="true">
@@ -34,23 +40,25 @@
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service android:name=".services.ImageCacheService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" />
<activity
android:name=".ui.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:theme="@style/NoActionBarAppTheme"
android:exported="true"
android:windowSoftInputMode="adjustResize">
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
</intent-filter>
</activity>
</application>

View File

@@ -23,42 +23,19 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException
import com.google.android.gms.security.ProviderInstaller
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import org.kodein.di.*
import org.kodein.di.android.x.androidXModule
import xyz.quaver.pupil.sources.core.NetworkCache
import xyz.quaver.pupil.sources.core.settingsDataStore
import xyz.quaver.pupil.util.PupilHttpClient
class Pupil : Application(), DIAware {
override val di: DI by DI.lazy {
import(androidXModule(this@Pupil))
bind { singleton { NetworkCache(this@Pupil) } }
bindSingleton { settingsDataStore }
bind { singleton { PupilHttpClient(OkHttp.create()) } }
}
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.hilt.android.HiltAndroidApp
import xyz.quaver.io.FileX
import java.util.UUID
@HiltAndroidApp
class Pupil : Application() {
override fun onCreate() {
super.onCreate()
try {
ProviderInstaller.installIfNeeded(this)
} catch (e: GooglePlayServicesRepairableException) {
e.printStackTrace()
} catch (e: GooglePlayServicesNotAvailableException) {
e.printStackTrace()
}
FirebaseApp.initializeApp(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -83,6 +60,15 @@ class Pupil : Application(), DIAware {
enableVibration(true)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.channel_update_description)
enableLights(false)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
}
super.onCreate()
}
}

View File

@@ -0,0 +1,24 @@
package xyz.quaver.pupil.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import xyz.quaver.pupil.networking.FileImageCache
import xyz.quaver.pupil.networking.ImageCache
import java.io.File
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SingletonModule {
@Singleton
@Provides
fun provideImageCache(
@ApplicationContext context: Context
): ImageCache {
return FileImageCache(File(context.cacheDir, "image_cache"))
}
}

View File

@@ -0,0 +1,110 @@
package xyz.quaver.pupil.networking
import android.os.BaseBundle
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
interface TagLike {
fun toTag(): SearchQuery.Tag
}
@Serializable
data class Artist(val artist: String): TagLike {
override fun toTag() = SearchQuery.Tag("artist", artist)
}
@Serializable
data class Group(val group: String): TagLike {
override fun toTag() = SearchQuery.Tag("group", group)
}
@Serializable
data class Series(@SerialName("parody") val series: String): TagLike {
override fun toTag() = SearchQuery.Tag("series", series)
}
@Serializable
data class Character(val character: String): TagLike {
override fun toTag() = SearchQuery.Tag("character", character)
}
@Serializable
data class GalleryTag(
val tag: String,
val female: String? = null,
val male: String? = null
): TagLike {
override fun toTag() = SearchQuery.Tag(
if (female.isNullOrEmpty() && male.isNullOrEmpty()) {
"tag"
} else if (male.isNullOrEmpty()) {
"female"
} else {
"male"
},
tag
)
}
@Serializable
data class Language(
@SerialName("galleryid") val galleryID: String,
val name: String
)
@Serializable
data class GalleryFile(
@SerialName("haswebp") val hasWebP: Int = 0,
@SerialName("hasavif") val hasAVIF: Int = 0,
@SerialName("hasjxl") val hasJXL: Int = 0,
val height: Int,
val width: Int,
val hash: String,
val name: String,
) {
fun writeToBundle(bundle: BaseBundle) {
bundle.putInt("hasWebP", hasWebP)
bundle.putInt("hasAVIF", hasAVIF)
bundle.putInt("hasJXL", hasJXL)
bundle.putInt("height", height)
bundle.putInt("width", width)
bundle.putString("hash", hash)
bundle.putString("name", name)
}
companion object {
fun fromBundle(bundle: BaseBundle) = GalleryFile(
bundle.getInt("hasWebP"),
bundle.getInt("hasAVIF"),
bundle.getInt("hasJXL"),
bundle.getInt("height"),
bundle.getInt("width"),
bundle.getString("hash")!!,
bundle.getString("name")!!
)
}
}
@Serializable
data class GalleryInfo(
val id: String,
val title: String,
@SerialName("japanese_title") val japaneseTitle: String? = null,
val language: String? = null,
val type: String,
val date: String,
val artists: List<Artist>? = null,
val groups: List<Group>? = null,
@SerialName("parodys") val series: List<Series>? = null,
val tags: List<GalleryTag>? = null,
val related: List<Int> = emptyList(),
val languages: List<Language> = emptyList(),
val characters: List<Character>? = null,
@SerialName("scene_indexes") val sceneIndices: List<Int>? = emptyList(),
val files: List<GalleryFile> = emptyList()
)
@JvmName("joinToCapitalizedStringArtist")
fun List<Artist>.joinToCapitalizedString() = joinToString { it.artist.replaceFirstChar(Char::titlecase) }
@JvmName("joinToCapitalizedStringGroup")
fun List<Group>.joinToCapitalizedString() = joinToString { it.group.replaceFirstChar(Char::titlecase) }

View File

@@ -0,0 +1,36 @@
package xyz.quaver.pupil.networking
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
class GallerySearchSource(val query: SearchQuery?) {
private var searchResult: List<Int>? = null
private var job: Job? = null
suspend fun load(range: IntRange): Result<Pair<List<GalleryInfo>, Int>> = runCatching {
val searchResult = searchResult ?: (
HitomiHttpClient
.search(query)
.getOrThrow()
.toList()
.also { searchResult = it }
)
val galleryResults = coroutineScope {
searchResult.slice(range).map { galleryID ->
async {
HitomiHttpClient.getGalleryInfo(galleryID)
}
}
}
val galleries = galleryResults.map { result ->
result.await().getOrThrow()
}
Pair(galleries, searchResult.size)
}
fun cancel() = job?.cancel()
}

View File

@@ -0,0 +1,402 @@
package xyz.quaver.pupil.networking
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.utils.io.ByteReadChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock.System.now
import kotlinx.datetime.Instant
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.nio.ByteBuffer
import java.nio.IntBuffer
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
const val domain = "ltn.hitomi.la"
const val nozomiExtension = ".nozomi"
const val compressedNozomiPrefix = "n"
const val B = 16
const val indexDir = "tagindex"
const val maxNodeSize = 464
const val galleriesIndexDir = "galleriesindex"
const val tagIndexDomain = "tagindex.hitomi.la"
const val separator = "-"
const val extension = ".html"
data class Suggestion(
val tag: SearchQuery.Tag,
val count: Int,
)
fun IntBuffer.toSet(): Set<Int> {
val result = LinkedHashSet<Int>()
while (this.hasRemaining()) {
result.add(this.get())
}
return result
}
private val json = Json {
isLenient = true
ignoreUnknownKeys = true
}
class ImagePathResolver(ggjs: String) {
private val defaultPrefix: Int = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt()
private val prefixMap: Map<Int, Int> = buildMap {
val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt()
Regex("case (\\d+):").findAll(ggjs).forEach {
val case = it.groupValues[1].toInt()
put(case, o)
}
}
private val imageBaseDir: String = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1]
fun decodeSubdomain(hash: String, thumbnail: Boolean): String {
val key = (hash.last() + hash.dropLast(1).takeLast(2)).toInt(16)
val base = if (thumbnail) "tn" else "a"
return "${'a' + (prefixMap[key] ?: defaultPrefix)}$base"
}
fun decodeImagePath(hash: String, thumbnail: Boolean): String {
val key = hash.last() to hash.dropLast(1).takeLast(2)
return if (thumbnail) {
"${key.first}/${key.second}/$hash"
} else {
"$imageBaseDir/${(key.first + key.second).toInt(16)}/$hash"
}
}
}
class ExpirableEntry<T>(
private val expiryDuration: Duration,
private val action: suspend () -> T,
) {
private var value: T? = null
private var expiresAt: Instant = now()
private val mutex = Mutex()
suspend fun getValue(): T = mutex.withLock {
value?.let { if (expiresAt > now()) value else null } ?: action().also {
expiresAt = now() + expiryDuration
value = it
}
}
}
object HitomiHttpClient {
private val httpClient = HttpClient(OkHttp) {
engine {
config {
sslSocketFactory(SSLSettings.sslContext!!.socketFactory, SSLSettings.trustManager!!)
}
}
}
private var imagePathResolver = ExpirableEntry(1.minutes) {
ImagePathResolver(httpClient.get("https://ltn.hitomi.la/gg.js").bodyAsText())
}
private val tagIndexVersion = ExpirableEntry(1.minutes) { getIndexVersion("tagindex") }
private val galleriesIndexVersion =
ExpirableEntry(1.minutes) { getIndexVersion("galleriesindex") }
private suspend fun getIndexVersion(name: String): String = withContext(Dispatchers.IO) {
httpClient.get("https://$domain/$name/version?_=${System.currentTimeMillis()}").bodyAsText()
}
private suspend fun getURLAtRange(url: String, range: LongRange): ByteBuffer {
val response: HttpResponse = withContext(Dispatchers.IO) {
httpClient.get(url) {
header("Range", "bytes=${range.first}-${range.last}")
}
}
val result: ByteArray = response.body()
return ByteBuffer.wrap(result)
}
private suspend fun getNodeAtAddress(field: String, address: Long): Node {
val url = when (field) {
"galleries" -> "https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.index"
"languages" -> "https://$domain/$galleriesIndexDir/languages.${galleriesIndexVersion.getValue()}.index"
"nozomiurl" -> "https://$domain/$galleriesIndexDir/nozomiurl.${galleriesIndexVersion.getValue()}.index"
else -> "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.index"
}
return Node.decodeNode(
getURLAtRange(url, address..<address + maxNodeSize)
)
}
private suspend fun bSearch(
field: String,
key: Node.Key,
node: Node,
): Node.Data? {
if (node.keys.isEmpty()) {
return null
}
val (matched, index) = node.locateKey(key)
if (matched) {
return node.datas[index]
} else if (node.isLeaf) {
return null
}
val nextNode = getNodeAtAddress(field, node.subNodeAddresses[index])
return bSearch(field, key, nextNode)
}
private suspend fun getGalleryIDsFromData(offset: Long, length: Int): IntBuffer {
val url =
"https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.data"
if (length > 100000000 || length <= 0) {
error("length $length is too long")
}
return getURLAtRange(url, offset until (offset + length)).asIntBuffer()
}
private fun encodeSearchQueryForUrl(s: Char) =
when (s) {
' ' -> "_"
'/' -> "slash"
'.' -> "dot"
else -> s.toString()
}
private fun sanitize(s: String) = s.replace(Regex("[/#]"), "")
private suspend fun getGalleryIDsFromNozomi(
area: String?,
tag: String,
language: String,
): IntBuffer {
val nozomiAddress = if (area == null) {
"https://$domain/$compressedNozomiPrefix/$tag-$language$nozomiExtension"
} else {
"https://$domain/$compressedNozomiPrefix/$area/$tag-$language$nozomiExtension"
}
val response: HttpResponse = withContext(Dispatchers.IO) {
httpClient.get(nozomiAddress)
}
val result: ByteArray = response.body()
return ByteBuffer.wrap(result).asIntBuffer()
}
private suspend fun getGalleryIDsForQuery(
query: SearchQuery.Tag,
language: String = "all",
): IntBuffer = when (query.namespace) {
"female", "male" -> getGalleryIDsFromNozomi("tag", query.toString(), language)
"language" -> getGalleryIDsFromNozomi(null, "index", query.tag)
null -> {
val key = Node.Key(query.tag)
val node = getNodeAtAddress("galleries", 0)
val data = bSearch("galleries", key, node)
if (data != null) getGalleryIDsFromData(
data.offset,
data.length
) else IntBuffer.allocate(0)
}
else -> getGalleryIDsFromNozomi(query.namespace, query.tag, language)
}
suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> =
runCatching {
val field = query.namespace ?: "global"
val chars = query.tag.map(::encodeSearchQueryForUrl)
val suggestions = json.parseToJsonElement(
withContext(Dispatchers.IO) {
httpClient.get(
"https://$tagIndexDomain/$field${
if (chars.isNotEmpty()) "/${
chars.joinToString(
"/"
)
}" else ""
}.json"
).bodyAsText()
}
)
buildList {
suggestions.jsonArray.forEach { suggestionRaw ->
val suggestion = suggestionRaw.jsonArray
if (suggestion.size < 3) {
return@forEach
}
val namespace = suggestion[2].jsonPrimitive.contentOrNull ?: ""
val tag =
sanitize(suggestion[0].jsonPrimitive.contentOrNull ?: return@forEach)
add(
Suggestion(
SearchQuery.Tag(
namespace,
tag
),
suggestion[1].jsonPrimitive.contentOrNull?.toIntOrNull() ?: 0,
)
)
}
}
}
suspend fun getGalleryInfo(galleryID: Int) = runCatching {
withContext(Dispatchers.IO) {
json.decodeFromString<GalleryInfo>(
httpClient.get("https://$domain/galleries/$galleryID.js").bodyAsText()
.replace("var galleryinfo = ", "")
)
}
}
suspend fun search(query: SearchQuery?): Result<Set<Int>> = runCatching {
when (query) {
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()
is SearchQuery.Not -> coroutineScope {
val allGalleries = async {
getGalleryIDsFromNozomi(null, "index", "all")
}
val queriedGalleries = search(query.query).getOrThrow()
val result = LinkedHashSet<Int>()
with(allGalleries.await()) {
while (this.hasRemaining()) {
val gallery = this.get()
if (gallery in queriedGalleries) {
result.add(gallery)
}
}
}
result
}
is SearchQuery.And -> coroutineScope {
val queries = query.queries.map { query ->
async {
search(query).getOrThrow()
}
}
val result = queries.first().await().toMutableSet()
queries.drop(1).forEach {
val queryResult = it.await()
result.retainAll(queryResult)
}
result
}
is SearchQuery.Or -> coroutineScope {
val queries = query.queries.map { query ->
async {
search(query).getOrThrow()
}
}
val result = LinkedHashSet<Int>()
queries.forEach {
val queryResult = it.await()
result.addAll(queryResult)
}
result
}
null -> getGalleryIDsFromNozomi(null, "index", "all").toSet()
}
}
suspend fun getImageURL(galleryFile: GalleryFile, thumbnail: Boolean = false): List<String> =
buildList {
val imagePathResolver = imagePathResolver.getValue()
listOf("webp", "avif", "jxl").forEach { type ->
val available = when {
thumbnail && type != "jxl" -> true
type == "webp" -> galleryFile.hasWebP != 0
type == "avif" -> galleryFile.hasAVIF != 0
!thumbnail && type == "jxl" -> galleryFile.hasJXL != 0
else -> false
}
if (!available) return@forEach
val url = buildString {
append("https://")
append(imagePathResolver.decodeSubdomain(galleryFile.hash, thumbnail))
append(".hitomi.la/")
append(type)
if (thumbnail) append("bigtn")
append('/')
append(imagePathResolver.decodeImagePath(galleryFile.hash, thumbnail))
append('.')
append(type)
}
add(url)
}
}
suspend fun loadImage(
galleryFile: GalleryFile,
thumbnail: Boolean = false,
acceptImage: (String) -> Boolean = { true },
onDownload: (bytesSentTotal: Long, contentLength: Long?) -> Unit = { _, _ -> },
): Result<Pair<ByteReadChannel, String>> {
return runCatching {
withContext(Dispatchers.IO) {
val url = getImageURL(galleryFile, thumbnail).firstOrNull(acceptImage)
?: error("No available image")
val channel: ByteReadChannel = httpClient.get(url) { onDownload(onDownload) }.body()
Pair(channel, url)
}
}
}
}

View File

@@ -0,0 +1,135 @@
package xyz.quaver.pupil.networking
import com.google.firebase.crashlytics.FirebaseCrashlytics
import io.ktor.util.cio.writeChannel
import io.ktor.utils.io.copyAndClose
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
sealed class ImageLoadProgress {
data object NotStarted : ImageLoadProgress()
data class Progress(val bytesSent: Long, val contentLength: Long?) : ImageLoadProgress()
data class Finished(val file: File) : ImageLoadProgress()
data class Error(val exception: Throwable) : ImageLoadProgress()
}
interface ImageCache {
suspend fun load(
galleryFile: GalleryFile,
forceDownload: Boolean = false,
): StateFlow<ImageLoadProgress>
suspend fun free(vararg files: GalleryFile)
suspend fun clear()
}
class FileImageCache(
private val cacheDir: File,
private val cacheLimit: Long = 128 * 1024 * 1024, // 128MB
) : ImageCache {
private val mutex = Mutex()
private val requests = mutableMapOf<String, Pair<Job, StateFlow<ImageLoadProgress>>>()
private val activeFiles = mutableMapOf<String, File>()
private suspend fun cleanup() = withContext(Dispatchers.IO) {
mutex.withLock {
val size = cacheDir.listFiles()?.sumOf { it.length() } ?: 0
if (size > cacheLimit) {
cacheDir.listFiles { file ->
file.nameWithoutExtension !in activeFiles
}?.forEach { file ->
file.delete()
}
}
}
}
override suspend fun free(vararg files: GalleryFile) = withContext(Dispatchers.IO) {
mutex.withLock {
files.forEach { file ->
val hash = file.hash
requests[hash]?.let { (job, _) ->
job.cancel()
}
requests.remove(hash)
activeFiles.remove(hash)
}
}
}
override suspend fun clear(): Unit = withContext(Dispatchers.IO) {
mutex.withLock {
requests.forEach { _, (job, _) -> job.cancel() }
activeFiles.clear()
cacheDir.deleteRecursively()
}
}
override suspend fun load(
galleryFile: GalleryFile,
forceDownload: Boolean,
): StateFlow<ImageLoadProgress> {
val hash = galleryFile.hash
mutex.withLock {
val file = activeFiles[hash]
if (!forceDownload && file != null) {
return MutableStateFlow(ImageLoadProgress.Finished(file))
}
}
cleanup()
mutex.withLock {
requests[hash]?.first?.cancelAndJoin()
activeFiles[hash]?.delete()
val flow = MutableStateFlow<ImageLoadProgress>(ImageLoadProgress.NotStarted)
val job = coroutineScope {
launch {
runCatching {
val (channel, url) = HitomiHttpClient.loadImage(galleryFile) { sent, total ->
flow.value = ImageLoadProgress.Progress(sent, total)
}.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
flow.value = ImageLoadProgress.Error(it)
}.getOrThrow()
val file = File(cacheDir, "$hash.${url.substringAfterLast('.')}")
mutex.withLock {
activeFiles.put(hash, file)
}
channel.copyAndClose(file.writeChannel())
file
}.onSuccess { file ->
flow.value = ImageLoadProgress.Finished(file)
}.onFailure {
activeFiles.remove(hash)
FirebaseCrashlytics.getInstance().recordException(it)
flow.value = ImageLoadProgress.Error(it)
}
}
}
requests[hash] = job to flow
return flow
}
}
}

View File

@@ -0,0 +1,107 @@
@file:OptIn(ExperimentalUnsignedTypes::class)
package xyz.quaver.pupil.networking
import java.nio.ByteBuffer
import java.security.MessageDigest
import kotlin.math.min
private fun sha256(data: ByteArray): ByteArray =
MessageDigest.getInstance("SHA-256").digest(data)
private fun hashTerm(term: String): UByteArray =
sha256(term.toByteArray()).sliceArray(0..<4).toUByteArray()
data class Node(
val keys: List<Key>,
val datas: List<Data>,
val subNodeAddresses: List<Long>
) {
data class Key(
private val key: UByteArray
): Comparable<Key> {
constructor(term: String): this(hashTerm(term))
override fun compareTo(other: Key): Int {
val minSize = min(this.key.size, other.key.size)
for (i in 0..<minSize) {
if (this.key[i] < other.key[i]) {
return -1
} else if(this.key[i] > other.key[i]) {
return 1
}
}
return 0
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Key
return key.contentEquals(other.key)
}
override fun hashCode(): Int {
return key.contentHashCode()
}
}
data class Data(
val offset: Long,
val length: Int
)
companion object {
fun decodeNode(buffer: ByteBuffer): Node {
val numberOfKeys = buffer.int
val keys = mutableListOf<Node.Key>()
for (i in 0..<numberOfKeys) {
val keySize = buffer.int
val key = ByteArray(keySize)
buffer.get(key)
keys.add(Node.Key(key.toUByteArray()))
}
val numberOfDatas = buffer.int
val datas = mutableListOf<Data>()
for (i in 0..<numberOfDatas) {
val offset = buffer.long
val length = buffer.int
datas.add(Data(offset, length))
}
val numberOfSubNodeAddresses = B+1
val subNodeAddresses = mutableListOf<Long>()
for (i in 0..<numberOfSubNodeAddresses) {
val subNodeAddress = buffer.long
subNodeAddresses.add(subNodeAddress)
}
return Node(keys, datas, subNodeAddresses)
}
}
val isLeaf: Boolean = subNodeAddresses.all { it == 0L }
fun locateKey(target: Key): Pair<Boolean, Int> {
val index = keys.indexOfFirst { key -> target <= key }
if (index == -1) {
return Pair(false, keys.size)
}
return Pair(keys[index] == target, index)
}
}

View File

@@ -0,0 +1,80 @@
package xyz.quaver.pupil.networking
import android.content.res.Resources
import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
const val ISRG_ROOT_X1 = """-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----"""
object SSLSettings {
val keyStore: KeyStore by lazy {
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
val certificateFactory = CertificateFactory.getInstance("X.509")
val certificate = certificateFactory.generateCertificate(ISRG_ROOT_X1.byteInputStream())
setCertificateEntry("isrgrootx1", certificate)
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
init(null as KeyStore?)
trustManagers.filterIsInstance<X509TrustManager>().forEach { trustManager ->
trustManager.acceptedIssuers.forEach { acceptedIssuer ->
setCertificateEntry(acceptedIssuer.subjectDN.name, acceptedIssuer)
}
}
}
}
}
val trustManagerFactory: TrustManagerFactory? by lazy {
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
init(keyStore)
}
}
val sslContext: SSLContext? by lazy {
SSLContext.getInstance("TLS").apply {
init(null, trustManagerFactory?.trustManagers, null)
}
}
val trustManager: X509TrustManager? by lazy {
trustManagerFactory?.trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
}
}

View File

@@ -0,0 +1,90 @@
package xyz.quaver.pupil.networking
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
val validNamespace = listOf(
"female",
"male",
"artist",
"group",
"character",
"series",
"type",
"language",
"tag"
)
class SearchQueryPreviewParameterProvider: PreviewParameterProvider<SearchQuery> {
override val values = sequenceOf(
SearchQuery.And(listOf(
SearchQuery.Or(listOf(
SearchQuery.And(listOf(
SearchQuery.Tag("language", "thisisareallylongtagyoucantevenseetheendofthis"),
SearchQuery.Tag("language", "korean"),
SearchQuery.Tag("female", "unusual pupil"),
SearchQuery.Tag("female", "collar")
)),
SearchQuery.And(listOf(
SearchQuery.Tag("language", "japanese"),
SearchQuery.Tag("female", "unusual pupil"),
SearchQuery.Tag("female", "collar")
))
)),
SearchQuery.Not(
SearchQuery.And(listOf(
SearchQuery.Tag("male", "yaoi"),
SearchQuery.Tag("group", "zenmai kourogi")
))
)
))
)
}
sealed interface SearchQuery {
data class Tag(
val namespace: String? = null,
val tag: String
): SearchQuery, TagLike {
companion object {
fun parseTag(tag: String): Tag {
val splitTag = tag.split(':', limit = 1)
return if (splitTag.size == 1) {
Tag(null, tag)
} else {
Tag(splitTag[0], splitTag[1])
}
}
}
override fun toString() = if (namespace == null) tag else "$namespace:$tag"
override fun toTag() = this
}
data class And(
val queries: List<SearchQuery>
): SearchQuery {
init {
if (queries.isEmpty()) {
error("queries cannot be empty")
}
}
}
data class Or(
val queries: List<SearchQuery>
): SearchQuery {
init {
if (queries.isEmpty()) {
error("queries cannot be empty")
}
}
}
data class Not(
val query: SearchQuery
): SearchQuery
}

View File

@@ -0,0 +1,57 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.services
import android.annotation.SuppressLint
import android.app.job.JobParameters
import android.app.job.JobService
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.hilt.android.AndroidEntryPoint
import io.ktor.util.cio.writeChannel
import io.ktor.util.collections.ConcurrentMap
import io.ktor.util.collections.ConcurrentSet
import io.ktor.utils.io.copyAndClose
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import xyz.quaver.pupil.Pupil
import xyz.quaver.pupil.networking.GalleryFile
import xyz.quaver.pupil.networking.HitomiHttpClient
import java.io.File
@SuppressLint("SpecifyJobSchedulerIdRange")
@AndroidEntryPoint
class ImageCacheService : JobService() {
override fun onStartJob(params: JobParameters?): Boolean {
return false
}
override fun onStopJob(params: JobParameters?): Boolean {
return false
}
}

View File

@@ -1,108 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources
import android.app.Application
import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import dalvik.system.PathClassLoader
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import xyz.quaver.pupil.sources.core.Source
@Composable
fun rememberLocalSourceList(context: Context = LocalContext.current): State<List<SourceEntry>> = produceState(emptyList()) {
while (true) {
value = loadSourceList(context)
delay(1000)
}
}
suspend fun loadSource(context: Context, sourceEntry: SourceEntry): Source = coroutineScope {
sourceCacheMutex.withLock {
sourceCache[sourceEntry.packageName] ?: run {
val classLoader = PathClassLoader(sourceEntry.sourceDir, null, context.classLoader)
Class.forName("${sourceEntry.packagePath}${sourceEntry.sourcePath}", false, classLoader)
.getConstructor(Application::class.java)
.newInstance(context.applicationContext) as Source
}.also { sourceCache[sourceEntry.packageName] = it }
}
}
private const val SOURCES_FEATURE = "pupil.sources"
private const val SOURCES_PACKAGE_PREFIX = "xyz.quaver.pupil.sources"
private const val SOURCES_PATH = "pupil.sources.path"
private val PackageInfo.isSourceFeatureEnabled
get() = this.reqFeatures.orEmpty().any { it.name == SOURCES_FEATURE }
private fun loadSource(context: Context, packageInfo: PackageInfo): List<SourceEntry> {
val packageManager = context.packageManager
val applicationInfo = packageInfo.applicationInfo
val packageName = packageManager.getApplicationLabel(applicationInfo).toString().substringAfter("[Pupil] ")
val packagePath = packageInfo.packageName
val icon = packageManager.getApplicationIcon(applicationInfo)
val version = packageInfo.versionName
return packageInfo
.applicationInfo
.metaData
?.getString(SOURCES_PATH)
?.split(';')
?.map { source ->
val (sourceName, sourcePath) = source.split(':', limit = 2)
SourceEntry(
packageName,
packagePath,
sourceName,
sourcePath,
applicationInfo.sourceDir,
icon,
version
)
}.orEmpty()
}
private val sourceCacheMutex = Mutex()
private val sourceCache = mutableMapOf<String, Source>()
private fun loadSourceList(context: Context): List<SourceEntry> {
val packageManager = context.packageManager
val packages = packageManager.getInstalledPackages(
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_META_DATA
)
return packages.flatMap { packageInfo ->
if (packageInfo.isSourceFeatureEnabled)
loadSource(context, packageInfo)
else
emptyList()
}
}

View File

@@ -1,36 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2022 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources
import androidx.compose.runtime.*
import kotlinx.coroutines.delay
import org.kodein.di.compose.localDI
import org.kodein.di.compose.rememberInstance
import org.kodein.di.direct
import org.kodein.di.instance
import xyz.quaver.pupil.util.PupilHttpClient
import xyz.quaver.pupil.util.RemoteSourceInfo
@Composable
fun rememberRemoteSourceList(client: PupilHttpClient = localDI().direct.instance()) = produceState<Map<String, RemoteSourceInfo>?>(null) {
while (true) {
value = client.getRemoteSourceList()
delay(1000)
}
}

View File

@@ -1,6 +1,6 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
* Copyright (C) 2022 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -13,14 +13,10 @@
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
plugins {
`kotlin-dsl`
}
package xyz.quaver.pupil.types
repositories {
mavenCentral()
google()
}
class SendLogException : Exception()
class JavascriptException(message: String?) : Exception(message)

View File

@@ -0,0 +1,111 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.types
import kotlinx.serialization.Serializable
@Serializable
data class Tag(val area: String?, val tag: String, val isNegative: Boolean = false) {
companion object {
fun parse(tag: String) : Tag {
if (tag.firstOrNull() == '-') {
tag.substring(1).split(Regex(":"), 2).let {
return when(it.size) {
2 -> Tag(it[0], it[1], true)
else -> Tag(null, tag, true)
}
}
}
tag.split(Regex(":"), 2).let {
return when(it.size) {
2 -> Tag(it[0], it[1])
else -> Tag(null, tag)
}
}
}
}
override fun toString(): String {
return (if (isNegative) "-" else "") + when(area) {
null -> tag
else -> "$area:$tag"
}
}
fun toQuery(): String {
return toString().replace(' ', '_')
}
override fun equals(other: Any?): Boolean {
if (other !is Tag)
return false
if (other.area == area && other.tag == tag)
return true
return false
}
override fun hashCode() = toString().hashCode()
}
class Tags(val tags: MutableSet<Tag> = mutableSetOf()) : MutableSet<Tag> by tags {
companion object {
fun parse(tags: String) : Tags {
return Tags(
tags.split(' ').map {
if (it.isNotEmpty())
Tag.parse(it)
else
null
}.filterNotNull().toMutableSet()
)
}
}
fun contains(element: String): Boolean {
tags.forEach {
if (it.toString() == element)
return true
}
return false
}
fun add(element: String): Boolean {
return tags.add(Tag.parse(element))
}
fun remove(element: String) {
tags.filter { it.toString() == element }.forEach {
tags.remove(it)
}
}
fun removeByArea(area: String, isNegative: Boolean? = null) {
tags.filter { it.area == area && (if(isNegative == null) true else (it.isNegative == isNegative)) }.forEach {
tags.remove(it)
}
}
override fun toString(): String {
return tags.joinToString(" ") { it.toString() }
}
}

View File

@@ -20,102 +20,50 @@ package xyz.quaver.pupil.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.animation.Crossfade
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.getValue
import androidx.core.view.WindowCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch
import org.kodein.di.*
import org.kodein.di.android.closestDI
import org.kodein.di.android.subDI
import org.kodein.di.compose.rememberInstance
import xyz.quaver.pupil.BuildConfig
import xyz.quaver.pupil.sources.core.Source
import xyz.quaver.pupil.sources.core.util.LocalActivity
import xyz.quaver.pupil.sources.loadSource
import xyz.quaver.pupil.ui.theme.PupilTheme
import xyz.quaver.pupil.util.PupilHttpClient
import xyz.quaver.pupil.util.Release
class MainActivity : ComponentActivity(), DIAware {
override val di by closestDI()
import com.google.accompanist.adaptive.calculateDisplayFeatures
import xyz.quaver.pupil.ui.composable.MainApp
import xyz.quaver.pupil.ui.theme.AppTheme
import xyz.quaver.pupil.ui.viewmodel.MainViewModel
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val viewModel: MainViewModel by viewModels()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
PupilTheme {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
AppTheme {
val windowSize = calculateWindowSizeClass(this)
val displayFeatures = calculateDisplayFeatures(this)
val uiState by viewModel.searchState.collectAsStateWithLifecycle()
val navController = rememberNavController()
val systemUiController = rememberSystemUiController()
val useDarkIcons = MaterialTheme.colors.isLight
val coroutineScope = rememberCoroutineScope()
val client: PupilHttpClient by rememberInstance()
val latestRelease by produceState<Release?>(null) {
value = null //client.latestRelease()
}
var dismissUpdate by remember { mutableStateOf(false) }
SideEffect {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
MainApp(
windowSize = windowSize,
displayFeatures = displayFeatures,
uiState = uiState,
navController = navController,
openGalleryDetails = viewModel::openGalleryDetails,
closeGalleryDetails = viewModel::closeGalleryDetails,
onQueryChange = viewModel::onQueryChange,
loadSearchResult = viewModel::loadSearchResult
)
}
latestRelease?.let { release ->
UpdateAlertDialog(
show = !dismissUpdate && release.version != BuildConfig.VERSION_NAME,
release = release,
onDismiss = { dismissUpdate = true }
)
}
NavHost(navController, "main") {
composable("main") {
var source by remember { mutableStateOf<Source?>(null) }
BackHandler(
enabled = source != null
) {
source = null
}
Crossfade(source) { _source ->
if (_source == null)
SourceSelector {
coroutineScope.launch {
source = loadSource(application, it)
}
}
else {
CompositionLocalProvider(LocalActivity provides this@MainActivity) {
_source.Entry()
}
}
}
}
composable("settings") {
}
}
}
}
}
}
}

View File

@@ -1,352 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.outlined.Info
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import coil.compose.AsyncImage
import com.google.accompanist.drawablepainter.rememberDrawablePainter
import com.google.accompanist.insets.LocalWindowInsets
import com.google.accompanist.insets.rememberInsetsPaddingValues
import com.google.accompanist.insets.systemBarsPadding
import com.google.accompanist.insets.ui.BottomNavigation
import com.google.accompanist.insets.ui.Scaffold
import com.google.accompanist.insets.ui.TopAppBar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.kodein.di.DI
import org.kodein.di.DIAware
import org.kodein.di.compose.localDI
import org.kodein.di.instance
import xyz.quaver.pupil.sources.SourceEntry
import xyz.quaver.pupil.sources.rememberLocalSourceList
import xyz.quaver.pupil.sources.rememberRemoteSourceList
import xyz.quaver.pupil.util.PupilHttpClient
import xyz.quaver.pupil.util.RemoteSourceInfo
import xyz.quaver.pupil.util.launchApkInstaller
import java.io.File
private sealed class SourceSelectorScreen(val route: String, val icon: ImageVector) {
object Local: SourceSelectorScreen("local", Icons.Default.DownloadDone)
object Explore: SourceSelectorScreen("explore", Icons.Default.Explore)
}
private val sourceSelectorScreens = listOf(
SourceSelectorScreen.Local,
SourceSelectorScreen.Explore
)
private val RemoteSourceInfo.apkUrl: String
get() = "https://github.com/tom5079/PupilSources/releases/download/$name-$version/$projectName-release.apk"
class DownloadApkActionState(override val di: DI) : DIAware {
private val app: Application by instance()
private val client: PupilHttpClient by instance()
var progress by mutableStateOf<Float?>(null)
private set
suspend fun download(url: String): File? = withContext(Dispatchers.IO) {
progress = 0f
val file = File.createTempFile("pupil", ".apk", File(app.cacheDir, "apks").also {
it.mkdirs()
})
client.downloadFile(url, file).collect { progress = it }
if (progress == Float.POSITIVE_INFINITY) file else null
}
fun reset() {
progress = null
}
}
@Composable
fun rememberDownloadApkActionState(di: DI = localDI()) = remember { DownloadApkActionState(di) }
@Composable
fun DownloadApkAction(
state: DownloadApkActionState,
content: @Composable () -> Unit
) {
state.progress?.let { progress ->
Box(
Modifier.padding(12.dp, 0.dp)
) {
when {
progress.isFinite() && progress > 0f ->
CircularProgressIndicator(progress, modifier = Modifier.size(24.dp))
else ->
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
true
} ?: content()
}
@Composable
fun SourceListItem(icon: @Composable (Modifier) -> Unit = { }, name: String, version: String, actions: @Composable () -> Unit = { }) {
Card(
modifier = Modifier.padding(8.dp),
elevation = 4.dp
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
icon(Modifier.size(48.dp))
Column(
Modifier.weight(1f)
) {
Text(name.capitalize(Locale.current))
CompositionLocalProvider(LocalContentAlpha provides 0.5f) {
Text(
"v$version",
style = MaterialTheme.typography.caption
)
}
}
actions()
}
}
}
@Composable
fun Local(onSource: (SourceEntry) -> Unit) {
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
val localSourceList by rememberLocalSourceList()
val remoteSourceList by rememberRemoteSourceList()
if (localSourceList.isEmpty()) {
Box(Modifier.fillMaxSize()) {
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CompositionLocalProvider(LocalContentAlpha provides 0.5f) {
Text("(´∇`)", style = MaterialTheme.typography.h2)
}
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
}
}
} else {
LazyColumn {
items(localSourceList) { source ->
val actionState = rememberDownloadApkActionState()
SourceListItem(
icon = { modifier ->
Image(
rememberDrawablePainter(source.icon),
contentDescription = "source icon",
modifier = modifier
)
},
source.sourceName,
source.version
) {
DownloadApkAction(actionState) {
val remoteSource = remoteSourceList?.get(source.packageName)
if (remoteSource != null && remoteSource.version != source.version) {
TextButton(onClick = {
coroutineScope.launch {
val file = actionState.download(remoteSource.apkUrl)!! // TODO("Handle error")
context.launchApkInstaller(file)
actionState.reset()
}
}) {
Text("UPDATE")
}
} else {
TextButton(
onClick = { onSource(source) }
) {
Text("GO")
}
}
}
}
}
}
}
}
@Composable
fun Explore() {
val localSourceList by rememberLocalSourceList()
val localSources by derivedStateOf {
localSourceList.associateBy {
it.packageName
}
}
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val remoteSources by rememberRemoteSourceList()
Box(
Modifier.fillMaxSize()
) {
if (remoteSources == null)
CircularProgressIndicator(Modifier.align(Alignment.Center))
else
LazyColumn {
items(remoteSources?.values?.toList().orEmpty()) { sourceInfo ->
val actionState = rememberDownloadApkActionState()
SourceListItem(
icon = { modifier ->
AsyncImage(
"https://raw.githubusercontent.com/tom5079/PupilSources/master/${sourceInfo.projectName}/src/main/res/mipmap-xxxhdpi/ic_launcher.png",
contentDescription = "source icon",
modifier = modifier
)
},
sourceInfo.name,
sourceInfo.version
) {
DownloadApkAction(actionState) {
if (localSources[sourceInfo.name]?.version != sourceInfo.version) {
TextButton(onClick = {
coroutineScope.launch {
val file = actionState.download(sourceInfo.apkUrl)!! // TODO("Handle exception")
context.launchApkInstaller(file)
actionState.reset()
}
}) {
Text("UPDATE")
}
} else {
IconButton(onClick = {
if (sourceInfo.name in localSources) {
context.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", localSources[sourceInfo.name]!!.packagePath, null)
)
)
} else coroutineScope.launch {
val file = actionState.download(sourceInfo.apkUrl)!! // TODO("Handle exception")
context.launchApkInstaller(file)
actionState.reset()
}
}) {
Icon(
if (sourceInfo.name !in localSources) Icons.Default.Download
else Icons.Outlined.Info,
contentDescription = "download"
)
}
}
}
}
}
}
}
}
@Composable
fun SourceSelector(onSource: (SourceEntry) -> Unit) {
val bottomNavController = rememberNavController()
Scaffold(
topBar = {
TopAppBar(
title = {
Text("Pupil")
},
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.statusBars)
)
},
bottomBar = {
BottomNavigation(
contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
) {
val navBackStackEntry by bottomNavController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
sourceSelectorScreens.forEach { screen ->
BottomNavigationItem(
icon = { Icon(screen.icon, contentDescription = screen.route) },
label = { Text(screen.route) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
bottomNavController.navigate(screen.route) {
popUpTo(bottomNavController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { contentPadding ->
NavHost(bottomNavController, startDestination = "local", modifier = Modifier
.systemBarsPadding(top = false, bottom = false)
.padding(contentPadding)) {
composable(SourceSelectorScreen.Local.route) { Local(onSource) }
composable(SourceSelectorScreen.Explore.route) { Explore() }
}
}
}

View File

@@ -1,95 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2022 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.launch
import org.kodein.di.compose.onDIContext
import xyz.quaver.pupil.util.Release
import xyz.quaver.pupil.util.launchApkInstaller
import java.util.*
@Composable
fun UpdateAlertDialog(
show: Boolean,
release: Release,
onDismiss: () -> Unit
) {
val state = rememberDownloadApkActionState()
val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current
if (show) {
Dialog(onDismissRequest = { if (state.progress == null) onDismiss() }) {
Card {
val progress = state.progress
if (progress != null) {
if (progress.isFinite() && progress > 0)
LinearProgressIndicator(progress)
else
LinearProgressIndicator()
}
Column(
Modifier.padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 0.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Update Available",
style = MaterialTheme.typography.h6
)
Text(release.releaseNotes.getOrElse(Locale.getDefault()) { release.releaseNotes[Locale.ENGLISH]!! })
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss, enabled = progress == null) {
Text("DISMISS")
}
TextButton(
onClick = {
coroutineScope.launch {
val file = state.download(release.apkUrl)!! // TODO("Handle exception")
context.launchApkInstaller(file)
state.reset()
onDismiss()
}
},
enabled = progress == null
) {
Text("UPDATE")
}
}
}
}
}
}
}

View File

@@ -0,0 +1,5 @@
package xyz.quaver.pupil.ui.composable
enum class ContentType {
SINGLE_PANE, DUAL_PANE
}

View File

@@ -0,0 +1,34 @@
package xyz.quaver.pupil.ui.composable
import android.graphics.Rect
import androidx.window.layout.FoldingFeature
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
sealed interface DevicePosture {
data object NormalPosture: DevicePosture
data class BookPosture(
val hingePosition: Rect
): DevicePosture
data class Separating(
val hingePosition: Rect,
val orientation: FoldingFeature.Orientation
): DevicePosture
}
@OptIn(ExperimentalContracts::class)
fun isBookPosture(foldingFeature: FoldingFeature?): Boolean {
contract { returns(true) implies (foldingFeature != null) }
return foldingFeature?.state == FoldingFeature.State.HALF_OPENED &&
foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL
}
@OptIn(ExperimentalContracts::class)
fun isSeparating(foldingFeature: FoldingFeature?): Boolean {
contract { returns(true) implies (foldingFeature != null) }
return foldingFeature?.state == FoldingFeature.State.FLAT && foldingFeature.isSeparating
}

View File

@@ -0,0 +1,504 @@
package xyz.quaver.pupil.ui.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material.icons.filled.QuestionMark
import androidx.compose.material.icons.filled.StarOutline
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import xyz.quaver.pupil.R
import xyz.quaver.pupil.networking.Artist
import xyz.quaver.pupil.networking.Character
import xyz.quaver.pupil.networking.GalleryFile
import xyz.quaver.pupil.networking.GalleryInfo
import xyz.quaver.pupil.networking.GalleryTag
import xyz.quaver.pupil.networking.Group
import xyz.quaver.pupil.networking.HitomiHttpClient
import xyz.quaver.pupil.networking.Language
import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.networking.Series
import xyz.quaver.pupil.networking.joinToCapitalizedString
import xyz.quaver.pupil.ui.theme.Blue500
import xyz.quaver.pupil.ui.theme.Green500
import xyz.quaver.pupil.ui.theme.Purple500
import xyz.quaver.pupil.ui.theme.Red500
import xyz.quaver.pupil.ui.theme.Yellow500
private val languageMap = mapOf(
"indonesian" to "Bahasa Indonesia",
"catalan" to "català",
"cebuano" to "Cebuano",
"czech" to "Čeština",
"danish" to "Dansk",
"german" to "Deutsch",
"estonian" to "eesti",
"english" to "English",
"spanish" to "Español",
"esperanto" to "Esperanto",
"french" to "Français",
"italian" to "Italiano",
"latin" to "Latina",
"hungarian" to "magyar",
"dutch" to "Nederlands",
"norwegian" to "norsk",
"polish" to "polski",
"portuguese" to "Português",
"romanian" to "română",
"albanian" to "shqip",
"slovak" to "Slovenčina",
"finnish" to "Suomi",
"swedish" to "Svenska",
"tagalog" to "Tagalog",
"vietnamese" to "tiếng việt",
"turkish" to "Türkçe",
"greek" to "Ελληνικά",
"mongolian" to "Монгол",
"russian" to "Русский",
"ukrainian" to "Українська",
"hebrew" to "עברית",
"arabic" to "العربية",
"persian" to "فارسی",
"thai" to "ไทย",
"korean" to "한국어",
"chinese" to "中文",
"japanese" to "日本語"
)
private val galleryTypeStringMap = mapOf(
"doujinshi" to R.string.doujinshi,
"manga" to R.string.manga,
"artistcg" to R.string.artist_cg,
"gamecg" to R.string.game_cg,
"imageset" to R.string.image_set
)
private val galleryTypeColorMap = mapOf(
"doujinshi" to Red500,
"manga" to Yellow500,
"artistcg" to Purple500,
"gamecg" to Green500,
"imageset" to Blue500
)
class GalleryInfoProvider: PreviewParameterProvider<GalleryInfo> {
override val values = sequenceOf(
GalleryInfo(
id = "2296437",
title = "Kakyuu Majutsushi, Inmon ni Somaru | 하급 마술사, 음문에 물들다",
language = "korean",
type = "doujinshi",
date = "2022-08-11 07:14:00-05",
artists = listOf(Artist("wagashi")),
groups = listOf(Group("dagashiya")),
series = listOf(Series("original")),
tags = listOf(
GalleryTag("ahegao", female="1"),
GalleryTag("big penis", male="1"),
GalleryTag("bike shorts", female="1"),
GalleryTag("blowjob", female="1"),
GalleryTag("blowjob face", female="1"),
GalleryTag("bukkake", female="1"),
GalleryTag("bunny girl", female="1"),
GalleryTag("clone", male="1"),
GalleryTag("corruption", female="1"),
GalleryTag("crotch tattoo", female="1"),
GalleryTag("gloves", female="1"),
GalleryTag("gokkun", female="1"),
GalleryTag("group"),
GalleryTag("kemonomimi", female="1"),
GalleryTag("leotard", female="1"),
GalleryTag("lingerie", female="1"),
GalleryTag("loli", female="1"),
GalleryTag("masked face", female="1"),
GalleryTag("masturbation", female="1"),
GalleryTag("mind control", female="1"),
GalleryTag("mmf threesome"),
GalleryTag("moral degeneration", female="1"),
GalleryTag("mouth mask", female="1"),
GalleryTag("nakadashi", female="1"),
GalleryTag("prostitution", female="1"),
GalleryTag("smell", male="1"),
GalleryTag("unusual pupils", female="1"),
),
related = listOf(2806924, 2806923, 2319091, 1647024, 2580808),
languages = listOf(
Language(galleryID="2806923", name="korean"),
Language(galleryID="2609305", name="english"),
Language(galleryID="2302333", name="spanish"),
Language(galleryID="2392785", name="portuguese"),
Language(galleryID="2303940", name="russian"),
Language(galleryID="2736129", name="chinese"),
Language(galleryID="2295647", name="japanese")
),
characters = listOf(Character("Kakyuu Majutsushi")),
files = listOf(
GalleryFile(name="01.jpg", hash="d441383396a6ba41a2db914328dc80d16b5191e53d23a5f0f9f8a0cd8f2e7cef", width=4185, height=6000, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="02.png", hash="a42517a19c7db6369749807bbc6676906e35709be07f780247f2e68d516ed1d5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="03.png", hash="ee0841953755f34a0a146a7f757cf2993c678384f53e88715b1c97a00abe5c27", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="04.png", hash="66fafca77a7ed0287666e77fe268a02f75b4e27c2b9b77e6577bb3132396132b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="05.png", hash="0ef8081ad9eef5093077c8551e87903e8b275e607634717857c2986e8d3c51a9", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="06.png", hash="2e59ffd59fa761355ea855a9e0f366cb39569207165a99659a9f0868cbba7e94", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="07.png", hash="5dc19fb97a2f1c64cae5634cd651f593d022b3114bbacaab15ba114be581cbea", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="08.png", hash="9121781d4f8fb1aaaee124f82c2ab98c97eb3e1f9508bab7c1d8771bb5eddfdb", width=4520, height=6295, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="09.png", hash="e5ceae4da5e497bd95a79a607f2c85bb3e8dc7386e041086cd5ccb9a9fb4dcb1", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="10.png", hash="be56219811a29f86dfcf5a7af0b25addc7436b134acd1a94d51c69c987da9d1b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="11.png", hash="4e3b09ac015360ad4daffcea46265f3eb319bbe638ce90f26d4cbae37dc8c0f1", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="12.png", hash="5d57c0a0cd00604382eeaf0b32446b938dafc9d980eb086673e1fa0307166e39", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="13.png", hash="1ff2313fe979b52b826d482be90699a7c086afc251fa22306e92dfc582611f10", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="14.png", hash="7ad92a9408a059afafc68d3086c5f6a070c7e0d550bc2d328b5c9e16a62be01c", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="15.png", hash="4a6db95b7111b647e450c155af07c617d28313c02871ed94ad0c5986d3c5d1aa", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="16.png", hash="d4b53bf416c9bd2f72850e80ddaaf8467c663c72433c8ebebf3a70742fea7c32", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="17.png", hash="d189d5321f18414de816c049d3e2d72a7d31124d628037ca9f7da4572025cf01", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="18.png", hash="3ff372d7ba4e34cff9f7b46f5323e60b36f9b9df3dd5d02be4c71de4a7ee23c7", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="19.png", hash="2965852c2000fb17f756263b47ca196563995be2d03143f64c297f5930248d1e", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="20.png", hash="3713f95947cc6df0b67af5532a440f023c99ee37d483f3f9252400168e3a55ad", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="21.png", hash="c0b2b2d5ee79c3dc3b737c0eaff19ed1a731d81495adc2c94260de7ebbd85415", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="22.png", hash="8835fe309a26fce6882c0fcdf37cbd5f5bcc69dd2c32e436deb644891ca8499b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="23.png", hash="5eb28619c1919ad29b86fb6cdaeccd50ad2ad857c81f56c060e8b66a2ed315d0", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="24.png", hash="7ea3ecf4c0b0e5e632163a5b0dc2475a071e1209a0fed8e8e49243093c35babb", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="25.png", hash="d01355724eb60c41e43159607652812d1fbbbac12962b2f9068a9e620ee0c246", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="26.png", hash="58a33c1d709b005a17600f7beb14a81711a106619bdb029d30646a9060c245c7", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="27.png", hash="0b18753f2fe7ea97c2e2c13a082a5a675f36085558bcd3fb7be916b6118c6000", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="28.png", hash="e8f0a2f9d35ec2c1974a4aea07eefd792462049e7b0972bf8cd5532dd82cee21", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="29.png", hash="90ec7a13aedf22c1f9317f75e843a4ace4e236d6100a8c8bb5aeb8160ff1cd00", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="30.png", hash="3229400975e6cef763c54a82a4ce0e6f51ec41c9b674072e74f66b41e325b655", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="31.png", hash="4e04efa56981804f7d13858a98935038fe421956367dcabba8e9118d273135d5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="32.png", hash="e0d8641127aaaf587ec5e3040c49edee9cda866a5f1f377e567b908514b1eb68", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="33.png", hash="00125ab9090be7d3462fd6943c209bc68c236ef50ca0f1552530fd8253d87f7d", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="34.png", hash="1ea303198badaecfcf66a438baa6867690416d77a542f1ff0181b1895df95ccb", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="35.png", hash="40181ac057808a16716e640c462cc2b992822999ab8db43d454bb331d50bd39f", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="36.png", hash="92cbe1067c1c554c5a2e01b58b6fb86677a523cba586dfa209d61b4af70b2f54", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="37.png", hash="4fbbdc6d5450eb1f2f7e45a97ce8360d43e199454a8eb9e536740309c2067999", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="38.png", hash="0cb74b8af27e51604c728a26cf4788899fa04f854853badea9884b008c024e94", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="39.png", hash="254676c13ab418ce1110a9bad009554abc72af7e2ff9d719409881ea3c635d46", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="40.png", hash="33527e1171b9cf21ab164be2b76cb5b9daa91980a3330f2441dd9bf911e2e05b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="41.png", hash="4dc331acb058665500aa308143c106efb6999855d1c3b0665c1dd331c8364430", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="42.png", hash="7674f03fa02cf96a20f1e192caf76d85e7e556add0cdb65aabdc86d417093d39", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="43.png", hash="27a4a280483142b213b26d53f06b991be35122cb263c69a87435e624b2a307fd", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="44.png", hash="3114ba3bff094abbae9160656894d462e0567cea23e6fa2693469c5e7defa6fe", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="45.png", hash="dc22ffed6a678a560e781f795b3ee0876ec8726ae2af942226710102758d44db", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="46.png", hash="4f7257ad75b990cc2b8dad0c5a09a831cb1670e7aaeadc71809f4bd7400f0071", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="47.png", hash="bf74fcd74ce77b5089bac98dc9f5737ba1fe87cdcfb01ad80e63024c93f82692", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="48.png", hash="d0baed210584183efa654ca7a483c558afe9eb18b71452ed2cfd4264b269ffca", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="49.png", hash="2b9c90a038e4e918655dffa84fbbb08cbedfce642277b1140d070aa9a711ebac", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="50.png", hash="cdfba6a9b8a570f2f317fb01e66ebc72ede5735a41256b326a38af2296799095", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="51.png", hash="84ad9d51aab2e8ae1cbb5a0918cc3f62473e98512db9f473ab1ffe3a4f7aa75a", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="52.png", hash="0d7a420aa23e23cee7f8e124b12080e309cffb3d7269389f23e1ac3d5b7363a5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="53.png", hash="575cf99346f9786d6e30bc163dc6d5fa439bd157a9ffce90586bfc5657121981", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="54.png", hash="9004d3fd46dc278b79cf7a5bf72002dd4b0b03ad6bded30ceebcb633185b49ce", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="55.png", hash="8e53c301e2c2b539783c7b8c4f5028f221198d33e114dcc40e6a0025aee840ab", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="56.png", hash="576764073554fcf644c12d80f26b3bae42f39f3516ed7841c9e297248324b237", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="57.png", hash="110da01b9ae7ed4a145792f55498a5efa22cebec4da84f6220904840b508c75e", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
GalleryFile(name="58.jpg", hash="3ae38577135465b6224e0487c0cdcd37cf11764883f3b78a67545d48c6beade5", width=4260, height=6000, hasWebP=1, hasAVIF=1, hasJXL=0),
)
)
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TagGroup(tags: List<SearchQuery.Tag>, folded: Boolean = false) {
var isFolded by remember { mutableStateOf(folded) }
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
tags.sortedBy {
when(it.namespace) {
"female" -> 1
"male" -> 2
else -> 3
}
}.let {
if (isFolded) it.take(10) else it
}.forEach { tag ->
TagChip(tag = tag.toTag())
}
if (isFolded && tags.size > 10)
Surface(
modifier = Modifier.height(32.dp),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(16.dp),
onClick = { isFolded = false }
) {
Text(
"",
modifier = Modifier.padding(16.dp, 8.dp),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
fun GalleryTypeIndicator(galleryType: String) {
Surface(
modifier = Modifier.height(32.dp),
color = galleryTypeColorMap[galleryType] ?: MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) {
Box(Modifier.fillMaxHeight()) {
Text(
galleryTypeStringMap[galleryType]?.let { stringResource(it) } ?: galleryType,
modifier = Modifier
.padding(horizontal = 16.dp)
.align(Alignment.Center),
style = MaterialTheme.typography.bodyMedium,
color = Color.White
)
}
}
}
@Composable
fun LanguageTitle(title: String, language: String?) {
val icon = languageIconMap[language]
if (icon != null) {
Text(
buildAnnotatedString {
appendInlineContent("language", "<language>")
append(' ')
append(title)
},
style = MaterialTheme.typography.headlineSmall,
inlineContent = mapOf(
"language" to InlineTextContent(
Placeholder(
width = 20.sp,
height = 20.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
Icon(
painterResource(icon),
contentDescription = null,
tint = Color.Unspecified
)
}
)
)
} else {
Text(title, style = MaterialTheme.typography.headlineSmall)
}
}
@Composable
fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) {
val thumbnailFile = galleryInfo.files.first()
val aspectRatio = thumbnailFile.let { it.width / it.height.toFloat() }
if (thumbnailFile.let { it.width > it.height }) {
Column {
if (thumbnailUrl != null) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(thumbnailUrl)
.setHeader("Referer", "https://hitomi.la/")
.build(),
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio)
.clip(RoundedCornerShape(8.dp)),
loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) },
error = {
Icon(
Icons.Default.BrokenImage,
contentDescription = null,
)
},
contentDescription = "Thumbnail"
)
} else {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio)) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
Spacer(Modifier.height(8.dp))
LanguageTitle(galleryInfo.title, galleryInfo.language)
val artistsAndGroups = buildString {
if (!galleryInfo.artists.isNullOrEmpty())
append(galleryInfo.artists.joinToCapitalizedString())
if (!galleryInfo.groups.isNullOrEmpty()) {
if (this.isNotEmpty()) append(' ')
append('(')
append(galleryInfo.groups.joinToCapitalizedString())
append(')')
}
}
if (artistsAndGroups.isNotEmpty()) {
Text(
artistsAndGroups,
style = MaterialTheme.typography.labelLarge,
)
}
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (thumbnailUrl != null) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(thumbnailUrl)
.setHeader("Referer", "https://hitomi.la/")
.build(),
modifier = Modifier
.height(200.dp)
.aspectRatio(aspectRatio)
.clip(RoundedCornerShape(8.dp)),
loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) },
error = {
Icon(
Icons.Default.BrokenImage,
contentDescription = null,
)
},
contentDescription = "Thumbnail"
)
} else {
Box(
Modifier
.height(200.dp)
.aspectRatio(aspectRatio)) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
Column(Modifier.heightIn(min = 200.dp)) {
LanguageTitle(galleryInfo.title, galleryInfo.language)
val artistsAndGroups = buildString {
if (!galleryInfo.artists.isNullOrEmpty())
append(galleryInfo.artists.joinToCapitalizedString())
if (!galleryInfo.groups.isNullOrEmpty()) {
if (this.isNotEmpty()) append(' ')
append('(')
append(galleryInfo.groups.joinToCapitalizedString())
append(')')
}
}
if (artistsAndGroups.isNotEmpty()) {
Text(
artistsAndGroups,
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
}
@Preview
@Composable
fun DetailedGalleryInfo(
@PreviewParameter(GalleryInfoProvider::class) galleryInfo: GalleryInfo,
modifier: Modifier = Modifier,
) {
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(galleryInfo) {
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
HitomiHttpClient.getImageURL(it, true).firstOrNull()
} ?: ""
}
Card(modifier) {
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
GalleryTypeIndicator(galleryInfo.type)
if (galleryInfo.tags?.isNotEmpty() == true) {
TagGroup(galleryInfo.tags.map { it.toTag() }, folded = true)
}
HorizontalDivider()
Box(
Modifier
.fillMaxWidth()
.padding(4.dp)) {
Text(
modifier = Modifier.align(Alignment.CenterStart),
text = galleryInfo.id,
style = MaterialTheme.typography.bodyMedium
)
Text(
modifier = Modifier.align(Alignment.Center),
text = "${galleryInfo.files.size}P",
style = MaterialTheme.typography.bodyMedium
)
Icon(
modifier = Modifier
.align(Alignment.CenterEnd)
.size(32.dp),
imageVector = Icons.Default.StarOutline,
contentDescription = null,
tint = Yellow500
)
}
}
}
}

View File

@@ -0,0 +1,306 @@
package xyz.quaver.pupil.ui.composable
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.PermanentNavigationDrawer
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.activity
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.window.layout.DisplayFeature
import androidx.window.layout.FoldingFeature
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.networking.GalleryInfo
import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.ui.viewmodel.SearchState
@Composable
fun MainApp(
windowSize: WindowSizeClass,
displayFeatures: List<DisplayFeature>,
uiState: SearchState,
navController: NavHostController,
openGalleryDetails: (GalleryInfo) -> Unit,
closeGalleryDetails: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit,
) {
val navigationType: NavigationType
val contentType: ContentType
val foldingFeature: FoldingFeature? = displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
val foldingDevicePosture = when {
isBookPosture(foldingFeature) -> DevicePosture.BookPosture(foldingFeature.bounds)
isSeparating(foldingFeature) -> DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation)
else -> DevicePosture.NormalPosture
}
when (windowSize.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
navigationType = NavigationType.BOTTOM_NAVIGATION
contentType = ContentType.SINGLE_PANE
}
WindowWidthSizeClass.Medium -> {
navigationType = NavigationType.NAVIGATION_RAIL
contentType = if (foldingDevicePosture != DevicePosture.NormalPosture) {
ContentType.DUAL_PANE
} else {
ContentType.SINGLE_PANE
}
}
WindowWidthSizeClass.Expanded -> {
navigationType = if (foldingDevicePosture is DevicePosture.BookPosture) {
NavigationType.NAVIGATION_RAIL
} else {
NavigationType.PERMANENT_NAVIGATION_DRAWER
}
contentType = ContentType.DUAL_PANE
}
else -> {
navigationType = NavigationType.BOTTOM_NAVIGATION
contentType = ContentType.SINGLE_PANE
}
}
val navigationContentPosition = when (windowSize.heightSizeClass) {
WindowHeightSizeClass.Compact -> NavigationContentPosition.TOP
WindowHeightSizeClass.Medium,
WindowHeightSizeClass.Expanded -> NavigationContentPosition.CENTER
else -> NavigationContentPosition.TOP
}
MainNavigationWrapper(
navigationType,
contentType,
displayFeatures,
navigationContentPosition,
uiState,
navController,
openGalleryDetails = openGalleryDetails,
closeGalleryDetails = closeGalleryDetails,
onQueryChange = onQueryChange,
loadSearchResult = loadSearchResult
)
}
@Composable
private fun MainNavigationWrapper(
navigationType: NavigationType,
contentType: ContentType,
displayFeatures: List<DisplayFeature>,
navigationContentPosition: NavigationContentPosition,
uiState: SearchState,
navController: NavHostController,
openGalleryDetails: (GalleryInfo) -> Unit,
closeGalleryDetails: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit
) {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val coroutineScope = rememberCoroutineScope()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val openDrawer: () -> Unit = {
coroutineScope.launch {
drawerState.open()
}
}
if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) {
PermanentNavigationDrawer(
drawerContent = {
PermanentNavigationDrawerContent(
selectedDestination = currentRoute,
navigateToDestination = { navController.navigate(it.route) {
popUpTo(MainDestination.Search.route)
launchSingleTop = true
} },
navigationContentPosition = navigationContentPosition,
)
}
) {
MainContent(
navigationType = navigationType,
contentType = contentType,
displayFeatures = displayFeatures,
uiState = uiState,
navController = navController,
onDrawerClicked = openDrawer,
openGalleryDetails = openGalleryDetails,
closeGalleryDetails = closeGalleryDetails,
onQueryChange = onQueryChange,
loadSearchResult = loadSearchResult,
)
}
} else {
ModalNavigationDrawer(
drawerContent = {
ModalNavigationDrawerContent(
selectedDestination = currentRoute,
navigateToDestination = { navController.navigate(it.route) {
popUpTo(MainDestination.Search.route)
launchSingleTop = true
} },
navigationContentPosition = navigationContentPosition,
onDrawerClicked = {
coroutineScope.launch {
drawerState.close()
}
}
)
},
drawerState = drawerState
) {
MainContent(
navigationType = navigationType,
contentType = contentType,
displayFeatures = displayFeatures,
uiState = uiState,
navController = navController,
onDrawerClicked = openDrawer,
openGalleryDetails = openGalleryDetails,
closeGalleryDetails = closeGalleryDetails,
onQueryChange = onQueryChange,
loadSearchResult = loadSearchResult,
)
}
}
}
@Composable
fun NotImplemented() {
Box(Modifier.fillMaxSize()) {
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("( ⁄•⁄ω⁄•⁄ )", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
Text(stringResource(R.string.not_implemented), textAlign = TextAlign.Center)
}
}
}
@Composable
fun MainContent(
navigationType: NavigationType,
contentType: ContentType,
displayFeatures: List<DisplayFeature>,
uiState: SearchState,
navController: NavHostController,
onDrawerClicked: () -> Unit,
openGalleryDetails: (GalleryInfo) -> Unit,
closeGalleryDetails: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
Row(modifier = Modifier.fillMaxSize()) {
AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) {
MainNavigationRail(
selectedDestination = currentRoute,
navigateToDestination = { navController.navigate(it.route) {
popUpTo(MainDestination.Search.route)
launchSingleTop = true
} },
onDrawerClicked = onDrawerClicked
)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.inverseOnSurface)
) {
Box(
modifier = Modifier
.weight(1f)
.run {
if (navigationType == NavigationType.BOTTOM_NAVIGATION) {
this
.consumeWindowInsets(WindowInsets.ime)
.consumeWindowInsets(WindowInsets.navigationBars)
} else this
}
) {
NavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
startDestination = MainDestination.Search.route
) {
composable(MainDestination.Search.route) {
SearchScreen(
contentType = contentType,
displayFeatures = displayFeatures,
uiState = uiState,
openGalleryDetails = openGalleryDetails,
closeGalleryDetails = closeGalleryDetails,
onQueryChange = onQueryChange,
loadSearchResult = loadSearchResult,
openGallery = {
navController.navigate(MainDestination.ImageViewer(it.id).route) {
launchSingleTop = true
}
}
)
}
composable(MainDestination.History.route) {
NotImplemented()
}
composable(MainDestination.Downloads.route) {
NotImplemented()
}
composable(MainDestination.Favorites.route) {
NotImplemented()
}
composable(MainDestination.Settings.route) {
NotImplemented()
}
composable(MainDestination.ImageViewer.commonRoute) {
NotImplemented()
}
}
}
AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) {
BottomNavigationBar(
selectedDestination = currentRoute,
navigateToDestination = { navController.navigate(it.route) {
popUpTo(MainDestination.Search.route)
launchSingleTop = true
} }
)
}
}
}
}

View File

@@ -0,0 +1,67 @@
package xyz.quaver.pupil.ui.composable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.ui.graphics.vector.ImageVector
import xyz.quaver.pupil.R
sealed interface MainDestination {
val route: String
val icon: ImageVector
val textId: Int
data object Search: MainDestination {
override val route = "search"
override val icon = Icons.Default.Search
override val textId = R.string.main_destination_search
}
data object History: MainDestination {
override val route = "history"
override val icon = Icons.Default.History
override val textId = R.string.main_destination_history
}
data object Downloads: MainDestination {
override val route = "downloads"
override val icon = Icons.Default.Download
override val textId = R.string.main_destination_downloads
}
data object Favorites: MainDestination {
override val route = "favorites"
override val icon = Icons.Default.Favorite
override val textId = R.string.main_destination_favorites
}
data object Settings: MainDestination {
override val route = "settings"
override val icon = Icons.Default.Settings
override val textId = R.string.main_destination_settings
}
class ImageViewer(galleryID: String): MainDestination {
override val route = "image_viewer/$galleryID"
override val icon = Icons.AutoMirrored.Filled.MenuBook
override val textId = R.string.main_destination_image_viewer
companion object {
val commonRoute = "image_viewer/{galleryID}"
}
}
}
val mainDestinations = listOf(
MainDestination.Search,
MainDestination.History,
MainDestination.Downloads,
MainDestination.Favorites,
MainDestination.Settings
)

View File

@@ -0,0 +1,5 @@
package xyz.quaver.pupil.ui.composable
enum class NavigationContentPosition {
TOP, CENTER
}

View File

@@ -0,0 +1,294 @@
package xyz.quaver.pupil.ui.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuOpen
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.PermanentDrawerSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import androidx.navigation.NavDestination
import xyz.quaver.pupil.R
@Composable
fun PermanentNavigationDrawerContent(
selectedDestination: String?,
navigateToDestination: (MainDestination) -> Unit,
navigationContentPosition: NavigationContentPosition,
) {
PermanentDrawerSheet(
modifier = Modifier.sizeIn(minWidth = 200.dp, maxWidth = 300.dp),
drawerContainerColor = MaterialTheme.colorScheme.inverseOnSurface
) {
Layout(
modifier = Modifier
.background(MaterialTheme.colorScheme.inverseOnSurface)
.padding(16.dp),
content = {
Row(
modifier = Modifier.layoutId(LayoutType.HEADER),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(32.dp),
painter = painterResource(R.drawable.app_icon),
tint = Color.Unspecified,
contentDescription = "app icon"
)
Text(
modifier = Modifier.padding(16.dp),
text = "Pupil",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary
)
}
Column(
modifier = Modifier
.layoutId(LayoutType.CONTENT)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
mainDestinations.forEach { destination ->
NavigationDrawerItem(
label = {
Text(
text = stringResource(destination.textId),
modifier = Modifier.padding(16.dp)
)
},
icon = {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.textId)
)
},
selected = selectedDestination == destination.route,
colors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = Color.Transparent
),
onClick = { navigateToDestination(destination) }
)
}
}
},
measurePolicy = navigationMeasurePolicy(navigationContentPosition)
)
}
}
@Composable
fun ModalNavigationDrawerContent(
selectedDestination: String?,
navigationContentPosition: NavigationContentPosition,
navigateToDestination: (MainDestination) -> Unit,
onDrawerClicked: () -> Unit
) {
ModalDrawerSheet {
Layout(
modifier = Modifier
.background(MaterialTheme.colorScheme.inverseOnSurface)
.padding(16.dp),
content = {
Row(
modifier = Modifier
.layoutId(LayoutType.HEADER)
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(32.dp),
painter = painterResource(R.drawable.app_icon),
tint = Color.Unspecified,
contentDescription = "app icon"
)
Text(
modifier = Modifier.padding(16.dp),
text = "Pupil",
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = onDrawerClicked) {
Icon(
imageVector = Icons.AutoMirrored.Default.MenuOpen,
contentDescription = stringResource(R.string.main_open_navigation_drawer)
)
}
}
Column (
modifier = Modifier
.layoutId(LayoutType.CONTENT)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
mainDestinations.forEach { destination ->
NavigationDrawerItem(
label = {
Text(
text = stringResource(destination.textId),
modifier = Modifier.padding(16.dp)
)
},
icon = {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.textId)
)
},
selected = selectedDestination == destination.route,
colors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = Color.Transparent
),
onClick = { navigateToDestination(destination) }
)
}
}
},
measurePolicy = navigationMeasurePolicy(navigationContentPosition)
)
}
}
@Composable
fun MainNavigationRail(
selectedDestination: String?,
navigateToDestination: (MainDestination) -> Unit,
onDrawerClicked: () -> Unit
) {
NavigationRail (
modifier = Modifier.fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.inverseOnSurface
) {
NavigationRailItem(
selected = false,
onClick = onDrawerClicked,
icon = {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.main_open_navigation_drawer)
)
}
)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
mainDestinations.forEach { destination ->
NavigationRailItem(
selected = selectedDestination == destination.route,
onClick = { navigateToDestination(destination) },
icon = {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.textId)
)
}
)
}
}
}
}
@Composable
fun BottomNavigationBar(
selectedDestination: String?,
navigateToDestination: (MainDestination) -> Unit
) {
NavigationBar(modifier = Modifier.fillMaxWidth(), windowInsets = WindowInsets.ime.union(WindowInsets.navigationBars)) {
mainDestinations.forEach { destination ->
NavigationBarItem(
selected = selectedDestination == destination.route,
onClick = { navigateToDestination(destination) },
icon = {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.textId)
)
}
)
}
}
}
fun navigationMeasurePolicy(
navigationContentPosition: NavigationContentPosition,
): MeasurePolicy {
return MeasurePolicy { measurables, constraints ->
lateinit var headerMeasurable: Measurable
lateinit var contentMeasurable: Measurable
measurables.forEach {
when (it.layoutId) {
LayoutType.HEADER -> headerMeasurable = it
LayoutType.CONTENT -> contentMeasurable = it
else -> error("Unknown layoutId encountered!")
}
}
val headerPlaceable = headerMeasurable.measure(constraints)
val contentPlaceable = contentMeasurable.measure(
constraints.offset(vertical = -headerPlaceable.height)
)
layout(constraints.maxWidth, constraints.maxHeight) {
headerPlaceable.placeRelative(0, 0)
val nonContentVerticalSpace = constraints.maxHeight - contentPlaceable.height
val contentPlaceableY = when (navigationContentPosition) {
NavigationContentPosition.TOP -> 0
NavigationContentPosition.CENTER -> nonContentVerticalSpace / 2
}.coerceAtLeast(headerPlaceable.height)
contentPlaceable.placeRelative(0, contentPlaceableY)
}
}
}
enum class LayoutType {
HEADER, CONTENT
}

View File

@@ -0,0 +1,5 @@
package xyz.quaver.pupil.ui.composable
enum class NavigationType {
NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER, BOTTOM_NAVIGATION
}

View File

@@ -0,0 +1,217 @@
package xyz.quaver.pupil.ui.composable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.util.fastFirstOrNull
import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.theme.Blue300
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@Composable
fun OverscrollPager(
prevPage: Int?,
nextPage: Int?,
onPageTurn: (Int) -> Unit,
prevPageTurnIndicatorOffset: Dp = 0.dp,
nextPageTurnIndicatorOffset: Dp = 0.dp,
content: @Composable () -> Unit
) {
val haptic = LocalHapticFeedback.current
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
var overscroll: Float? by remember { mutableStateOf(null) }
var size: Size? by remember { mutableStateOf(null) }
val circleRadius = (size?.width ?: 0f) / 2
val topCircleRadius by animateFloatAsState(if (overscroll?.let { it >= pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "topCircleRadius")
val bottomCircleRadius by animateFloatAsState(if (overscroll?.let { it <= -pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "bottomCircleRadius")
val prevPageTurnIndicatorOffsetPx = LocalDensity.current.run { prevPageTurnIndicatorOffset.toPx() }
val nextPageTurnIndicatorOffsetPx = LocalDensity.current.run { nextPageTurnIndicatorOffset.toPx() }
if (topCircleRadius != 0f || bottomCircleRadius != 0f)
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
Blue300,
center = Offset(this.center.x, prevPageTurnIndicatorOffsetPx),
radius = topCircleRadius
)
drawCircle(
Blue300,
center = Offset(this.center.x, this.size.height-nextPageTurnIndicatorOffsetPx),
radius = bottomCircleRadius
)
}
val isOverscrollOverHeight = overscroll?.let { abs(it) >= pageTurnIndicatorHeight } == true
LaunchedEffect(isOverscrollOverHeight) {
if (isOverscrollOverHeight) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
Box(
Modifier
.fillMaxHeight()
.onGloballyPositioned {
size = it.size.toSize()
}
) {
overscroll?.let { overscroll ->
if (overscroll > 0f && prevPage != null) {
Row(
modifier = Modifier
.align(Alignment.TopCenter)
.offset(0.dp, prevPageTurnIndicatorOffset),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(48.dp)
)
Text(stringResource(R.string.move_to_page, prevPage))
}
}
if (overscroll < 0f && nextPage != null) {
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.offset(0.dp, -nextPageTurnIndicatorOffset),
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.move_to_page, nextPage))
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(48.dp)
)
}
}
}
Box(
modifier = Modifier
.offset(
0.dp,
overscroll
?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
?: 0.dp)
.nestedScroll(object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
val overscrollSnapshot = overscroll
return if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
Offset.Zero
} else {
val newOverscroll =
if (overscrollSnapshot > 0f && available.y < 0f)
max(overscrollSnapshot + available.y, 0f)
else if (overscrollSnapshot < 0f && available.y > 0f)
min(overscrollSnapshot + available.y, 0f)
else
overscrollSnapshot
Offset(0f, newOverscroll - overscrollSnapshot).also {
overscroll = newOverscroll
}
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (
available.y == 0f ||
prevPage == null && available.y > 0f ||
nextPage == null && available.y < 0f
) return Offset.Zero
return overscroll?.let {
overscroll = it + available.y
Offset(0f, available.y)
} ?: Offset.Zero
}
})
.pointerInput(prevPage, nextPage) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
var pointer = down.id
overscroll = 0f
while (true) {
val event = awaitPointerEvent()
val dragEvent =
event.changes.fastFirstOrNull { it.id == pointer }!!
if (dragEvent.changedToUpIgnoreConsumed()) {
val otherDown = event.changes.fastFirstOrNull { it.pressed }
if (otherDown == null) {
if (dragEvent.positionChange() != Offset.Zero) dragEvent.consume()
overscroll?.let {
if (abs(it) > pageTurnIndicatorHeight) {
if (it > 0 && prevPage != null) onPageTurn(prevPage)
if (it < 0 && nextPage != null) onPageTurn(nextPage)
}
}
overscroll = null
break
} else
pointer = otherDown.id
}
}
}
}
) {
content()
}
}
}

View File

@@ -0,0 +1,793 @@
package xyz.quaver.pupil.ui.composable
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.networking.HitomiHttpClient
import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.networking.Suggestion
import xyz.quaver.pupil.networking.validNamespace
import xyz.quaver.pupil.ui.theme.Blue300
import xyz.quaver.pupil.ui.theme.Blue600
import xyz.quaver.pupil.ui.theme.Gray300
import xyz.quaver.pupil.ui.theme.Pink600
import xyz.quaver.pupil.ui.theme.Red300
import xyz.quaver.pupil.ui.theme.Yellow400
private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) {
is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag)
is SearchQuery.And -> EditableSearchQueryState.And(queries.map { it.toEditableStateInternal() })
is SearchQuery.Or -> EditableSearchQueryState.Or(queries.map { it.toEditableStateInternal() })
is SearchQuery.Not -> EditableSearchQueryState.Not(query.toEditableStateInternal())
}
fun SearchQuery?.toEditableState(): EditableSearchQueryState.Root =
EditableSearchQueryState.Root(this?.toEditableStateInternal())
private fun EditableSearchQueryState.Tag.toSearchQueryInternal(): SearchQuery.Tag? =
if (namespace.value != null || tag.value.isNotBlank()) SearchQuery.Tag(
namespace.value,
tag.value.lowercase().trim()
) else null
private fun EditableSearchQueryState.And.toSearchQueryInternal(): SearchQuery.And? =
queries.mapNotNull { it.toSearchQueryInternal() }
.let { if (it.isNotEmpty()) SearchQuery.And(it) else null }
private fun EditableSearchQueryState.Or.toSearchQueryInternal(): SearchQuery.Or? =
queries.mapNotNull { it.toSearchQueryInternal() }
.let { if (it.isNotEmpty()) SearchQuery.Or(it) else null }
private fun EditableSearchQueryState.Not.toSearchQueryInternal(): SearchQuery.Not? =
query.value?.toSearchQueryInternal()?.let { SearchQuery.Not(it) }
private fun EditableSearchQueryState.toSearchQueryInternal(): SearchQuery? = when (this) {
is EditableSearchQueryState.Tag -> this.toSearchQueryInternal()
is EditableSearchQueryState.And -> this.toSearchQueryInternal()
is EditableSearchQueryState.Or -> this.toSearchQueryInternal()
is EditableSearchQueryState.Not -> this.toSearchQueryInternal()
}
fun EditableSearchQueryState.Root.toSearchQuery(): SearchQuery? =
query.value?.toSearchQueryInternal()
fun coalesceTags(
oldTag: EditableSearchQueryState.Tag?,
newTag: EditableSearchQueryState?,
): EditableSearchQueryState? = if (oldTag != null) {
when (newTag) {
is EditableSearchQueryState.Tag,
is EditableSearchQueryState.Not,
-> EditableSearchQueryState.And(listOf(oldTag, newTag))
is EditableSearchQueryState.And -> newTag.apply { queries.add(oldTag) }
is EditableSearchQueryState.Or -> newTag.apply { queries.add(oldTag) }
null -> oldTag
}
} else newTag
sealed interface EditableSearchQueryState {
class Tag(
namespace: String? = null,
tag: String = "",
expanded: Boolean = false,
) : EditableSearchQueryState {
val namespace = mutableStateOf(namespace)
val tag = mutableStateOf(tag)
val expanded = mutableStateOf(expanded)
}
class And(
queries: List<EditableSearchQueryState> = emptyList(),
) : EditableSearchQueryState {
val queries = queries.toMutableStateList()
}
class Or(
queries: List<EditableSearchQueryState> = emptyList(),
) : EditableSearchQueryState {
val queries = queries.toMutableStateList()
}
class Not(
query: EditableSearchQueryState? = null,
) : EditableSearchQueryState {
val query = mutableStateOf(query)
}
class Root(
query: EditableSearchQueryState? = null,
) {
val query = mutableStateOf(query)
}
}
@Composable
fun TagSuggestionList(
state: EditableSearchQueryState.Tag,
) {
var suggestionList: List<Suggestion>? by remember { mutableStateOf(null) }
var namespace by state.namespace
var tag by state.tag
var expanded by state.expanded
LaunchedEffect(namespace, tag) {
suggestionList = null
val searchQuery = state.toSearchQueryInternal()
suggestionList = if (searchQuery != null) {
HitomiHttpClient.getSuggestionsForQuery(searchQuery)
.getOrDefault(emptyList())
.filterNot { it.tag == SearchQuery.Tag(namespace, tag) }
} else {
emptyList()
}
}
val suggestionListSnapshot = suggestionList
if (suggestionListSnapshot == null) {
Row(
Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(Modifier.size(24.dp))
Text("Loading")
}
} else if (suggestionListSnapshot.isNotEmpty()) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
suggestionListSnapshot.forEach { suggestion ->
TagChip(
tag = suggestion.tag,
onClick = {
namespace = it.namespace
tag = it.tag
expanded = false
}
)
}
}
}
}
@Composable
fun EditableTagChip(
state: EditableSearchQueryState.Tag,
isFavorite: Boolean = false,
autoFocus: Boolean = true,
requestScrollTo: (Float) -> Unit,
leftIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) },
rightIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) },
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag ->
Text(
modifier = Modifier
.weight(1f, fill = false)
.horizontalScroll(rememberScrollState()),
text = tag.tag.ifBlank { stringResource(R.string.search_bar_edit_tag) }
)
},
) {
val coroutineScope = rememberCoroutineScope()
var namespace by state.namespace
var tag by state.tag
var expanded by state.expanded
var wasFocused by remember { mutableStateOf(false) }
var positionY by remember { mutableFloatStateOf(0f) }
LaunchedEffect(expanded) {
if (!expanded) {
wasFocused = false
}
}
val surfaceColor by animateColorAsState(
when {
expanded -> MaterialTheme.colorScheme.surface
isFavorite -> Yellow400
namespace == "male" -> Blue600
namespace == "female" -> Pink600
else -> MaterialTheme.colorScheme.surface
}, label = "tag surface color"
)
val contentColor by animateColorAsState(
when {
expanded -> Color.White
isFavorite -> Color.White
namespace == "male" -> Color.White
namespace == "female" -> Color.White
else -> MaterialTheme.colorScheme.onSurface
}, label = "tag content color"
)
Surface(
modifier = Modifier.onGloballyPositioned {
positionY = it.positionInRoot().y
},
shape = RoundedCornerShape(16.dp),
color = surfaceColor,
shadowElevation = 4.dp
) {
AnimatedContent(targetState = expanded, label = "open tag editor") { targetExpanded ->
if (!targetExpanded) {
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalTextStyle provides MaterialTheme.typography.bodyMedium
) {
val queryTag = SearchQuery.Tag(namespace, tag)
Row(
modifier = Modifier
.height(32.dp)
.clickable { expanded = true },
verticalAlignment = Alignment.CenterVertically
) {
leftIcon(queryTag)
content(queryTag)
rightIcon(queryTag)
}
}
} else {
Column(
Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 8.dp, end = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
expanded = false
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "close tag editor"
)
}
var selection by remember(tag) { mutableStateOf(TextRange(tag.length)) }
var composition by remember { mutableStateOf<TextRange?>(null) }
val focusRequester = remember { FocusRequester() }
val textFieldValue = remember(tag, selection, composition) {
TextFieldValue(tag, selection, composition)
}
LaunchedEffect(expanded) {
if (autoFocus && expanded) {
focusRequester.requestFocus()
}
}
OutlinedTextField(
value = textFieldValue,
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrectEnabled = false,
imeAction = ImeAction.Done
),
leadingIcon = {
TagChipIcon(SearchQuery.Tag(namespace, tag))
},
modifier = Modifier
.fillMaxWidth()
.onKeyEvent { event ->
if (event.key == Key.Backspace && tag.isEmpty()) {
val newTag = namespace?.dropLast(1) ?: ""
namespace = null
tag = newTag
selection = TextRange(newTag.length)
composition = null
true
} else false
}
.focusRequester(focusRequester)
.onFocusChanged { event ->
if (event.isFocused) {
wasFocused = true
coroutineScope.launch {
delay(300)
requestScrollTo(positionY)
}
} else if (wasFocused) {
expanded = false
}
},
keyboardActions = KeyboardActions(
onDone = {
expanded = false
}
),
onValueChange = { newTextValue ->
val newTag = newTextValue.text
val possibleNamespace = newTag.dropLast(1).lowercase().trim()
tag =
if (namespace == null && newTag.endsWith(':') && possibleNamespace in validNamespace) {
namespace = possibleNamespace
""
} else newTag
selection = newTextValue.selection
composition = newTextValue.composition
}
)
}
TagSuggestionList(state)
}
}
}
}
}
@Composable
fun NewQueryChip(
currentQuery: EditableSearchQueryState?,
onNewQuery: (EditableSearchQueryState) -> Unit,
) {
var opened by remember { mutableStateOf(false) }
@Composable
fun NewQueryRow(
modifier: Modifier = Modifier,
icon: ImageVector = Icons.Default.AddCircleOutline,
text: String,
onClick: () -> Unit,
) {
Row(
modifier = modifier
.height(32.dp)
.clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.padding(8.dp)
.size(16.dp),
imageVector = icon,
contentDescription = text
)
Text(
modifier = Modifier.padding(end = 16.dp),
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
Surface(shape = RoundedCornerShape(16.dp), shadowElevation = 4.dp) {
AnimatedContent(targetState = opened, label = "add new query") { targetOpened ->
if (targetOpened) {
Column {
NewQueryRow(
modifier = Modifier.fillMaxWidth(),
icon = Icons.Default.RemoveCircleOutline,
text = stringResource(android.R.string.cancel)
) {
opened = false
}
HorizontalDivider()
if (currentQuery != null && currentQuery !is EditableSearchQueryState.Tag && currentQuery !is EditableSearchQueryState.And) {
NewQueryRow(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.search_add_query_item_tag)
) {
opened = false
onNewQuery(EditableSearchQueryState.Tag(expanded = true))
}
}
if (currentQuery !is EditableSearchQueryState.And) {
HorizontalDivider()
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "AND") {
opened = false
onNewQuery(EditableSearchQueryState.And())
}
}
if (currentQuery !is EditableSearchQueryState.Or) {
HorizontalDivider()
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "OR") {
opened = false
onNewQuery(EditableSearchQueryState.Or())
}
}
if (currentQuery !is EditableSearchQueryState.Not || currentQuery.query.value != null) {
HorizontalDivider()
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "NOT") {
opened = false
onNewQuery(EditableSearchQueryState.Not())
}
}
}
} else {
NewQueryRow(text = stringResource(R.string.search_add_query_item)) {
opened = true
}
}
}
}
}
@Composable
fun QueryEditorQueryView(
state: EditableSearchQueryState,
onQueryRemove: (EditableSearchQueryState) -> Unit,
requestScrollTo: (Float) -> Unit,
requestScrollBy: (Float) -> Unit,
) {
when (state) {
is EditableSearchQueryState.Tag -> {
EditableTagChip(
state,
requestScrollTo = requestScrollTo,
rightIcon = {
Icon(
modifier = Modifier
.padding(8.dp)
.size(16.dp)
.clickable {
onQueryRemove(state)
},
imageVector = Icons.Default.RemoveCircleOutline,
contentDescription = stringResource(R.string.search_remove_query_item_description)
)
}
)
}
is EditableSearchQueryState.Or -> {
Card(
colors = CardColors(
containerColor = Blue300,
contentColor = Color.Black,
disabledContainerColor = Blue300,
disabledContentColor = Color.Black
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"OR",
modifier = Modifier.padding(horizontal = 8.dp),
style = MaterialTheme.typography.labelMedium
)
Icon(
modifier = Modifier
.size(16.dp)
.clickable { onQueryRemove(state) },
imageVector = Icons.Default.RemoveCircleOutline,
contentDescription = stringResource(R.string.search_remove_query_item_description)
)
}
state.queries.forEachIndexed { index, subQueryState ->
if (index != 0) {
Text("+", modifier = Modifier.padding(horizontal = 8.dp))
}
QueryEditorQueryView(
subQueryState,
onQueryRemove = { state.queries.remove(it) },
requestScrollTo = requestScrollTo,
requestScrollBy = requestScrollBy
)
}
NewQueryChip(state) { newQueryState ->
state.queries.add(newQueryState)
}
}
}
}
is EditableSearchQueryState.And -> {
Card(
colors = CardColors(
containerColor = Gray300,
contentColor = Color.Black,
disabledContainerColor = Gray300,
disabledContentColor = Color.Black
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start,
) {
val newSearchQuery = remember { EditableSearchQueryState.Tag() }
var newQueryNamespace by newSearchQuery.namespace
var newQueryTag by newSearchQuery.tag
var newQueryExpanded by newSearchQuery.expanded
val offset = with(LocalDensity.current) { 40.dp.toPx() }
LaunchedEffect(newQueryExpanded) {
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
state.queries.add(
EditableSearchQueryState.Tag(
newQueryNamespace,
newQueryTag
)
)
newQueryNamespace = null
newQueryTag = ""
newQueryExpanded = true
requestScrollBy(offset)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"AND",
modifier = Modifier.padding(horizontal = 8.dp),
style = MaterialTheme.typography.labelMedium
)
Icon(
modifier = Modifier
.size(16.dp)
.clickable { onQueryRemove(state) },
imageVector = Icons.Default.RemoveCircleOutline,
contentDescription = stringResource(R.string.search_remove_query_item_description)
)
}
state.queries.forEach { subQuery ->
QueryEditorQueryView(
subQuery,
onQueryRemove = { state.queries.remove(it) },
requestScrollTo = requestScrollTo,
requestScrollBy = requestScrollBy
)
}
EditableTagChip(
newSearchQuery,
requestScrollTo = requestScrollTo,
)
NewQueryChip(state) { newQueryState ->
state.queries.add(newQueryState)
}
}
}
}
is EditableSearchQueryState.Not -> {
var subQueryState by state.query
Card(
colors = CardColors(
containerColor = Red300,
contentColor = Color.Black,
disabledContainerColor = Red300,
disabledContentColor = Color.Black
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"-",
modifier = Modifier.padding(horizontal = 8.dp),
style = MaterialTheme.typography.labelMedium
)
Icon(
modifier = Modifier
.size(16.dp)
.clickable { onQueryRemove(state) },
imageVector = Icons.Default.RemoveCircleOutline,
contentDescription = stringResource(R.string.search_remove_query_item_description)
)
}
val subQueryStateSnapshot = subQueryState
if (subQueryStateSnapshot != null) {
QueryEditorQueryView(
subQueryStateSnapshot,
onQueryRemove = { subQueryState = null },
requestScrollTo = requestScrollTo,
requestScrollBy = requestScrollBy,
)
}
if (subQueryStateSnapshot == null) {
NewQueryChip(state) { newQueryState ->
subQueryState = newQueryState
}
}
if (subQueryStateSnapshot is EditableSearchQueryState.Tag) {
NewQueryChip(state) { newQueryState ->
subQueryState = coalesceTags(subQueryStateSnapshot, newQueryState)
}
}
}
}
}
}
}
@Composable
fun QueryEditor(
state: EditableSearchQueryState.Root,
) {
var rootQuery by state.query
val scrollState = rememberScrollState()
var topY by remember { mutableFloatStateOf(0f) }
val scrollOffset = with(LocalDensity.current) { 16.dp.toPx() }
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier
.onGloballyPositioned {
topY = it.positionInRoot().y
}
.verticalScroll(scrollState)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
val rootQuerySnapshot = rootQuery
val requestScrollTo: (Float) -> Unit = { target ->
val topYSnapshot = topY
coroutineScope.launch {
scrollState.animateScrollBy(
target - topYSnapshot - scrollOffset,
spring(stiffness = Spring.StiffnessLow)
)
}
}
val requestScrollBy: (Float) -> Unit = { value ->
coroutineScope.launch {
scrollState.animateScrollBy(value)
}
}
if (rootQuerySnapshot != null) {
QueryEditorQueryView(
state = rootQuerySnapshot,
onQueryRemove = { rootQuery = null },
requestScrollTo = requestScrollTo,
requestScrollBy = requestScrollBy
)
}
if (rootQuerySnapshot is EditableSearchQueryState.Tag?) {
val newSearchQuery = remember { EditableSearchQueryState.Tag(expanded = true) }
var newQueryNamespace by newSearchQuery.namespace
var newQueryTag by newSearchQuery.tag
var newQueryExpanded by newSearchQuery.expanded
val offset = with(LocalDensity.current) { 40.dp.toPx() }
LaunchedEffect(newQueryExpanded) {
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
rootQuery = if (rootQuerySnapshot == null) {
EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)
} else {
EditableSearchQueryState.And(
listOf(
rootQuerySnapshot,
EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)
)
)
}
newQueryNamespace = null
newQueryTag = ""
newQueryExpanded = true
requestScrollBy(offset)
}
}
EditableTagChip(
newSearchQuery,
requestScrollTo = requestScrollTo
)
NewQueryChip(rootQuerySnapshot) { newState ->
rootQuery = coalesceTags(rootQuerySnapshot, newState)
}
}
}
}

View File

@@ -0,0 +1,703 @@
package xyz.quaver.pupil.ui.composable
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.Female
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.Male
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.window.layout.DisplayFeature
import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy
import com.google.accompanist.adaptive.TwoPane
import xyz.quaver.pupil.R
import xyz.quaver.pupil.networking.GalleryInfo
import xyz.quaver.pupil.networking.HitomiHttpClient
import xyz.quaver.pupil.networking.SearchQuery
import xyz.quaver.pupil.ui.theme.Blue600
import xyz.quaver.pupil.ui.theme.Pink600
import xyz.quaver.pupil.ui.theme.Yellow400
import xyz.quaver.pupil.ui.viewmodel.SearchState
import kotlin.math.roundToInt
private val iconMap = mapOf(
"female" to Icons.Default.Female,
"male" to Icons.Default.Male,
"artist" to Icons.Default.Brush,
"group" to Icons.Default.Group,
"character" to Icons.Default.Face,
"series" to Icons.Default.Book,
"type" to Icons.Default.Folder,
"language" to Icons.Default.Translate,
"tag" to Icons.AutoMirrored.Filled.Label,
)
val languageIconMap = mapOf(
"indonesian" to R.drawable.language_indonesian,
"javanese" to R.drawable.language_javanese,
"catalan" to R.drawable.language_catalan,
"cebuano" to R.drawable.language_philippines,
"czech" to R.drawable.language_czech,
"danish" to R.drawable.language_danish,
"german" to R.drawable.language_german,
"estonian" to R.drawable.language_estonian,
"english" to R.drawable.language_english,
"spanish" to R.drawable.language_spanish,
"french" to R.drawable.language_french,
"italian" to R.drawable.language_italian,
"latin" to R.drawable.language_latin,
"hungarian" to R.drawable.language_hungarian,
"dutch" to R.drawable.language_dutch,
"norwegian" to R.drawable.language_norwegian,
"polish" to R.drawable.language_polish,
"portuguese" to R.drawable.language_portuguese,
"romanian" to R.drawable.language_romanian,
"albanian" to R.drawable.language_albanian,
"slovak" to R.drawable.language_slovak,
"finnish" to R.drawable.language_finnish,
"swedish" to R.drawable.language_swedish,
"tagalog" to R.drawable.language_philippines,
"vietnamese" to R.drawable.language_vietnamese,
"turkish" to R.drawable.language_turkish,
"greek" to R.drawable.language_greek,
"mongolian" to R.drawable.language_mongolian,
"russian" to R.drawable.language_russian,
"ukrainian" to R.drawable.language_ukrainian,
"hebrew" to R.drawable.language_hebrew,
"persian" to R.drawable.language_persian,
"thai" to R.drawable.language_thai,
"korean" to R.drawable.language_korean,
"chinese" to R.drawable.language_chinese,
"japanese" to R.drawable.language_japanese,
)
@Composable
fun TagChipIcon(tag: SearchQuery.Tag) {
val icon = iconMap[tag.namespace]
if (icon != null) {
if (tag.namespace == "language" && languageIconMap.contains(tag.tag)) {
Icon(
painter = painterResource(languageIconMap[tag.tag]!!),
contentDescription = "icon",
modifier = Modifier
.padding(4.dp)
.size(24.dp),
tint = Color.Unspecified
)
} else {
Icon(
icon,
contentDescription = "icon",
modifier = Modifier
.padding(4.dp)
.size(24.dp)
)
}
} else {
Spacer(Modifier.width(16.dp))
}
}
@Composable
fun TagChip(
tag: SearchQuery.Tag,
isFavorite: Boolean = false,
enabled: Boolean = true,
onClick: (SearchQuery.Tag) -> Unit = { },
leftIcon: @Composable (SearchQuery.Tag) -> Unit = { TagChipIcon(it) },
rightIcon: @Composable (SearchQuery.Tag) -> Unit = { Spacer(Modifier.width(16.dp)) },
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = {
Text(
it.tag,
modifier = Modifier
.weight(1f, fill = false)
.horizontalScroll(rememberScrollState())
)
},
) {
val surfaceColor = if (isFavorite) Yellow400 else when (tag.namespace) {
"male" -> Blue600
"female" -> Pink600
else -> MaterialTheme.colorScheme.surface
}
val contentColor =
if (surfaceColor == MaterialTheme.colorScheme.surface)
MaterialTheme.colorScheme.onSurface
else
Color.White
val inner = @Composable {
CompositionLocalProvider(
LocalContentColor provides contentColor,
LocalTextStyle provides MaterialTheme.typography.bodyMedium
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
leftIcon(tag)
content(tag)
rightIcon(tag)
}
}
}
val modifier = Modifier.height(32.dp)
val shape = RoundedCornerShape(16.dp)
if (enabled)
Surface(
modifier = modifier,
shape = shape,
color = surfaceColor,
onClick = { onClick(tag) },
content = inner,
shadowElevation = 4.dp
)
else
Surface(
modifier,
shape = shape,
color = surfaceColor,
content = inner,
shadowElevation = 4.dp
)
}
@Composable
fun QueryView(
query: SearchQuery?,
topLevel: Boolean = true,
) {
val modifier = if (topLevel) {
Modifier
} else {
Modifier.border(
width = 0.5.dp,
color = LocalContentColor.current,
shape = CardDefaults.shape
)
}
when (query) {
null -> {
Text(
modifier = Modifier
.height(60.dp)
.wrapContentHeight()
.padding(horizontal = 16.dp),
text = stringResource(id = R.string.search_hint),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
is SearchQuery.Tag -> {
TagChip(
query,
enabled = false
)
}
is SearchQuery.Or -> {
Row(
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
query.queries.forEachIndexed { index, subQuery ->
if (index != 0) {
Text("+")
}
QueryView(subQuery, topLevel = false)
}
}
}
is SearchQuery.And -> {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
query.queries.forEach { subQuery ->
QueryView(subQuery, topLevel = false)
}
}
}
is SearchQuery.Not -> {
Row(
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("-")
QueryView(query.query, topLevel = false)
}
}
}
}
@Composable
fun SearchBar(
contentType: ContentType,
query: SearchQuery?,
onQueryChange: (SearchQuery?) -> Unit,
onSearchBarPositioned: (Int) -> Unit,
topOffset: Int,
onTopOffsetChange: (Int) -> Unit,
content: @Composable () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
val scrimAlpha: Float by animateFloatAsState(
if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f,
label = "scrim alpha"
)
val interactionSource = remember { MutableInteractionSource() }
val state = remember(query) { query.toEditableState() }
LaunchedEffect(focused) {
if (!focused) {
onQueryChange(state.toSearchQuery())
} else {
AnimationState(Int.VectorConverter, topOffset).animateTo(0) { onTopOffsetChange(value) }
}
}
if (focused) {
BackHandler {
focused = false
}
}
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
focused = false
}
) {
val height: Dp by animateDpAsState(
if (focused) maxHeight else 60.dp,
label = "searchbar height"
)
val cardShape = RoundedCornerShape(30.dp)
content()
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = scrimAlpha))
)
Card(
modifier = Modifier
.safeDrawingPadding()
.padding(16.dp)
.fillMaxWidth()
.height(height)
.clickable(
interactionSource = interactionSource,
indication = null
) {
focused = true
}
.onGloballyPositioned {
onSearchBarPositioned(it.positionInRoot().y.roundToInt() + it.size.height)
}
.absoluteOffset { IntOffset(0, topOffset) },
shape = cardShape,
elevation = CardDefaults.cardElevation(6.dp)
) {
Box {
androidx.compose.animation.AnimatedVisibility(
!focused,
enter = fadeIn(),
exit = fadeOut()
) {
Row(
modifier = Modifier
.heightIn(min = 60.dp)
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.size(8.dp))
QueryView(query)
Box(Modifier.size(8.dp))
}
}
androidx.compose.animation.AnimatedVisibility(
focused,
enter = fadeIn(),
exit = fadeOut()
) {
Column(
Modifier
.fillMaxSize()
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
) {
IconButton(
onClick = {
focused = false
}
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "close search bar"
)
}
QueryEditor(state = state)
}
}
}
}
}
}
@Composable
fun GalleryList(
contentType: ContentType,
galleries: List<GalleryInfo>,
query: SearchQuery?,
currentPage: Int,
maxPage: Int,
loading: Boolean = false,
error: Boolean = false,
onPageChange: (Int) -> Unit,
onQueryChange: (SearchQuery?) -> Unit = {},
openGalleryDetails: (GalleryInfo) -> Unit,
) {
val listState = rememberLazyListState()
var topOffset by remember { mutableIntStateOf(0) }
var searchBarPosition by remember { mutableIntStateOf(0) }
val listModifier = Modifier.nestedScroll(object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
topOffset = (topOffset + available.y.roundToInt()).coerceIn(-searchBarPosition, 0)
return Offset.Zero
}
})
LaunchedEffect(galleries) {
listState.animateScrollToItem(0)
topOffset = 0
}
SearchBar(
contentType = contentType,
query = query,
onQueryChange = onQueryChange,
onSearchBarPositioned = { searchBarPosition = it },
topOffset = topOffset,
onTopOffsetChange = { topOffset = it },
) {
AnimatedVisibility(loading, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize()) {
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
}
AnimatedVisibility(error, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize()) {
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"(´∇`)",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
)
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
}
}
}
AnimatedVisibility(!loading && !error, enter = fadeIn(), exit = fadeOut()) {
OverscrollPager(
prevPage = if (currentPage != 0) currentPage else null,
nextPage = if (currentPage < maxPage) currentPage + 2 else null,
onPageTurn = { onPageChange(it - 1) }
) {
LazyColumn(
modifier = listModifier,
contentPadding = WindowInsets.systemBars.asPaddingValues()
.let { systemBarPaddingValues ->
val layoutDirection = LocalLayoutDirection.current
PaddingValues(
top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
bottom = systemBarPaddingValues.calculateBottomPadding(),
start = systemBarPaddingValues.calculateStartPadding(layoutDirection),
end = systemBarPaddingValues.calculateEndPadding(layoutDirection),
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
state = listState
) {
items(galleries, key = { it.id }) { galleryInfo ->
DetailedGalleryInfo(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.clickable { openGalleryDetails(galleryInfo) },
galleryInfo = galleryInfo
)
}
}
}
}
}
}
@Composable
fun DetailScreen(
galleryInfo: GalleryInfo,
closeGalleryDetails: () -> Unit = { },
openGallery: (GalleryInfo) -> Unit = { },
) {
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(galleryInfo) {
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
HitomiHttpClient.getImageURL(it, true).firstOrNull()
} ?: ""
}
Column(
Modifier
.padding(horizontal = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
IconButton(onClick = closeGalleryDetails) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Detail")
}
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
Row(Modifier.fillMaxWidth()) {
FilledTonalButton(
modifier = Modifier
.weight(1f)
.padding(horizontal = 4.dp),
onClick = { /*TODO*/ }
) {
Text(stringResource(R.string.download))
}
Button(
modifier = Modifier
.weight(1f)
.padding(horizontal = 4.dp),
onClick = { openGallery(galleryInfo) }
) {
Text("Open")
}
}
GalleryTypeIndicator(galleryInfo.type)
if (galleryInfo.series?.isNotEmpty() == true) {
TagGroup(galleryInfo.series.map { it.toTag() })
}
if (galleryInfo.characters?.isNotEmpty() == true) {
TagGroup(galleryInfo.characters.map { it.toTag() })
}
if (galleryInfo.tags?.isNotEmpty() == true) {
TagGroup(galleryInfo.tags.map { it.toTag() })
}
}
}
@Composable
fun SearchScreen(
contentType: ContentType,
displayFeatures: List<DisplayFeature>,
uiState: SearchState,
openGalleryDetails: (GalleryInfo) -> Unit,
closeGalleryDetails: () -> Unit,
onQueryChange: (SearchQuery?) -> Unit,
loadSearchResult: (IntRange) -> Unit,
openGallery: (GalleryInfo) -> Unit,
) {
val itemsPerPage by remember { mutableIntStateOf(20) }
val pageToRange: (Int) -> IntRange = remember(itemsPerPage) {
{ page ->
page * itemsPerPage..<(page + 1) * itemsPerPage
}
}
val currentPage = remember(uiState) {
if (uiState.currentRange != IntRange.EMPTY) {
uiState.currentRange.first / itemsPerPage
} else 0
}
val maxPage = remember(itemsPerPage, uiState) {
if (uiState.galleryCount != null) {
uiState.galleryCount / itemsPerPage + if (uiState.galleryCount % itemsPerPage != 0) 1 else 0
} else 0
}
val loadResult: (Int) -> Unit = remember(loadSearchResult) {
{ page ->
loadSearchResult(pageToRange(page))
}
}
LaunchedEffect(uiState.query) { loadSearchResult(pageToRange(currentPage)) }
LaunchedEffect(contentType) {
if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) {
closeGalleryDetails()
}
}
if (contentType == ContentType.SINGLE_PANE && uiState.isDetailOnlyOpen) {
BackHandler {
closeGalleryDetails()
}
}
if (contentType == ContentType.DUAL_PANE) {
TwoPane(
first = {
GalleryList(
contentType = contentType,
galleries = uiState.galleries,
query = uiState.query,
currentPage = currentPage,
maxPage = maxPage,
loading = uiState.loading,
error = uiState.error,
onQueryChange = onQueryChange,
onPageChange = loadResult,
openGalleryDetails = openGalleryDetails
)
},
second = {
},
strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f, gapWidth = 16.dp),
displayFeatures = displayFeatures
)
} else {
val detailGallery = uiState.openedGallery
AnimatedVisibility(!uiState.isDetailOnlyOpen || detailGallery == null) {
GalleryList(
contentType = contentType,
galleries = uiState.galleries,
query = uiState.query,
currentPage = currentPage,
maxPage = maxPage,
loading = uiState.loading,
error = uiState.error,
onQueryChange = onQueryChange,
onPageChange = loadResult,
openGalleryDetails = openGalleryDetails
)
}
AnimatedVisibility(uiState.isDetailOnlyOpen && detailGallery != null) {
if (detailGallery != null) {
DetailScreen(
galleryInfo = detailGallery,
closeGalleryDetails = closeGalleryDetails,
openGallery = openGallery
)
}
}
}
}

View File

@@ -1,28 +1,148 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.theme
import androidx.compose.ui.graphics.Color
val LightBlue300 = Color(0xFF4FC3F7)
val LightBlue700 = Color(0xFF0288D1)
val Pink600 = Color(0xFFD81B60)
val Blue700 = Color(0xFF1976D2)
val GreenA700 = Color(0xFF00C853)
val Orange500 = Color(0xFFFF9800)
val md_theme_light_primary = Color(0xFF006688)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFC2E8FF)
val md_theme_light_onPrimaryContainer = Color(0xFF001E2B)
val md_theme_light_secondary = Color(0xFF4E616D)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFD1E5F3)
val md_theme_light_onSecondaryContainer = Color(0xFF091E28)
val md_theme_light_tertiary = Color(0xFF5F5A7D)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFE5DEFF)
val md_theme_light_onTertiaryContainer = Color(0xFF1C1736)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFBFCFE)
val md_theme_light_onBackground = Color(0xFF191C1E)
val md_theme_light_surface = Color(0xFFFBFCFE)
val md_theme_light_onSurface = Color(0xFF191C1E)
val md_theme_light_surfaceVariant = Color(0xFFDCE3E9)
val md_theme_light_onSurfaceVariant = Color(0xFF40484D)
val md_theme_light_outline = Color(0xFF71787D)
val md_theme_light_inverseOnSurface = Color(0xFFF0F1F3)
val md_theme_light_inverseSurface = Color(0xFF2E3133)
val md_theme_light_inversePrimary = Color(0xFF75D1FF)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF006688)
val md_theme_light_outlineVariant = Color(0xFFC0C7CD)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF75D1FF)
val md_theme_dark_onPrimary = Color(0xFF003548)
val md_theme_dark_primaryContainer = Color(0xFF004D67)
val md_theme_dark_onPrimaryContainer = Color(0xFFC2E8FF)
val md_theme_dark_secondary = Color(0xFFB5C9D7)
val md_theme_dark_onSecondary = Color(0xFF20333D)
val md_theme_dark_secondaryContainer = Color(0xFF364954)
val md_theme_dark_onSecondaryContainer = Color(0xFFD1E5F3)
val md_theme_dark_tertiary = Color(0xFFC9C2EA)
val md_theme_dark_onTertiary = Color(0xFF312C4C)
val md_theme_dark_tertiaryContainer = Color(0xFF474364)
val md_theme_dark_onTertiaryContainer = Color(0xFFE5DEFF)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF191C1E)
val md_theme_dark_onBackground = Color(0xFFE1E2E5)
val md_theme_dark_surface = Color(0xFF191C1E)
val md_theme_dark_onSurface = Color(0xFFE1E2E5)
val md_theme_dark_surfaceVariant = Color(0xFF40484D)
val md_theme_dark_onSurfaceVariant = Color(0xFFC0C7CD)
val md_theme_dark_outline = Color(0xFF8A9297)
val md_theme_dark_inverseOnSurface = Color(0xFF191C1E)
val md_theme_dark_inverseSurface = Color(0xFFE1E2E5)
val md_theme_dark_inversePrimary = Color(0xFF006688)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF75D1FF)
val md_theme_dark_outlineVariant = Color(0xFF40484D)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF4FC3F7)
val Gray50 = Color(0xFFF9FAFB)
val Gray100 = Color(0xFFF3F4F6)
val Gray200 = Color(0xFFE5E7EB)
val Gray300 = Color(0xFFD1D5DB)
val Gray400 = Color(0xFF9CA3AF)
val Gray500 = Color(0xFF6B7280)
val Gray600 = Color(0xFF4B5563)
val Gray700 = Color(0xFF374151)
val Gray800 = Color(0xFF1F2937)
val Gray900 = Color(0xFF111827)
val Red50 = Color(0xFFFEF2F2)
val Red100 = Color(0xFFFEE2E2)
val Red200 = Color(0xFFFECACA)
val Red300 = Color(0xFFFCA5A5)
val Red400 = Color(0xFFF87171)
val Red500 = Color(0xFFEF4444)
val Red600 = Color(0xFFDC2626)
val Red700 = Color(0xFFB91C1C)
val Red800 = Color(0xFF991B1B)
val Red900 = Color(0xFF7F1D1D)
val Yellow50 = Color(0xFFFFFBEB)
val Yellow100 = Color(0xFFFEF3C7)
val Yellow200 = Color(0xFFFDE68A)
val Yellow300 = Color(0xFFFCD34D)
val Yellow400 = Color(0xFFFBBF24)
val Yellow500 = Color(0xFFF59E0B)
val Yellow600 = Color(0xFFD97706)
val Yellow700 = Color(0xFFB45309)
val Yellow800 = Color(0xFF92400E)
val Yellow900 = Color(0xFF78350F)
val Green50 = Color(0xFFECFDF5)
val Green100 = Color(0xFFD1FAE5)
val Green200 = Color(0xFFA7F3D0)
val Green300 = Color(0xFF6EE7B7)
val Green400 = Color(0xFF34D399)
val Green500 = Color(0xFF10B981)
val Green600 = Color(0xFF059669)
val Green700 = Color(0xFF047857)
val Green800 = Color(0xFF065F46)
val Green900 = Color(0xFF064E3B)
val Blue50 = Color(0xFFEFF6FF)
val Blue100 = Color(0xFFDBEAFE)
val Blue200 = Color(0xFFBFDBFE)
val Blue300 = Color(0xFF93C5FD)
val Blue400 = Color(0xFF60A5FA)
val Blue500 = Color(0xFF3B82F6)
val Blue600 = Color(0xFF2563EB)
val Blue700 = Color(0xFF1D4ED8)
val Blue800 = Color(0xFF1E40AF)
val Blue900 = Color(0xFF1E3A8A)
val Indigo50 = Color(0xFFEEF2FF)
val Indigo100 = Color(0xFFE0E7FF)
val Indigo200 = Color(0xFFC7D2FE)
val Indigo300 = Color(0xFFA5B4FC)
val Indigo400 = Color(0xFF818CF8)
val Indigo500 = Color(0xFF6366F1)
val Indigo600 = Color(0xFF4F46E5)
val Indigo700 = Color(0xFF4338CA)
val Indigo800 = Color(0xFF3730A3)
val Indigo900 = Color(0xFF312E81)
val Purple50 = Color(0xFFF5F3FF)
val Purple100 = Color(0xFFEDE9FE)
val Purple200 = Color(0xFFDDD6FE)
val Purple300 = Color(0xFFC4B5FD)
val Purple400 = Color(0xFFA78BFA)
val Purple500 = Color(0xFF8B5CF6)
val Purple600 = Color(0xFF7C3AED)
val Purple700 = Color(0xFF6D28D9)
val Purple800 = Color(0xFF5B21B6)
val Purple900 = Color(0xFF4C1D95)
val Pink50 = Color(0xFFFDF2F8)
val Pink100 = Color(0xFFFCE7F3)
val Pink200 = Color(0xFFFBCFE8)
val Pink300 = Color(0xFFF9A8D4)
val Pink400 = Color(0xFFF472B6)
val Pink500 = Color(0xFFEC4899)
val Pink600 = Color(0xFFDB2777)
val Pink700 = Color(0xFFBE185D)
val Pink800 = Color(0xFF9D174D)
val Pink900 = Color(0xFF831843)

View File

@@ -1,29 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)

View File

@@ -1,57 +1,90 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.contentColorFor
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorPalette = darkColors(
primary = LightBlue300,
primaryVariant = LightBlue700,
secondary = Pink600,
onPrimary = Color.White,
onSecondary = Color.White
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val LightColorPalette = lightColors(
primary = LightBlue300,
primaryVariant = LightBlue700,
secondary = Pink600,
onPrimary = Color.White,
onSecondary = Color.White
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable
fun PupilTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
fun AppTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable() () -> Unit
) {
val colors = if (darkTheme) DarkColorPalette else LightColorPalette
val colors = if (!useDarkTheme) {
LightColors
} else {
DarkColors
}
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
colorScheme = colors,
content = content
)
}

View File

@@ -1,33 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
)
)

View File

@@ -0,0 +1,86 @@
package xyz.quaver.pupil.ui.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import xyz.quaver.pupil.networking.GalleryInfo
import xyz.quaver.pupil.networking.GallerySearchSource
import xyz.quaver.pupil.networking.SearchQuery
import kotlin.math.max
import kotlin.math.min
class MainViewModel : ViewModel() {
private val _uiState = MutableStateFlow(SearchState())
val searchState: StateFlow<SearchState> = _uiState
private var searchSource: GallerySearchSource = GallerySearchSource(null)
private var job: Job? = null
fun openGalleryDetails(galleryInfo: GalleryInfo) {
_uiState.value = _uiState.value.copy(
openedGallery = galleryInfo,
isDetailOnlyOpen = true
)
}
fun closeGalleryDetails() {
_uiState.value = _uiState.value.copy(
isDetailOnlyOpen = false
)
}
fun onQueryChange(query: SearchQuery?) {
_uiState.value = _uiState.value.copy(
query = query,
galleryCount = null,
currentRange = IntRange.EMPTY
)
searchSource = GallerySearchSource(query)
}
fun loadSearchResult(range: IntRange) {
Thread.dumpStack()
job?.cancel()
job = viewModelScope.launch {
val sanitizedRange = max(range.first, 0) .. min(range.last, searchState.value.galleryCount ?: Int.MAX_VALUE)
_uiState.value = _uiState.value.copy(
loading = true,
error = false,
currentRange = sanitizedRange
)
var error = false
val (galleries, galleryCount) = searchSource.load(range).getOrElse {
error = true
it.printStackTrace()
emptyList<GalleryInfo>() to 0
}
_uiState.value = _uiState.value.copy(
galleries = galleries,
galleryCount = galleryCount,
error = error,
loading = false
)
}
}
fun navigateToDetail() {
}
}
data class SearchState(
val query: SearchQuery? = null,
val galleries: List<GalleryInfo> = emptyList(),
val loading: Boolean = false,
val error: Boolean = false,
val galleryCount: Int? = null,
val currentRange: IntRange = IntRange.EMPTY,
val openedGallery: GalleryInfo? = null,
val isDetailOnlyOpen: Boolean = false
)

View File

@@ -1,156 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2022 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import androidx.compose.ui.res.stringArrayResource
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.core.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import java.io.File
import java.util.*
@Serializable
data class RemoteSourceInfo(
val projectName: String,
val name: String,
val version: String
)
class Release(
val version: String,
val apkUrl: String,
val releaseNotes: Map<Locale, String>
)
private val localeMap = mapOf(
"한국어" to Locale.KOREAN,
"日本語" to Locale.JAPANESE,
"English" to Locale.ENGLISH
)
class PupilHttpClient(engine: HttpClientEngine) {
private val httpClient = HttpClient(engine) {
install(ContentNegotiation) {
json()
}
}
/**
* Fetch a list of available sources from PupilSources repository.
* Returns empty map when exception occurs
*/
suspend fun getRemoteSourceList(): Map<String, RemoteSourceInfo> = withContext(Dispatchers.IO) {
runCatching {
httpClient.get("https://tom5079.github.io/PupilSources/versions.json").body<Map<String, RemoteSourceInfo>>()
}.getOrDefault(emptyMap())
}
/**
* Downloads specific file from :url to :dest.
* Returns flow that emits progress.
* when value emitted by flow {
* in 0f .. 1f -> downloading
* POSITIVE_INFINITY -> download finised
* NEGATIVE_INFINITY -> exception occured
* }
*/
fun downloadFile(url: String, dest: File) = flow {
runCatching {
httpClient.prepareGet(url).execute { response ->
val channel = response.bodyAsChannel()
val contentLength = response.contentLength() ?: -1
var readBytes = 0f
dest.outputStream().use { outputStream ->
while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (!packet.isEmpty) {
val bytes = packet.readBytes()
outputStream.write(bytes)
readBytes += bytes.size
emit(readBytes / contentLength)
}
}
}
}
emit(Float.POSITIVE_INFINITY)
}.onFailure {
emit(Float.NEGATIVE_INFINITY)
}
}.flowOn(Dispatchers.IO)
/**
* Latest application release info from Github API.
* Returns null when exception occurs.
*/
suspend fun latestRelease(beta: Boolean = true): Release? = withContext(Dispatchers.IO) {
runCatching {
val releases = Json.parseToJsonElement(
httpClient.get("https://api.github.com/repos/tom5079/Pupil/releases").bodyAsText()
).jsonArray
val latestRelease = releases.first { release ->
beta || !release.jsonObject["prerelease"]!!.jsonPrimitive.boolean
}.jsonObject
val version = latestRelease["tag_name"]!!.jsonPrimitive.content
val apkUrl = latestRelease["assets"]!!.jsonArray.first { asset ->
val name = asset.jsonObject["name"]!!.jsonPrimitive.content
name.startsWith("Pupil-v") && name.endsWith(".apk")
}.jsonObject["browser_download_url"]!!.jsonPrimitive.content
val releaseNotes: Map<Locale, String> = buildMap {
val body = latestRelease["body"]!!.jsonPrimitive.content
var locale: Locale? = null
val stringBuilder = StringBuilder()
body.lineSequence().forEach { line ->
localeMap[line.drop(3)]?.let { newLocale ->
if (locale != null) {
put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString())
stringBuilder.clear()
}
locale = newLocale
return@forEach
}
if (locale != null) stringBuilder.appendLine(line)
}
put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString())
}
Release(version, apkUrl, releaseNotes)
}.getOrNull()
}
}

View File

@@ -1,38 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2022 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.content.Context
import android.content.Intent
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import java.io.File
fun Context.launchApkInstaller(file: File) {
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_ACTIVITY_NEW_TASK
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
}
startActivity(intent)
}

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="900dp"
android:height="900dp"
android:viewportWidth="900"
android:viewportHeight="900">
<path
android:pathData="M450,450m-450,0a450,450 0,1 1,900 0a450,450 0,1 1,-900 0"
android:fillColor="#4EC1F5"/>
<path
android:pathData="M450,450m-175,0a175,175 0,1 1,350 0a175,175 0,1 1,-350 0"
android:fillColor="#1D1D1D"/>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,4 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24.0" android:viewportHeight="24.0">
<path android:fillColor="#fff" android:pathData="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73 0.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/>
<path android:fillColor="#fff" android:pathData="M8.5 15a1.5 1.5 0 1 1 1.5 1.5A1.5 1.5 0 0 1 8.5 15z"/>
</vector>

View File

@@ -1,3 +0,0 @@
<vector android:height="24dp" android:width="24dp" android:viewportHeight="24.0" android:viewportWidth="24.0" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@color/material_orange_500" android:pathData="M12 15.39l-3.76 2.27 0.99-4.28-3.32-2.88 4.38-0.37L12 6.09l1.71 4.04 4.38 0.37-3.32 2.88 0.99 4.28M22 9.24l-7.19-0.61L12 2 9.19 8.63 2 9.24l5.45 4.73L5.82 21 12 17.27 18.18 21l-1.64-7.03L22 9.24z"/>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v512H0z"
android:fillColor="#d80027"/>
<path
android:pathData="M400.7,190H308a33.3,33.3 0,0 0,-24.2 -56.4,33.3 33.3,0 0,0 -27.8,14.9 33.4,33.4 0,1 0,-52 41.5h-92.7a45.8,45.8 0,0 0,46 44.5h-1.5c0,24.6 20,44.6 44.5,44.6 0,8 2.1,15.4 5.8,21.8l-37,37 28.4,28.3 40.2,-40.2a30.5,30.5 0,0 0,4.9 1.4l-24.3,54.8L256,423l37.7,-40.8 -24.3,-54.8a30.4,30.4 0,0 0,4.9 -1.4l40.2,40.2 28.3,-28.3 -37,-37a44.2,44.2 0,0 0,5.9 -21.8c24.5,0 44.5,-20 44.5,-44.6h-1.5c24.6,0 46,-19.9 46,-44.5z"
android:fillColor="#333"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0v57l32,29 -32,28v57l32,29 -32,28v57l32,28 -32,28v57l32,29 -32,28v57h512v-57l-32,-28 32,-29v-57l-32,-28 32,-28v-57l-32,-28 32,-29v-57l-32,-28 32,-29V0H0z"
android:fillColor="#ffda44"/>
<path
android:pathData="M0,57h512v57L0,114ZM0,171h512v57L0,228ZM0,285h512v56L0,341ZM0,398h512v57L0,455Z"
android:fillColor="#d80027"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v512H0z"
android:fillColor="#d80027"/>
<path
android:pathData="m140.1,155.8 l22.1,68h71.5l-57.8,42.1 22.1,68 -57.9,-42 -57.9,42 22.2,-68 -57.9,-42.1L118,223.8zM303.5,396.5 L286.6,375.7 261.6,385.4 276.1,362.9 259.2,342 285.1,348.9 299.7,326.4 301.1,353.2 327.1,360.1 302,369.7zM337.1,335.5 L345.1,309.9 323.2,294.4 350,294 357.9,268.4 366.6,293.8 393.4,293.5 371.9,309.5 380.5,334.9 358.6,319.4zM382.4,187.9L370.6,212l19.2,18.7 -26.5,-3.8 -11.8,24 -4.6,-26.4 -26.6,-3.8 23.8,-12.5 -4.6,-26.5 19.2,18.7zM304.2,114.9 L302.2,141.6 327.1,151.7 301,158.1 299.1,184.9 285,162.1 258.9,168.5 276.2,148 262,125.3 286.9,135.4z"
android:fillColor="#ffda44"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v256l-265,45.2z"
android:fillColor="#eee"/>
<path
android:pathData="M210,256h302v256H0z"
android:fillColor="#d80027"/>
<path
android:pathData="M0,0v512l256,-256L0,0z"
android:fillColor="#0052b4"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h133.6l32.7,20.3 34,-20.3H512v222.6L491.4,256l20.6,33.4V512H200.3l-31.7,-20.4 -35,20.4H0V289.4l29.4,-33L0,222.7z"
android:fillColor="#d80027"/>
<path
android:pathData="M133.6,0v222.6H0v66.8h133.6V512h66.7V289.4H512v-66.8H200.3V0h-66.7z"
android:fillColor="#eee"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,167 l253.8,-19.3L512,167v178l-254.9,32.3L0,345z"
android:fillColor="#eee"/>
<path
android:pathData="M0,0h512v167H0z"
android:fillColor="#a2001d"/>
<path
android:pathData="M0,345h512v167H0z"
android:fillColor="#0052b4"/>
</group>
</vector>

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M256,0 L0,256v64l32,32 -32,32v128l22,-8 23,8h23l54,-32 54,32h32l48,-32 48,32h32l54,-32 54,32h68l-8,-22 8,-23v-23l-32,-54 32,-54v-32l-32,-48 32,-48v-32l-32,-54 32,-54V0H256z"
android:fillColor="#eee"/>
<path
android:pathData="M224,64v64h160l64,-64zM224,192 L256,256 208,304v208h96L304,304h208v-96L304,208l16,-16zM0,320v64h128l-64,64L0,448v64h45l131,-131v-45l16,-16zM336,336 L512,512v-45L381,336Z"
android:fillColor="#d80027"/>
<path
android:pathData="M0,0v256h256L256,0L0,0zM512,68L404,176h108L512,68zM404,336l108,108L512,336L404,336zM176,404L68,512h108L176,404zM336,404v108h108L336,404z"
android:fillColor="#0052b4"/>
<path
android:pathData="m187,243 l57,-41h-70l57,41 -22,-67zM106,243 L163,202L93,202l57,41 -22,-67zM25,243 L82,202L12,202l57,41 -22,-67zM187,162 L244,121h-70l57,41 -22,-67zM106,162 L163,121L93,121l57,41 -22,-67zM25,162 L82,121L12,121l57,41 -22,-67ZM187,80 L244,39h-70l57,41 -22,-67zM106,80 L163,39L93,39l57,41 -22,-67ZM25,80 L82,39L12,39l57,41 -22,-67Z"
android:fillColor="#eee"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,167 l254.6,-36.6L512,166.9v178l-254.6,36.4L0,344.9z"
android:fillColor="#333"/>
<path
android:pathData="M0,0h512v166.9H0z"
android:fillColor="#0052b4"/>
<path
android:pathData="M0,344.9h512V512H0z"
android:fillColor="#eee"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h133.6l35.3,16.7L200.3,0H512v222.6l-22.6,31.7 22.6,35.1V512H200.3l-32,-19.8 -34.7,19.8H0V289.4l22.1,-33.3L0,222.6z"
android:fillColor="#eee"/>
<path
android:pathData="M133.6,0v222.6H0v66.8h133.6V512h66.7V289.4H512v-66.8H200.3V0h-66.7z"
android:fillColor="#0052b4"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M167,0h178l25.9,252.3L345,512H167l-29.8,-253.4z"
android:fillColor="#eee"/>
<path
android:pathData="M0,0h167v512H0z"
android:fillColor="#0052b4"/>
<path
android:pathData="M345,0h167v512H345z"
android:fillColor="#d80027"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,345 l256.7,-25.5L512,345v167H0z"
android:fillColor="#ffda44"/>
<path
android:pathData="m0,167 l255,-23 257,23v178H0z"
android:fillColor="#d80027"/>
<path
android:pathData="M0,0h512v167H0z"
android:fillColor="#333"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h99l29,32 28,-32h356v57l-32,28 32,29v57l-32,28 32,29v57l-32,28 32,28v57l-32,29 32,28v57H0v-57l32,-28 -32,-29v-56l32,-29 -32,-28V171l32,-29 -32,-28Z"
android:fillColor="#0052b4"/>
<path
android:pathData="M99,0v114L0,114v57h99v114L0,285v57h512v-57L156,285L156,171h100v-57L156,114L156,0ZM256,57v57h256L512,57ZM256,171v57h256v-57ZM0,398v57h512v-57z"
android:fillColor="#eee"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v55.7l-25,32.7 25,34v267.2l-26,36 26,30.7V512H0v-55.7l24.8,-34.1L0,389.6V122.4l27.2,-33.2L0,55.7z"
android:fillColor="#eee"/>
<path
android:pathData="M0,55.7v66.7h512L512,55.7zM0,389.6v66.7h512v-66.7zM352.4,200.3L288,200.3l-32,-55.6 -32.1,55.6h-64.3l32.1,55.7 -32,55.7h64.2l32.1,55.6 32.1,-55.6h64.3L320.3,256l32,-55.7zM295.4,256 L275.7,290.2h-39.4L216.5,256l19.8,-34.2h39.4l19.8,34.2zM256,187.6l7.3,12.7h-14.6zM196.8,221.8h14.7l-7.4,12.7zM196.8,290.2 L204.1,277.5 211.5,290.2zM256,324.4 L248.7,311.7h14.6zM315.2,290.2h-14.7l7.4,-12.7zM300.5,221.8h14.7l-7.3,12.7z"
android:fillColor="#0052b4"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,167 l253.8,-19.3L512,167v178l-254.9,32.3L0,345z"
android:fillColor="#eee"/>
<path
android:pathData="M0,0h512v167H0z"
android:fillColor="#d80027"/>
<path
android:pathData="M0,345h512v167H0z"
android:fillColor="#6da544"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,256 l249.6,-41.3L512,256v256H0z"
android:fillColor="#eee"/>
<path
android:pathData="M0,0h512v256H0z"
android:fillColor="#a2001d"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M167,0h178l25.9,252.3L345,512H167l-29.8,-253.4z"
android:fillColor="#eee"/>
<path
android:pathData="M0,0h167v512H0z"
android:fillColor="#6da544"/>
<path
android:pathData="M345,0h167v512H345z"
android:fillColor="#d80027"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v512H0z"
android:fillColor="#eee"/>
<path
android:pathData="M256,256m-111.3,0a111.3,111.3 0,1 1,222.6 0a111.3,111.3 0,1 1,-222.6 0"
android:fillColor="#d80027"/>
</group>
</vector>

View File

@@ -0,0 +1,43 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v512H0Z"
android:fillColor="#ffda44"/>
<path
android:pathData="m256,114 l-6,2 -95,78c-4,3 -4,9 -1,13l102,-33 102,33c3,-4 3,-10 -1,-13l-95,-78 -6,-2z"
android:fillColor="#d80027"/>
<path
android:pathData="M278,231h-1zM235,231z"
android:fillColor="#eee"/>
<path
android:pathData="m256,134 l-99,72 6,22 51,-32 42,25 42,-25 51,32 6,-22z"
android:fillColor="#6da544"/>
<path
android:pathData="m256,191 l-28,60 -9,-21 -10,21 -7,-16 -10,21 -7,-16 -13,27 14,23h140l14,-23 -12,-27 -8,16 -10,-21 -7,16 -10,-21 -9,21zM194,325zM318,325z"
android:fillColor="#333"/>
<path
android:pathData="m183,290 l11,32h124l11,-32h-73z"
android:fillColor="#338af3"/>
<path
android:pathData="M256,129a4,4 0,0 0,-2 1l-100,73a4,4 0,0 0,-2 4l38,117a4,4 0,0 0,4 3h124a4,4 0,0 0,4 -3l38,-117a4,4 0,0 0,-2 -4l-100,-73a4,4 0,0 0,-2 -1zM256,138 L351,208 315,319L197,319l-36,-111 95,-70z"
android:fillColor="#eee"/>
<path
android:pathData="M256.5,170L256.5,170A4.5,4.5 0,0 1,261 174.5L261,307.5A4.5,4.5 0,0 1,256.5 312L256.5,312A4.5,4.5 0,0 1,252 307.5L252,174.5A4.5,4.5 0,0 1,256.5 170z"
android:fillColor="#ff9811"/>
<path
android:pathData="M204,338L308,338A12,12 0,0 1,320 350L320,350A12,12 0,0 1,308 362L204,362A12,12 0,0 1,192 350L192,350A12,12 0,0 1,204 338z"
android:fillColor="#d80027"/>
<path
android:pathData="M188,350L324,350A12,12 0,0 1,336 362L336,362A12,12 0,0 1,324 374L188,374A12,12 0,0 1,176 362L176,362A12,12 0,0 1,188 350z"
android:fillColor="#eee"/>
<path
android:pathData="m242,191 l14,-42 14,42 -36,-26h44z"
android:fillColor="#ffda44"/>
</group>
</vector>

View File

@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v512H0Z"
android:fillColor="#eee"/>
<path
android:pathData="m350,335 l24,-24 16,16 -24,23zM311,374 L335,350 350,366 327,390zM398,382 L421,358 437,374 413,398zM358,421 L382,398 398,413 374,437ZM374,358 L398,335 413,350 390,374zM335,398 L358,374 374,390 350,413zM398,177 L335,114 350,99 414,162zM335,162 L311,138 327,122 350,146zM374,201 L350,177 366,162 390,185zM382,114 L358,91 374,75 398,99ZM421,154 L398,130 413,114 437,138ZM91,358l63,63 -16,16 -63,-63zM154,374 L177,398 162,413 138,390zM114,335 L138,358 122,374 99,350zM138,311 L201,374 185,390 122,327zM154,91 L91,154 75,138 138,75zM177,114 L114,177 99,161 162,98zM201,138 L138,201 122,185 185,122z"
android:fillColor="#333"/>
<path
android:pathData="M319,319 L193,193a89,89 0,1 1,126 126z"
android:fillColor="#d80027"/>
<path
android:pathData="M319,319a89,89 0,1 1,-126 -126z"
android:fillColor="#0052b4"/>
<path
android:pathData="M224.5,224.5m-44.5,0a44.5,44.5 0,1 1,89 0a44.5,44.5 0,1 1,-89 0"
android:fillColor="#d80027"/>
<path
android:pathData="M287.5,287.5m-44.5,0a44.5,44.5 0,1 1,89 0a44.5,44.5 0,1 1,-89 0"
android:fillColor="#0052b4"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v512H0Z"
android:fillColor="#d80027"/>
<path
android:pathData="M192,112a16,16 0,0 0,-8 2c-17,9 -33,20 -45,34 -8,-22 -24,-26 -24,-26s-14,13 -7,35c2,9 6,15 10,19 -8,14 -14,29 -18,45 -17,-15 -33,-10 -33,-10s-6,17 11,34c6,6 13,9 18,11 0,17 3,33 7,48 -22,-5 -33,7 -33,7s4,18 26,24c9,3 16,2 21,1 9,14 19,27 31,38 -22,6 -26,23 -26,23s13,14 35,7c9,-2 15,-6 19,-10a159,159 0,0 0,160 0c4,4 10,8 19,10 22,7 35,-7 35,-7s-4,-17 -26,-23c12,-11 22,-24 31,-38 5,1 12,2 21,-1 22,-6 26,-24 26,-24s-11,-12 -33,-7c4,-15 7,-31 7,-48 5,-2 12,-5 18,-11 17,-17 11,-34 11,-34s-16,-5 -33,10c-4,-16 -10,-31 -18,-45 4,-4 8,-10 10,-19 7,-22 -7,-35 -7,-35s-16,4 -24,26c-12,-14 -28,-25 -45,-34a16,16 0,0 0,-8 -2,16 16,0 0,0 -13,9 16,16 0,0 0,7 21c13,7 24,16 34,26 -23,1 -31,16 -31,16s8,16 32,16c8,0 15,-2 19,-5 6,10 10,20 12,31 -20,-11 -35,-2 -35,-2s-1,18 19,30c8,4 14,6 20,6 0,11 -2,23 -5,33l-1,-1c-11,-20 -30,-19 -30,-19s-9,15 3,36c4,7 9,12 14,14 -6,9 -12,18 -20,25 -1,-23 -17,-31 -17,-31s-16,8 -16,32c0,8 3,15 5,19a126,126 0,0 1,-122 0c3,-4 5,-11 5,-19 0,-24 -16,-32 -16,-32s-16,8 -17,31c-7,-7 -14,-16 -20,-25 5,-2 10,-7 14,-14 12,-21 3,-36 3,-36s-19,-1 -30,19v1c-4,-10 -6,-22 -6,-33 6,0 12,-2 20,-6 20,-12 19,-30 19,-30s-15,-9 -35,2c2,-11 6,-21 12,-31 4,3 11,5 19,5 24,0 32,-16 32,-16s-8,-15 -31,-16c10,-10 21,-19 34,-26a16,16 0,0 0,7 -21,16 16,0 0,0 -13,-9z"
android:fillColor="#ffda44"/>
</group>
</vector>

View File

@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h167l84.9,45L345,0h167v512H345l-87.7,-48.1L167,512H0z"
android:fillColor="#a2001d"/>
<path
android:pathData="M167,0h178v512H167z"
android:fillColor="#0052b4"/>
<path
android:pathData="M122.4,256h22.3v89h-22.3zM33.4,256h22.3v89L33.4,345z"
android:fillColor="#ffda44"/>
<path
android:pathData="M89,289.4m-22.3,0a22.3,22.3 0,1 1,44.6 0a22.3,22.3 0,1 1,-44.6 0"
android:fillColor="#ffda44"/>
<path
android:pathData="M89,211.5m-11.1,0a11.1,11.1 0,1 1,22.2 0a11.1,11.1 0,1 1,-22.2 0"
android:fillColor="#ffda44"/>
<path
android:pathData="M66.8,322.8h44.5L111.3,345L66.8,345zM66.8,233.8h44.5L111.3,256L66.8,256zM89,133.5l8,24.2h25.4l-20.6,15 7.9,24.3L89,182l-20.6,15 7.9,-24.3 -20.6,-15h25.5z"
android:fillColor="#ffda44"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h100.2l66.1,53.5L233.7,0H512v189.3L466.3,257l45.7,65.8V512H233.7l-68,-50.7 -65.5,50.7H0V322.8l51.4,-68.5 -51.4,-65z"
android:fillColor="#d80027"/>
<path
android:pathData="M100.2,0v189.3H0v33.4l24.6,33L0,289.5v33.4h100.2V512h33.4l30.6,-26.3 36.1,26.3h33.4V322.8H512v-33.4l-24.6,-33.7 24.6,-33v-33.4H233.7V0h-33.4l-33.8,25.3L133.6,0z"
android:fillColor="#eee"/>
<path
android:pathData="M133.6,0v222.7H0v66.7h133.6V512h66.7V289.4H512v-66.7H200.3V0z"
android:fillColor="#0052b4"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,144.7 L258.8,39.6 512,144.7v222.6L257,493 0,367.3z"
android:fillColor="#eee"/>
<path
android:pathData="M0,0v144.7h105.6v-22.2h33.6v22.2h33.3v-22.2h33.6v22.2h33.3v-22.2H273v22.2h33v-22.2h33.6v22.2h33.2v-22.2h33.6v22.2H512V0z"
android:fillColor="#6da544"/>
<path
android:pathData="M0,367.3L0,512h512L512,367.3L406.4,367.3v22.4h-33.6v-22.4h-33.2v22.4L306,389.7v-22.4h-33v22.4h-33.6v-22.4h-33.3v22.4h-33.6v-22.4h-33.3v22.4h-33.6v-22.4zM339.1,189.3h-33.4c0.2,3.7 0.4,7.4 0.4,11.1 0,24.8 -6.2,48.8 -17,66 -3.3,5.2 -9,12.6 -16.4,17.6v-94.7h-33.4v94.8c-7.5,-5 -13,-12.4 -16.4,-17.7 -10.8,-17 -17,-41 -17,-65.9 0,-3.7 0.2,-7.4 0.4,-11L173,189.5a190,190 0,0 0,-0.4 11c0,68.7 36.7,122.5 83.5,122.5s83.5,-53.8 83.5,-122.5c0,-3.7 -0.1,-7.4 -0.4,-11z"
android:fillColor="#d80027"/>
</group>
</vector>

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v256l-265,45.2z"
android:fillColor="#0052b4"/>
<path
android:pathData="M210,256h302v256H0z"
android:fillColor="#d80027"/>
<path
android:pathData="M0,0v512l256,-256z"
android:fillColor="#eee"/>
<path
android:pathData="M175.3,256 L144,241.3l16.7,-30.3 -34,6.5 -4.3,-34.3 -23.6,25.2L75,183.2l-4.3,34.3 -34,-6.5 16.7,30.3L22.3,256l31.2,14.7L37,301l34,-6.5 4.2,34.3 23.7,-25.2 23.6,25.2 4.3,-34.3 34,6.5 -16.7,-30.3zM68.3,100.2 L78.7,114.7 95.7,109.3 85.1,123.7 95.5,138.2 78.5,132.6L68,147l0.2,-17.9 -17,-5.6 17,-5.4zM68.3,365 L78.7,379.6 95.7,374.2 85.1,388.5 95.5,403.1 78.5,397.4L68,411.8l0.2,-17.9 -17,-5.6 17,-5.4zM216.7,232.6L206.3,247l-17,-5.4 10.5,14.4 -10.4,14.6 17,-5.7 10.6,14.4 -0.1,-17.9 17,-5.6 -17.1,-5.4z"
android:fillColor="#ffda44"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,256 l256.4,-44.3L512,256v256H0z"
android:fillColor="#d80027"/>
<path
android:pathData="M0,0h512v256H0z"
android:fillColor="#eee"/>
</group>
</vector>

View File

@@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,512h167l37.9,-260.3L167,0H0z"
android:fillColor="#6da544"/>
<path
android:pathData="M512,0H167v512h345z"
android:fillColor="#d80027"/>
<path
android:pathData="M167,256m-89,0a89,89 0,1 1,178 0a89,89 0,1 1,-178 0"
android:fillColor="#ffda44"/>
<path
android:pathData="M116.9,211.5V267a50,50 0,1 0,100.1 0v-55.6H117z"
android:fillColor="#d80027"/>
<path
android:pathData="M167,283.8c-9.2,0 -16.7,-7.5 -16.7,-16.7V245h33.4v22c0,9.2 -7.5,16.7 -16.7,16.7z"
android:fillColor="#eee"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M167,0h178l25.9,252.3L345,512H167l-29.8,-253.4z"
android:fillColor="#ffda44"/>
<path
android:pathData="M0,0h167v512H0z"
android:fillColor="#0052b4"/>
<path
android:pathData="M345,0h167v512H345z"
android:fillColor="#d80027"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M512,170v172l-256,32L0,342V170l256,-32z"
android:fillColor="#0052b4"/>
<path
android:pathData="M512,0v170H0V0Z"
android:fillColor="#eee"/>
<path
android:pathData="M512,342v170H0V342Z"
android:fillColor="#d80027"/>
</group>
</vector>

View File

@@ -0,0 +1,31 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,160 l256,-32 256,32v192l-256,32L0,352z"
android:fillColor="#0052b4"/>
<path
android:pathData="M0,0h512v160H0z"
android:fillColor="#eee"/>
<path
android:pathData="M0,352h512v160H0z"
android:fillColor="#d80027"/>
<path
android:pathData="M64,63v217c0,104 144,137 144,137s144,-33 144,-137V63z"
android:fillColor="#eee"/>
<path
android:pathData="M96,95v185a83,78 0,0 0,9 34h206a83,77 0,0 0,9 -34V95z"
android:fillColor="#d80027"/>
<path
android:pathData="M288,224h-64v-32h32v-32h-32v-32h-32v32h-32v32h32v32h-64v32h64v32h32v-32h64z"
android:fillColor="#eee"/>
<path
android:pathData="M152,359a247,231 0,0 0,56 24c12,-3 34,-11 56,-24a123,115 0,0 0,47 -45,60 56,0 0,0 -34,-10l-14,2a60,56 0,0 0,-110 0,60 56,0 0,0 -14,-2c-12,0 -24,4 -34,10a123,115 0,0 0,47 45z"
android:fillColor="#0052b4"/>
</group>
</vector>

View File

@@ -0,0 +1,91 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,128 l256,-32 256,32v256l-256,32L0,384Z"
android:fillColor="#ffda44"/>
<path
android:pathData="M0,0h512v128L0,128zM0,384h512v128L0,512z"
android:fillColor="#d80027"/>
<path
android:pathData="M144,304h-16v-80h16zM272,304h16v-80h-16z"
android:fillColor="#eee"/>
<path
android:pathData="M160,296a48,32 0,1 0,96 0a48,32 0,1 0,-96 0z"
android:fillColor="#eee"/>
<path
android:pathData="M136,192L136,192A8,8 0,0 1,144 200L144,208A8,8 0,0 1,136 216L136,216A8,8 0,0 1,128 208L128,200A8,8 0,0 1,136 192z"
android:fillColor="#d80027"/>
<path
android:pathData="M280,192L280,192A8,8 0,0 1,288 200L288,208A8,8 0,0 1,280 216L280,216A8,8 0,0 1,272 208L272,200A8,8 0,0 1,280 192z"
android:fillColor="#d80027"/>
<path
android:pathData="M208,272v24a24,24 0,0 0,24 24,24 24,0 0,0 24,-24v-24h-24z"
android:fillColor="#d80027"/>
<path
android:pathData="M128,208L144,208A8,8 0,0 1,152 216L152,216A8,8 0,0 1,144 224L128,224A8,8 0,0 1,120 216L120,216A8,8 0,0 1,128 208z"
android:fillColor="#ff9811"/>
<path
android:pathData="M272,208L288,208A8,8 0,0 1,296 216L296,216A8,8 0,0 1,288 224L272,224A8,8 0,0 1,264 216L264,216A8,8 0,0 1,272 208z"
android:fillColor="#ff9811"/>
<path
android:pathData="M128,304L144,304A8,8 0,0 1,152 312L152,312A8,8 0,0 1,144 320L128,320A8,8 0,0 1,120 312L120,312A8,8 0,0 1,128 304z"
android:fillColor="#ff9811"/>
<path
android:pathData="M272,304L288,304A8,8 0,0 1,296 312L296,312A8,8 0,0 1,288 320L272,320A8,8 0,0 1,264 312L264,312A8,8 0,0 1,272 304z"
android:fillColor="#ff9811"/>
<path
android:pathData="M160,272v24c0,8 4,14 9,19l5,-6 5,10a21,21 0,0 0,10 0l5,-10 5,6c6,-5 9,-11 9,-19v-24h-9l-5,8 -5,-8h-10l-5,8 -5,-8z"
android:fillColor="#ff9811"/>
<path
android:fillColor="#FF000000"
android:pathData="M122,252h172m-172,24h28m116,0h28"/>
<path
android:pathData="M122,248a4,4 0,0 0,-4 4,4 4,0 0,0 4,4h172a4,4 0,0 0,4 -4,4 4,0 0,0 -4,-4zM122,272a4,4 0,0 0,-4 4,4 4,0 0,0 4,4h28a4,4 0,0 0,4 -4,4 4,0 0,0 -4,-4zM266,272a4,4 0,0 0,-4 4,4 4,0 0,0 4,4h28a4,4 0,0 0,4 -4,4 4,0 0,0 -4,-4z"
android:fillColor="#d80027"/>
<path
android:pathData="M196,168c-7,0 -13,5 -15,11l-5,-1c-9,0 -16,7 -16,16s7,16 16,16c7,0 13,-4 15,-11a16,16 0,0 0,17 -4,16 16,0 0,0 17,4 16,16 0,1 0,10 -20,16 16,0 0,0 -27,-5c-3,-4 -7,-6 -12,-6zM196,176c5,0 8,4 8,8 0,5 -3,8 -8,8 -4,0 -8,-3 -8,-8 0,-4 4,-8 8,-8zM220,176c5,0 8,4 8,8 0,5 -3,8 -8,8 -4,0 -8,-3 -8,-8 0,-4 4,-8 8,-8zM176,186 L180,187 184,195c0,4 -4,7 -8,7s-8,-3 -8,-8c0,-4 4,-8 8,-8zM240,186c5,0 8,4 8,8 0,5 -3,8 -8,8 -4,0 -8,-3 -8,-7l4,-8z"
android:fillColor="#eee"/>
<path
android:pathData="M200,160h16v32h-16z"
android:fillColor="#ff9811"/>
<path
android:pathData="M208,224h48v48h-48z"
android:fillColor="#eee"/>
<path
android:pathData="m248,208 l-8,8h-64l-8,-8c0,-13 18,-24 40,-24s40,11 40,24zM160,224h48v48h-48z"
android:fillColor="#d80027"/>
<path
android:pathData="M232,232L232,232A10,10 0,0 1,242 242L242,254A10,10 0,0 1,232 264L232,264A10,10 0,0 1,222 254L222,242A10,10 0,0 1,232 232z"
android:fillColor="#d80027"/>
<path
android:pathData="M168,232v8h8v16h-8v8h32v-8h-8v-16h8v-8zM176,216h64v8h-64z"
android:fillColor="#ff9811"/>
<path
android:pathData="M186,202m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"
android:fillColor="#ffda44"/>
<path
android:pathData="M208,202m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"
android:fillColor="#ffda44"/>
<path
android:pathData="M230,202m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"
android:fillColor="#ffda44"/>
<path
android:pathData="M169,272v43a24,24 0,0 0,10 4v-47h-10zM189,272v47a24,24 0,0 0,10 -4v-43h-10z"
android:fillColor="#d80027"/>
<path
android:pathData="M208,272m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:fillColor="#338af3"/>
<path
android:pathData="M272,320L288,320A8,8 0,0 1,296 328L296,328A8,8 0,0 1,288 336L272,336A8,8 0,0 1,264 328L264,328A8,8 0,0 1,272 320z"
android:fillColor="#338af3"/>
<path
android:pathData="M128,320L144,320A8,8 0,0 1,152 328L152,328A8,8 0,0 1,144 336L128,336A8,8 0,0 1,120 328L120,328A8,8 0,0 1,128 320z"
android:fillColor="#338af3"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h133.6l35.3,16.7L200.3,0H512v222.6l-22.6,31.7 22.6,35.1V512H200.3l-32,-19.8 -34.7,19.8H0V289.4l22.1,-33.3L0,222.6z"
android:fillColor="#0052b4"/>
<path
android:pathData="M133.6,0v222.6H0v66.8h133.6V512h66.7V289.4H512v-66.8H200.3V0z"
android:fillColor="#ffda44"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v89l-79.2,163.7L512,423v89H0v-89l82.7,-169.6L0,89z"
android:fillColor="#d80027"/>
<path
android:pathData="M0,89h512v78l-42.6,91.2L512,345v78H0v-78l40,-92.5L0,167z"
android:fillColor="#eee"/>
<path
android:pathData="M0,167h512v178H0z"
android:fillColor="#0052b4"/>
</group>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v512H0z"
android:fillColor="#d80027"/>
<path
android:pathData="m245.5,209.2 l21,29 34,-11.1 -21,29 21,28.9 -34,-11.1 -21,29V267l-34,-11.1 34,-11z"
android:fillColor="#eee"/>
<path
android:pathData="M188.2,328.3a72.3,72.3 0,1 1,34.4 -136,89 89,0 1,0 0,127.3 72,72 0,0 1,-34.4 8.7z"
android:fillColor="#eee"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="m0,256 l258,-39.4L512,256v256H0z"
android:fillColor="#ffda44"/>
<path
android:pathData="M0,0h512v256H0z"
android:fillColor="#338af3"/>
</group>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group>
<clip-path
android:pathData="M256,256m-256,0a256,256 0,1 1,512 0a256,256 0,1 1,-512 0"/>
<path
android:pathData="M0,0h512v512H0z"
android:fillColor="#d80027"/>
<path
android:pathData="m256,133.6 l27.6,85H373L300.7,271l27.6,85 -72.3,-52.5 -72.3,52.6 27.6,-85 -72.3,-52.6h89.4z"
android:fillColor="#ffda44"/>
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,4 +0,0 @@
<!--drawable/numeric.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#fff" android:pathData="M4 17V9H2V7h4v10H4m18-2c0 1.11-0.9 2-2 2h-4v-2h4v-2h-2v-2h2V9h-4V7h4a2 2 0 0 1 2 2v1.5a1.5 1.5 0 0 1-1.5 1.5 1.5 1.5 0 0 1 1.5 1.5V15m-8 0v2H8v-4c0-1.11 0.9-2 2-2h2V9H8V7h4a2 2 0 0 1 2 2v2c0 1.11-0.9 2-2 2h-2v2h4z"/>
</vector>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Pupil, Hitomi.la viewer for Android
~ Copyright (C) 2020 tom5079
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<resources></resources>

View File

@@ -4,7 +4,7 @@
<string name="galleryblock_series">シリーズ: %1$s</string>
<string name="galleryblock_type">タイプ: %1$s</string>
<string name="main_no_result">結果なし</string>
<string name="search_hint">検索</string>
<string name="search_hint">ギャラリー検索</string>
<string name="settings_clear_cache">キャッシュクリア</string>
<string name="settings_clear_cache_alert_message">キャッシュをクリアするとイメージのロード速度に影響を与えます。実行しますか?</string>
<string name="settings_storage_usage">%s使用中</string>
@@ -14,6 +14,11 @@
<string name="settings_search_title">検索設定</string>
<string name="settings_title">設定</string>
<string name="update_notification_description">アップデートダウンロード中</string>
<string name="doujinshi">同人誌</string>
<string name="manga">漫画</string>
<string name="artist_cg">アーティストCG</string>
<string name="game_cg">ゲームCG</string>
<string name="image_set">イメージまとめ</string>
<string name="update_title">新しいアップデートがあります</string>
<string name="warning">注意</string>
<string name="settings_miscellaneous_title">その他</string>
@@ -21,8 +26,9 @@
<string name="settings_clear_history">履歴を削除</string>
<string name="settings_clear_history_alert_message">履歴を削除しますか?</string>
<string name="settings_clear_history_summary">履歴数: %1$d</string>
<string name="main_drawer_history">履歴</string>
<string name="main_drawer_home">トップ</string>
<string name="main_destination_history">履歴</string>
<string name="notification_denied">通知を無効にするとバックグラウンドダウンロード及びアプリのアップデート機能が使用不可になります。</string>
<string name="main_destination_search">トップ</string>
<string name="update_release_note"># リリースノート(v%1$s)\n%2$s</string>
<string name="settings_security_mode_title">セキュリティーモード</string>
<string name="settings_security_mode_summary">アプリ履歴でアプリの画面を表示しない</string>
@@ -32,6 +38,8 @@
<string name="default_query_dialog_filter_guro">グロフィルター</string>
<string name="default_query_dialog_language">"言語: "</string>
<string name="default_query_dialog_title">デフォルトキーワード設定</string>
<string name="main_destination_settings">設定</string>
<string name="main_open_navigation_drawer">メニューを開く</string>
<string name="main_drawer_group_contact_title">お問い合わせ先</string>
<string name="main_drawer_group_contact_homepage">ホームページ</string>
<string name="main_drawer_group_contact_help">ヘルプ</string>
@@ -44,7 +52,7 @@
<string name="reader_notification_text">ダウンロード中…</string>
<string name="reader_notification_complete">ダウンロード完了</string>
<string name="reader_fab_download_cancel">バックグラウンドダウンロード中止</string>
<string name="main_drawer_downloads">ダウンロード</string>
<string name="main_destination_downloads">ダウンロード</string>
<string name="main_jump_title">ページ移動</string>
<string name="main_jump_message">現ページ番号: %1$d\nページ数: %2$d</string>
<string name="unable_to_connect">hitomi.laに接続できません</string>
@@ -52,7 +60,7 @@
<string name="settings_clear_downloads">ダウンロード削除</string>
<string name="settings_clear_downloads_alert_message">ダウンロードしたギャラリーを全て削除します。\n実行しますか</string>
<string name="settings_mirror_summary">ミラーサーバからイメージをロード</string>
<string name="main_drawer_favorite">ブックマーク</string>
<string name="main_destination_favorites">ブックマーク</string>
<string name="main_open_gallery_by_id">ギャラリー番号で見る</string>
<string name="reader_failed_to_find_gallery">エラーが発生しました</string>
<string name="settings_storage">ストレージ</string>
@@ -77,6 +85,7 @@
<string name="lock_corrupted">ロックファイルが破損されています。Pupilを再再インストールしてください。</string>
<string name="settings_dark_mode_title">ダークモード</string>
<string name="settings_dark_mode_summary">夜にシコりたい方々へ</string>
<string name="search_add_query_item">追加</string>
<string name="gallery_details">ギャラリー情報</string>
<string name="gallery_artists">アーティスト</string>
<string name="gallery_characters">キャラクター</string>
@@ -88,7 +97,7 @@
<string name="gallery_related">おすすめ</string>
<string name="settings_nomedia_title">イメージを隠す</string>
<string name="main_delete">削除</string>
<string name="main_download">ダウンロード</string>
<string name="download">ダウンロード</string>
<string name="settings_backup_title">ブックマークバックアップ</string>
<string name="settings_restore_title">ブックマーク復元</string>
<string name="settings_backup_file_created">バックアップファイルを作成しました</string>
@@ -157,4 +166,12 @@
<string name="settings_max_concurrent_download">並列ダウンロード</string>
<string name="unaccessible_download_folder">アンドロイド11以上では外部からのアプリ内部空間接近が不可能です。ダウンロードフォルダを変更しますか</string>
<string name="settings_networking">ネットワーク</string>
<string name="settings_recover_downloads">ダウンロードデータベースを再構築</string>
<string name="main_close_navigation_drawer">メニューを閉じる</string>
<string name="search_remove_query_item_description">検索構文を除去</string>
<string name="search_add_query_item_tag">タグ</string>
<string name="move_to_page">%1$d ページへ移動</string>
<string name="search_bar_edit_tag">タッチして編集</string>
<string name="main_destination_image_viewer">イメージビューア</string>
<string name="not_implemented">この機能はまだ実装されていません</string>
</resources>

View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Pupil, Hitomi.la viewer for Android
~ Copyright (C) 2020 tom5079
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<resources></resources>

View File

@@ -3,7 +3,7 @@
<string name="galleryblock_language">언어: %1$s</string>
<string name="galleryblock_series">시리즈: %1$s</string>
<string name="galleryblock_type">종류: %1$s</string>
<string name="search_hint">검색</string>
<string name="search_hint">갤러리 검색</string>
<string name="settings_default_query">기본 검색어</string>
<string name="settings_clear_cache">캐시 정리하기</string>
<string name="settings_clear_cache_alert_message">캐시를 정리하면 이미지 로딩속도가 느려질 수 있습니다. 계속하시겠습니까?</string>
@@ -13,6 +13,11 @@
<string name="settings_search_title">검색 설정</string>
<string name="settings_title">설정</string>
<string name="update_notification_description">업데이트 다운로드중&#8230;</string>
<string name="doujinshi">동인지</string>
<string name="manga">만화</string>
<string name="artist_cg">아티스트 CG</string>
<string name="game_cg">게임 CG</string>
<string name="image_set">이미지 모음</string>
<string name="update_title">업데이트가 있습니다!</string>
<string name="warning">경고</string>
<string name="main_no_result">결과 없음\n해결법</string>
@@ -20,8 +25,9 @@
<string name="settings_clear_history">기록 삭제</string>
<string name="settings_clear_history_alert_message">기록을 삭제하시겠습니까?</string>
<string name="settings_clear_history_summary">기록 %1$d개 저장됨</string>
<string name="main_drawer_history">기록</string>
<string name="main_drawer_home"></string>
<string name="main_destination_history">기록</string>
<string name="notification_denied">백그라운드 다운로드를 위해서는 알림을 활성화할 필요가 있습니다. 알림을 비활성화하면 백그라운드 다운로드와 앱 업데이트 기능을 사용할 수 없습니다.</string>
<string name="main_destination_search"></string>
<string name="update_release_note"># 릴리즈 노트(v%1$s)\n%2$s</string>
<string name="settings_security_mode_summary">최근 앱 목록 창에서 앱 화면을 보이지 않게 합니다</string>
<string name="settings_security_mode_title">보안 모드 활성화</string>
@@ -35,6 +41,9 @@
<string name="main_drawer_group_contact_github">Github</string>
<string name="main_drawer_group_contact_help">도움말</string>
<string name="main_drawer_group_contact_homepage">홈페이지</string>
<string name="main_destination_settings">설정</string>
<string name="main_destination_image_viewer">뷰어</string>
<string name="main_open_navigation_drawer">메뉴 열기</string>
<string name="main_drawer_group_contact_title">문의</string>
<string name="reader_fab_fullscreen">전체 화면</string>
<string name="channel_download">다운로드</string>
@@ -43,14 +52,14 @@
<string name="reader_notification_text">다운로드 중…</string>
<string name="reader_notification_complete">다운로드 완료</string>
<string name="reader_fab_download_cancel">백그라운드 다운로드 취소</string>
<string name="main_drawer_downloads">다운로드</string>
<string name="main_destination_downloads">다운로드</string>
<string name="main_jump_title">페이지 이동</string>
<string name="main_jump_message">현재 페이지: %1$d\n페이지 수: %2$d</string>
<string name="unable_to_connect">hitomi.la에 연결할 수 없습니다</string>
<string name="main_move_to_page">%1$d 페이지로 이동</string>
<string name="settings_clear_downloads">다운로드 삭제</string>
<string name="settings_clear_downloads_alert_message">다운로드 된 만화를 모두 삭제합니다.\n계속하시겠습니까?</string>
<string name="main_drawer_favorite">즐겨찾기</string>
<string name="main_destination_favorites">즐겨찾기</string>
<string name="main_open_gallery_by_id">갤러리 번호로 열기</string>
<string name="reader_failed_to_find_gallery">갤러리를 찾지 못했습니다</string>
<string name="settings_storage">저장 공간</string>
@@ -75,6 +84,7 @@
<string name="lock_corrupted">잠금 파일이 손상되었습니다! 앱을 재설치 해 주시기 바랍니다.</string>
<string name="settings_dark_mode_title">다크 모드</string>
<string name="settings_dark_mode_summary">딥 다크한 모오드</string>
<string name="search_add_query_item">추가</string>
<string name="gallery_details">갤러리 정보</string>
<string name="gallery_artists">작가</string>
<string name="gallery_characters">캐릭터</string>
@@ -86,7 +96,7 @@
<string name="gallery_thumbnails">미리보기</string>
<string name="settings_nomedia_title">이미지 숨기기</string>
<string name="main_delete">삭제</string>
<string name="main_download">다운로드</string>
<string name="download">다운로드</string>
<string name="settings_backup_title">즐겨찾기 백업</string>
<string name="settings_restore_title">즐겨찾기 복원</string>
<string name="settings_backup_file_created">백업 파일을 생성하였습니다</string>
@@ -157,4 +167,11 @@
<string name="settings_max_concurrent_download">병렬 다운로드</string>
<string name="unaccessible_download_folder">안드로이드 11 이상에서는 외부에서 현재 다운로드 폴더에 접근할 수 없습니다. 변경하시겠습니까?</string>
<string name="settings_networking">네트워크</string>
<string name="settings_recover_downloads">다운로드 데이터베이스 복구</string>
<string name="main_close_navigation_drawer">메뉴 닫기</string>
<string name="search_remove_query_item_description">검색 구문 제거</string>
<string name="search_add_query_item_tag">태그</string>
<string name="move_to_page">%1$d 페이지로 이동</string>
<string name="search_bar_edit_tag">터치하여 수정</string>
<string name="not_implemented">이 기능은 아직 개발 중입니다</string>
</resources>

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="hitomi_sort_mode">
<item>NEWEST</item>
<item>POPULAR</item>
</string-array>
</resources>

View File

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Pupil, Hitomi.la viewer for Android
~ Copyright (C) 2020 tom5079
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<resources>
<declare-styleable name="TagChipGroup">
<attr name="maxTag" format="integer"/>
</declare-styleable>
<declare-styleable name="RippleCircleStatus">
<attr name="half" format="enum">
<enum name="top" value="1"/>
<enum name="bottom" value="-1"/>
</attr>
</declare-styleable>
</resources>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#4fc3f7</color>
<color name="colorPrimaryDark">#0093c4</color>
<color name="colorAccent">#D81B60</color>
<color name="material_orange_500">#ff9800</color>
</resources>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
</resources>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="notification_id_update" type="id" />
</resources>

View File

@@ -1,5 +1,5 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" translatable="false" tools:override="true">Pupil-Beta</string>
<string name="app_name" translatable="false" tools:override="true">Pupil</string>
<string name="release_url" translatable="false">https://api.github.com/repos/tom5079/Pupil/releases</string>
@@ -32,6 +32,8 @@
<string name="unlimited">Unlimited</string>
<string name="not_implemented">This feature is not implemented yet</string>
<string name="copied_to_clipboard">Copied to clipboard</string>
<string name="channel_download">Download</string>
@@ -51,10 +53,16 @@
<string name="unaccessible_download_folder">From Android 11 and above, current Download folder cannot be accessed by outside apps. Would you like to change the download folder?</string>
<string name="main_drawer_home">Home</string>
<string name="main_drawer_history">History</string>
<string name="main_drawer_downloads">Downloads</string>
<string name="main_drawer_favorite">Favorites</string>
<string name="notification_denied">Notification permission is required for background downloads. If you deny notifications from this app, in-app update and background download will be disabled.</string>
<string name="main_destination_search">Home</string>
<string name="main_destination_history">History</string>
<string name="main_destination_downloads">Downloads</string>
<string name="main_destination_favorites">Favorites</string>
<string name="main_destination_settings">Settings</string>
<string name="main_destination_image_viewer">Reader</string>
<string name="main_open_navigation_drawer">Open Navigation Drawer</string>
<string name="main_close_navigation_drawer">Close Navigation Drawer</string>
<string name="main_drawer_group_contact_title">Contact</string>
<string name="main_drawer_group_contact_help">Help</string>
<string name="main_drawer_group_contact_homepage">Visit homepage</string>
@@ -62,8 +70,6 @@
<string name="main_drawer_group_contact_email">Email me!</string>
<string name="main_drawer_grouop_contact_discord">Discord</string>
<string name="main_menu_thin">Thin Mode</string>
<string name="main_menu_sort">Sort</string>
<string name="main_menu_sort_newest">Newest</string>
<string name="main_menu_sort_popular">Popular</string>
@@ -77,20 +83,30 @@
<string name="main_move_to_page">Move to page %1$d</string>
<string name="main_download">DOWNLOAD</string>
<string name="download">Download</string>
<string name="main_delete">DELETE</string>
<string name="doujinshi">Doujinshi</string>
<string name="manga">Manga</string>
<string name="artist_cg">Artist CG</string>
<string name="game_cg">Game CG</string>
<string name="image_set">Image Set</string>
<string name="update_title">Update available</string>
<string name="update_download_completed">Download Completed</string>
<string name="update_download_completed_description">Click here to update</string>
<string name="update_notification_description">Downloading update&#8230;</string>
<string name="update_release_note"># Release Note(v%1$s)\n%2$s</string>
<string name="search_hint">Search</string>
<string name="search_hint">Search galleries</string>
<string name="search_all">Search all galleries</string>
<string name="search_show_histories">Show histories</string>
<string name="search_show_tags">Show favorite tags</string>
<string name="search_add_query_item">Add</string>
<string name="search_add_query_item_tag">Tag</string>
<string name="search_remove_query_item_description">Remove query item</string>
<string name="gallery_details">Details</string>
<string name="gallery_thumbnails">Thumbnails</string>
<string name="gallery_related">Related Galleries</string>
@@ -106,6 +122,11 @@
<string name="galleryblock_language">Language: %1$s</string>
<string name="galleryblock_pagecount" translatable="false">%dP</string>
<string name="move_to_page">Move to page %1$d</string>
<!-- SEARCH BAR -->
<string name="search_bar_edit_tag">Touch to edit</string>
<!-- READER -->
<string name="reader_loading">Loading</string>
@@ -150,6 +171,7 @@
<string name="settings_storage_usage_loading">Calculating storage usage…</string>
<string name="settings_clear_cache">Clear cache</string>
<string name="settings_clear_cache_alert_message">Deleting cache can affect image loading speed. Do you want to continue?</string>
<string name="settings_recover_downloads">Reconstruct download database</string>
<string name="settings_clear_downloads">Clear downloads</string>
<string name="settings_clear_downloads_alert_message">Delete all downloaded galleries.\nDo you want to continue?</string>
<string name="settings_clear_history">Clear history</string>
@@ -248,5 +270,6 @@
<string name="import_old_galleries_notification_text" translatable="false">%1$d/%2$d</string>
<string name="import_old_galleries_notification_done">Importing completed</string>
<string name="settings_lock_fingerprint_prompt_subtitle">Ah Shit, Here we go again</string>
<string name="main_menu_thin">Thin Mode</string>
</resources>

View File

@@ -1,18 +0,0 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="NoActionBarAppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

View File

@@ -18,6 +18,7 @@
-->
<paths>
<cache-path name="cached_image" path="networkcache/" />
<cache-path name="apks" path="apks/" />
<external-path name="external" path="."/>
<external-files-path name="files" path="."/>
<files-path name="files" path="." />
</paths>

View File

@@ -18,6 +18,9 @@
-->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">ix.io</domain>
</domain-config>

View File

@@ -1,6 +1,6 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2022 tom5079
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,16 +16,26 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.sources
package xyz.quaver.pupil
import android.graphics.drawable.Drawable
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
data class SourceEntry(
val packageName: String,
val packagePath: String,
val sourceName: String,
val sourcePath: String,
val sourceDir: String,
val icon: Drawable,
val version: String
)
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Test
import xyz.quaver.pupil.networking.HitomiHttpClient
class ExampleUnitTest {
@Test
fun test() = runTest {
val hitomi = HitomiHttpClient()
val result = hitomi.getGalleryIDsFromNozomi(null, "index", "all")
println(result.array())
}
}

View File

@@ -1,113 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2022 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil
import io.ktor.client.engine.mock.*
import io.ktor.http.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
import xyz.quaver.pupil.util.PupilHttpClient
import xyz.quaver.pupil.util.RemoteSourceInfo
import java.io.File
import java.util.*
import kotlin.random.Random
@OptIn(ExperimentalCoroutinesApi::class)
class PupilHttpClientTest {
val tempFile = File.createTempFile("pupilhttpclienttest", ".apk").also {
it.deleteOnExit()
}
@Test
fun getRemoteSourceList() = runTest {
val expected = buildMap {
put("hitomi.la", RemoteSourceInfo("hitomi", "hitomi.la", "0.0.1"))
}
val mockEngine = MockEngine { _ ->
respond(Json.encodeToString(expected), headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.contentType))
}
val client = PupilHttpClient(mockEngine)
assertEquals(expected, client.getRemoteSourceList())
}
@Test
fun downloadApk() = runTest {
val expected = Random.Default.nextBytes(1000000) // 1MB
val mockEngine = MockEngine { _ ->
respond(expected, headers = headersOf(HttpHeaders.ContentType, "application/vnd.android.package-archive"))
}
val client = PupilHttpClient(mockEngine)
client.downloadFile("http://a/", tempFile).collect()
assertArrayEquals(expected, tempFile.readBytes())
}
@Test
fun latestRelease() = runTest {
val expectedVersion = "5.3.7"
val expectedApkUrl = "https://github.com/tom5079/Pupil/releases/download/5.3.7/Pupil-v5.3.7.apk"
val expectedReleaseNotes = mapOf(
Locale.KOREAN to """
* 가끔씩 무한로딩 걸리는 현상 수정
* 백업시 즐겨찾기 태그도 백업되게 수정
* 이전 안드로이드에서 앱이 튕기는 오류 수정
""".trimIndent(),
Locale.JAPANESE to """
* 稀に接続不可になるバグを修正
* お気に入りタグを含むようバックアップ機能を修正
* 旧バージョンのアンドロイドでアプリがクラッシュするバグを解決
""".trimIndent(),
Locale.ENGLISH to """
* Fixed occasional outage
* Updated backup/restore feature to include favorite tags
* Fixed app crashing on older Androids
""".trimIndent()
)
val mockEngine = MockEngine { _ ->
val response = javaClass.getResource("/releases.json")!!.readText()
respond(response)
}
val client = PupilHttpClient(mockEngine)
val release = client.latestRelease()!!
assertEquals(expectedVersion, release.version)
assertEquals(expectedApkUrl, release.apkUrl)
println(expectedReleaseNotes)
println(release.releaseNotes)
assertEquals(expectedReleaseNotes, release.releaseNotes)
}
}

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