Compare commits

...

363 Commits

Author SHA1 Message Date
tom5079
0eb6d40d7e fix tag 2025-11-14 19:09:46 -08:00
tom5079
f7b9260a2b Merge pull request #189 from contigo/patch-1
Update strings.xml (zh-rTW)
2025-11-14 19:07:16 -08:00
contigo
7df09aa74b Update strings.xml (zh-rTW)
</string> 가 없는 2개 문자열을 수정
2025-10-15 11:42:01 +09:00
tom5079
b3ce36f81c Merge pull request #160 from Regu-Miabyss/master
Add Chinese(Taiwan) translation
2025-10-09 10:49:25 -07:00
tom5079
27dded11c1 Merge pull request #188 from EmiyaSyahriel/apptl-id
add Indonesian App UI translation
2025-10-09 10:49:01 -07:00
Syahriel Ibnu Irfansyah
758210658e change: polish indonesian translation a bit 2025-10-08 16:17:55 +07:00
Syahriel Ibnu Irfansyah
830d490822 change: replace "atur" with "kelola" 2025-10-08 16:15:16 +07:00
Syahriel Ibnu Irfansyah
236ce2c189 add: indonesian translation 2025-10-08 15:58:40 +07:00
tom5079
7f3f17d08e image fix 2025-03-23 10:43:17 -07:00
tom5079
72937cdd42 thumbnail fix 2025-03-23 10:31:12 -07:00
tom5079
9c878f5e44 fix image loading 2025-03-23 10:13:41 -07:00
tom5079
db928a168f fix loading 2025-03-23 09:44:53 -07:00
tom5079
0eff941d48 update README.md 2025-02-26 23:42:41 -08:00
tom5079
be4aca1f6b fix layout in android 35 2025-02-26 23:39:11 -08:00
tom5079
038b8e0ac5 fix language search 2025-02-25 00:19:51 -08:00
tom5079
79a4917897 build apk 2025-02-24 23:55:55 -08:00
tom5079
8c8ead5830 improve japanese 2025-02-24 23:47:44 -08:00
tom5079
a3b6b010be build apk 2025-02-24 23:38:14 -08:00
tom5079
8bf936ee20 build apk 2025-02-24 23:38:14 -08:00
tom5079
c69972f289 update README.md 2025-02-24 23:38:14 -08:00
tom5079
05f555bb91 minsdk 2025-02-24 23:38:14 -08:00
tom5079
83d6058f2b sort mode 2025-02-24 23:38:12 -08:00
tom5079
f888535389 migrate to kts 2025-02-24 23:36:02 -08:00
tom5079
0f2336eccf migrate to kts 2025-02-24 23:36:02 -08:00
tom5079
e8443664dc cleanup 2025-02-24 23:36:02 -08:00
tom5079
0f9b6963a6 dependency update 2025-02-24 23:36:02 -08:00
tom5079
6727ac1014 upgrade agp 2025-02-24 23:36:02 -08:00
tom5079
a15e2c30cb upgrade gcp 2025-02-24 23:36:02 -08:00
tom5079
6c603b2bf3 Merge pull request #171 from maboroshin/master
Update strings.xml improve Japanese translation
2025-02-09 11:24:24 -08:00
maboroshin
8146fc8473 Update strings.xml improve Japanese 2025-02-03 09:17:38 +09:00
maboroshin
3eff34e585 Update strings.xml improve Japanese 2025-02-03 08:28:10 +09:00
maboroshin
b24f4b5306 Update strings.xml reorder Japanese 2025-02-03 07:25:29 +09:00
tom5079
1bcbc5f42b tag suggestion 2024-11-18 19:58:22 -08:00
Regu-Miabyss
a6d5336608 Add Chinese(Taiwan) translation 2024-07-19 13:36:02 +08:00
tom5079
290b7fb158 transfer wip 2024-06-30 13:40:57 -07:00
tom5079
9f103dcffe Merge pull request #152 from tom5079/master2
Fix ellipsize, WIP Transfer
2024-04-23 11:21:07 -07:00
tom5079
68ec919ae4 fixed downloading galleris with long title, [wip] transfer data 2024-04-17 23:25:36 -07:00
tom5079
b0e194898e wip 2024-04-15 18:19:49 -07:00
tom5079
03b88c5b4b wip 2024-04-11 08:44:56 -07:00
tom5079
a5d4cbfaec fix ellipsize 2024-04-11 08:44:56 -07:00
tom5079
19450f66a0 favorite scroll to top 2024-03-01 21:50:42 -08:00
tom5079
5b36fd9257 add certificate 2024-03-01 11:56:25 -08:00
tom5079
a3158d320b update README.md 2024-02-26 00:13:39 -08:00
tom5079
38494c9fbc add certificate 2024-02-26 00:13:00 -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
tom5079
9f9a4c81b3 migrated to ViewBinding 2020-11-29 14:01:56 +09:00
tom5079
d567b30f4b Fixed typo & Built apk 2020-11-29 14:01:56 +09:00
tom5079
6d7c4ce0ab Fixed show extra tags button not showing up & version up 2020-11-29 14:01:51 +09:00
tom5079
e062b8f9e9 Implemented proper Page Turn without relying on RecyclerView 2020-11-29 14:01:46 +09:00
tom5079
08403b7a4e fixed gallery import 2020-11-29 14:01:41 +09:00
tom5079
c6ed5d35e7 ProgressCard 2020-11-29 14:01:25 +09:00
tom5079
dba3460b60 Fixes unable to recursively delete when the download folder is not not SAF based 2020-11-29 14:01:09 +09:00
tom5079
f07f624fcf search bug fix 2020-10-25 00:15:21 +09:00
tom5079
48ff2f328f search bug fix 2020-10-24 23:55:50 +09:00
tom5079
9ae2423a40 search bug fix 2020-10-24 23:05:11 +09:00
tom5079
2bc3c78c75 search bug fix 2020-10-24 23:04:49 +09:00
tom5079
18e9fe75fb hiyobi.me fix 2020-10-24 11:48:14 +09:00
tom5079
880a741a44 hiyobi.me fix 2020-10-24 11:25:16 +09:00
tom5079
2c6ddcc64b hitomi.la image not loading fix 2020-10-21 14:42:07 +09:00
tom5079
8f2e757b77 Update README.md 2020-10-15 15:53:30 +09:00
tom5079
ff177955b3 Update README.md 2020-10-15 15:49:07 +09:00
tom5079
8bb8066a98 Apk built 2020-10-15 14:37:13 +09:00
tom5079
2747ddbf65 Adjust gallery_id margin 2020-10-15 14:32:36 +09:00
tom5079
b939e9424d Translate tag by default 2020-10-15 14:29:22 +09:00
tom5079
fb9dea5d1e Copy galleryID by clicking galleryblock_id 2020-10-15 12:52:38 +09:00
tom5079
da4d5d711b Prefetch
Resolves #109
2020-10-15 10:20:36 +09:00
tom5079
331cbec5f1 Bug fix 2020-10-14 18:36:36 +09:00
tom5079
7f02284285 Update README.md 2020-10-14 00:26:06 +09:00
tom5079
ac2c3a6d97 Merge remote-tracking branch 'origin/master' into master 2020-10-14 00:25:44 +09:00
tom5079
c3bc80fec6 Bug fix 2020-10-14 00:24:38 +09:00
tom5079
09779a0710 Update README.md 2020-10-13 23:47:56 +09:00
tom5079
e82c6ef866 App built
Possible build time optimization
2020-10-13 23:40:53 +09:00
tom5079
861ae9be64 Merge remote-tracking branch 'origin/dev' into dev 2020-10-13 23:34:28 +09:00
tom5079
96108bc1ec Improves Scroll Jitter 2020-10-13 23:34:16 +09:00
tom5079
016f217db0 Merge pull request #108 from klx7007/patch-1
Fix FloatingSearchView imeOptions to only affect keyboard visibility
2020-10-13 23:05:34 +09:00
tom5079
0688294f18 Dependency update
Support non external storage document Uris

Support non external storage document Uris
2020-10-13 22:59:29 +09:00
klx7007
9ad008255d FloatingSearchView imeOptions 수정
imeOption을 덮어씌워서 search할때 키보드만 숨겨짐
2020-10-13 22:58:13 +09:00
tom5079
4c5a862dd6 Update README.md 2020-10-13 17:57:19 +09:00
tom5079
b165a2308f Merge remote-tracking branch 'origin/master' into master 2020-10-13 17:13:10 +09:00
tom5079
8757b08cd2 Fixed pagecount not showing up 2020-10-13 17:12:53 +09:00
tom5079
3800543fba Update README.md 2020-10-13 13:59:02 +09:00
tom5079
02ef60c818 Update README.md 2020-10-13 13:57:23 +09:00
tom5079
88f3b30266 Merge branch 'dev' into master 2020-10-13 13:52:34 +09:00
tom5079
9203dc0112 Tag translation 2020-10-13 13:51:53 +09:00
tom5079
4c683bec68 Dependency update
Fixes concurrentmodificationexception
2020-10-13 08:34:04 +09:00
tom5079
0cfd1eb453 Update README.md 2020-10-04 23:05:30 +09:00
tom5079
19744dab37 Merge remote-tracking branch 'origin/master' into master 2020-10-04 23:04:30 +09:00
tom5079
12d58e5aa7 Don't cancel download onPause 2020-10-04 23:04:12 +09:00
tom5079
e46dd37a26 Update README.md 2020-10-04 22:47:45 +09:00
tom5079
49c3ebc36b Concurrency issue fixed
Don't cancel download onPause
Limit folder length to 127 characters
2020-10-04 22:44:31 +09:00
tom5079
11e9bc2235 Added five entries per page option 2020-10-04 20:39:33 +09:00
tom5079
3029b3bf0e Update README.md 2020-10-03 20:20:41 +09:00
tom5079
9a6c6f67ce Merge branch 'dev' into master 2020-10-03 20:18:53 +09:00
tom5079
a6ed0baef2 Fix auto cache cleanup 2020-10-03 20:18:20 +09:00
tom5079
d3b43d80da Update README.md 2020-10-03 10:06:17 +09:00
tom5079
46d4316d49 Merge remote-tracking branch 'origin/master' into master 2020-10-03 10:05:30 +09:00
tom5079
ade2864351 Fix auto cache cleanup 2020-10-03 10:03:57 +09:00
tom5079
365fc56e9d Update README.md 2020-10-02 13:09:46 +09:00
tom5079
54a5cd21ea Merge branch 'dev' into master 2020-10-02 13:00:02 +09:00
tom5079
38c0399b09 App built 2020-10-02 12:59:32 +09:00
tom5079
2b67858453 Auto cache clean 2020-10-02 12:51:59 +09:00
tom5079
87fdbdbb6e Open GalleryDialog first instead of opening Reader directly 2020-10-02 00:19:56 +09:00
tom5079
bab77a4116 (KO) Added support link 2020-10-02 00:13:34 +09:00
tom5079
d20756ab96 Reset security mode 2020-10-01 22:51:52 +09:00
tom5079
dc75a753c3 Minimum thumbnail height 2020-10-01 22:46:03 +09:00
tom5079
4712d47903 Show 10 tags maximum 2020-10-01 22:20:49 +09:00
tom5079
c5561801e1 Add group name to GalleryBlock 2020-10-01 21:32:42 +09:00
tom5079
5c259fa07a Dependency update
Fixed duplicated download file
Better download progress update handling

TODO: Add group name to GalleryBlock
2020-10-01 21:24:32 +09:00
tom5079
60e8b18702 Update README.md 2020-09-29 16:53:15 +09:00
tom5079
a8317824a9 Merge remote-tracking branch 'origin/master' into master 2020-09-27 21:40:32 +09:00
tom5079
0c3c78cc72 Fixed app crashing when loading thumbnail 2020-09-27 21:40:22 +09:00
tom5079
cfd4a8faac Update README.md 2020-09-27 21:39:05 +09:00
tom5079
7f3fb0db0d Update README.md 2020-09-27 20:30:47 +09:00
tom5079
385d3f0d1b Update README.md 2020-09-27 20:29:50 +09:00
tom5079
8fa6bd12a2 Update README.md 2020-09-27 20:27:29 +09:00
tom5079
57c2004e46 Update README.md 2020-09-27 20:21:45 +09:00
tom5079
c6b069bbfb Update README.md 2020-09-27 20:19:53 +09:00
tom5079
c18bffd08f Fixed app crashing when thumbnail is null 2020-09-27 20:18:04 +09:00
tom5079
47ec181439 Fixed app crashing when thumbnail is null 2020-09-27 20:15:43 +09:00
tom5079
90ad40b1b7 Update README.md 2020-09-27 19:32:18 +09:00
tom5079
4d3f20cf98 Update README.md 2020-09-27 19:31:30 +09:00
tom5079
86df9d52bc Update README.md 2020-09-27 19:15:46 +09:00
tom5079
1bd025e070 Fixed ProxyDialog not showing up 2020-09-27 15:09:19 +09:00
tom5079
86ee239c71 App built 2020-09-27 14:39:47 +09:00
tom5079
27d0c01e1f Don't refresh onResume 2020-09-27 14:37:16 +09:00
tom5079
7a9507be01 Somewhat working 2020-09-27 14:29:02 +09:00
tom5079
1490035893 Does not work 2020-09-27 10:04:26 +09:00
tom5079
a6afcb0ed0 Consistent usage of quotation marks 2020-09-26 22:41:51 +09:00
tom5079
ea7e8584cb Consistent usage of quotation marks 2020-09-26 22:36:48 +09:00
tom5079
608c6e6a1d App built 2020-09-26 21:01:36 +09:00
tom5079
bb2c91145f Dependency update 2020-09-26 20:58:46 +09:00
tom5079
db074df0f7 Fixed Download Concurrency issue
Fixed image not showing up after reader is paused and resumed
2020-09-26 11:07:35 +09:00
tom5079
f7c45df9a6 Tag favorite bug fix 2020-09-26 09:36:20 +09:00
tom5079
44e3d16cd6 Merge branch 'dev' into master 2020-09-26 09:08:29 +09:00
tom5079
a973cdfe0b Download Bug fix
Added favorite to TagChip
Improved eyeblink recognition
2020-09-26 09:07:52 +09:00
tom5079
fca42c79a8 Updated startActivityForResult to launchers 2020-09-25 15:39:07 +09:00
tom5079
f236775599 Bug fix
Remember thin mode preference
TagChip favorites
2020-09-25 15:17:05 +09:00
tom5079
360decd37c FloatingSearchView migration 2020-09-16 14:31:45 +09:00
tom5079
998433479b Merge branch 'dev' into master 2020-09-15 23:20:01 +09:00
tom5079
c7e75aacf0 Layout fix
History fix
2020-09-15 23:19:26 +09:00
tom5079
690338273a Merge branch 'dev' into master 2020-09-15 02:42:33 +09:00
tom5079
4207ea494d Bug fix 2020-09-15 02:42:18 +09:00
tom5079
265473a15a Merge branch 'dev' into master
# Conflicts:
#	app/release/app-release.apk
#	app/release/output-metadata.json
2020-09-15 02:13:53 +09:00
tom5079
b907d36770 Bug fix 2020-09-15 02:13:25 +09:00
tom5079
fee280341a Blink Recognition 2020-09-15 01:12:29 +09:00
tom5079
0f1ef70752 Bug fix 2020-09-14 22:34:51 +09:00
tom5079
0f8c68b22e Fixed to work on old Androids 2020-09-13 21:42:02 +09:00
tom5079
701017d2ca Merge branch 'face-recog' into dev 2020-09-13 21:10:29 +09:00
tom5079
be6903ca12 App built 2020-09-13 16:24:23 +09:00
tom5079
1521bc1223 Downloader Bug fix
UI Optimized
Scroller autohide, track disable
2020-09-13 16:19:32 +09:00
tom5079
7ed66b827f Implemented eye recognition
TODO: Move pages according to eye blinking
2020-09-12 20:25:55 +09:00
tom5079
df3a478ef3 Bug fix 2020-09-12 20:22:34 +09:00
tom5079
974ddf69d5 Bug fix 2020-09-12 19:33:13 +09:00
tom5079
56a91268de Fixed fastscroll 2020-09-12 19:10:15 +09:00
tom5079
3dda2f9a1c Dependency update 2020-09-12 12:35:10 +09:00
tom5079
ed20456f9f VD Optimization 2020-09-12 12:17:02 +09:00
tom5079
281d4a0023 Adjusted Fastscroll UI
VD Optimized
2020-09-12 11:23:38 +09:00
tom5079
2170403662 Fixed Image UI 2020-09-12 10:32:23 +09:00
tom5079
b1c1e96135 Smol fix 2020-09-11 20:45:31 +09:00
tom5079
a8de1429c1 Bug fix 2020-09-11 19:53:49 +09:00
tom5079
3ba6cb81ae Bug fix 2020-09-11 19:40:56 +09:00
224 changed files with 8714 additions and 10998 deletions

47
.gitignore vendored
View File

@@ -1,20 +1,33 @@
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml *.iml
.gradle .idea/
/local.properties misc.xml
/.idea/caches deploymentTargetDropDown.xml
/.idea/libraries render.experimental.xml
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
#Github pages # Keystore files
/gh-pages *.jks
*.keystore
#Private files # Google Services (e.g. APIs or Firebase)
**/google-services.json google-services.json
**/credentials.json
# Android Profiling
*.hprof

View File

@@ -1,139 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="RIGHT_MARGIN" value="120" />
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="java.util" alias="false" withSubpackages="false" />
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="java" alias="false" withSubpackages="true" />
<package name="javax" alias="false" withSubpackages="true" />
<package name="kotlin" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -1,6 +0,0 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value=" Pupil, Hitomi.la viewer for Android&#10; Copyright (C) &amp;#36;today.year tom5079&#10;&#10; This program is free software: you can redistribute it and/or modify&#10; it under the terms of the GNU General Public License as published by&#10; the Free Software Foundation, either version 3 of the License, or&#10; (at your option) any later version.&#10;&#10; This program is distributed in the hope that it will be useful,&#10; but WITHOUT ANY WARRANTY; without even the implied warranty of&#10; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the&#10; GNU General Public License for more details.&#10;&#10; You should have received a copy of the GNU General Public License&#10; along with this program. If not, see &lt;http://www.gnu.org/licenses/&gt;." />
<option name="myName" value="GPL" />
</copyright>
</component>

View File

@@ -1,7 +0,0 @@
<component name="CopyrightManager">
<settings>
<module2copyright>
<element module="Pupil" copyright="GPL" />
</module2copyright>
</settings>
</component>

View File

@@ -1,7 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="tom50">
<words>
<w>hitomi</w>
</words>
</dictionary>
</component>

4
.idea/encodings.xml generated
View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
</project>

20
.idea/gradle.xml generated
View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="PLATFORM" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@@ -1,65 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven2" />
<option name="name" value="maven2" />
<option name="url" value="http://guardian.github.com/maven/repo-releases" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven" />
<option name="name" value="maven" />
<option name="url" value="https://jitpack.io" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="maven2" />
<option name="name" value="maven2" />
<option name="url" value="https://guardian.github.com/maven/repo-releases" />
</remote-repository>
<remote-repository>
<option name="id" value="maven3" />
<option name="name" value="maven3" />
<option name="url" value="https://s3.amazonaws.com/fabric-artifacts-private/internal-snapshots" />
</remote-repository>
<remote-repository>
<option name="id" value="maven4" />
<option name="name" value="maven4" />
<option name="url" value="https://maven.fabric.io/public" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenLocal" />
<option name="name" value="MavenLocal" />
<option name="url" value="file:/$USER_HOME$/.m2/repository/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenLocal" />
<option name="name" value="MavenLocal" />
<option name="url" value="file:/$USER_HOME$/.m2/repository" />
</remote-repository>
</component>
</project>

View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinCodeInsightWorkspaceSettings">
<option name="addUnambiguousImportsOnTheFly" value="true" />
<option name="optimizeImportsOnTheFly" value="true" />
</component>
</project>

6
.idea/kotlinc.xml generated
View File

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

9
.idea/misc.xml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

View File

@@ -1,3 +0,0 @@
<component name="DependencyValidationManager">
<scope name="Pupil" pattern="file[app]:*/" />
</component>

7
.idea/vcs.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/gh-pages" vcs="Git" />
</component>
</project>

View File

@@ -1,18 +1,13 @@
# Pupil
![Banner](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/pupil-banner.png?raw=true) ![Banner](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/pupil-banner.png?raw=true)
*Pupil, Hitomi.la viewer for Android* *Pupil, Hitomi.la viewer for Android*
![](https://img.shields.io/github/downloads/tom5079/Pupil/total)
[![](https://img.shields.io/github/downloads/tom5079/Pupil/5.3.18/Pupil-v5.3.18.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.3.18/Pupil-v5.3.18.apk)
[![](https://discordapp.com/api/guilds/610452916612104194/embed.png?style=banner2)](https://discord.gg/Stj4b5v) [![](https://discordapp.com/api/guilds/610452916612104194/embed.png?style=banner2)](https://discord.gg/Stj4b5v)
# Screenshot # Features
![Main Screen](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/main-screenshot.png?raw=true)
*Main Screen*
![Reader Screen](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/reader-screenshot.png?raw=true) ![Main Screen](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/main-screenshot.jpg?raw=true)
*Reader Screen*
Images are censored to be SFW
# Installation # Installation
@@ -22,8 +17,13 @@ or Build app yourself
# Manual # Manual
[Manual](https://tom5079.github.io/Pupil/2019/06/06/manual-kr.html) is only available in Korean. Consider using translator. [Manual](https://tom5079.github.io/Pupil/2019/06/06/manual-kr.html) is only available in Korean.
Consider using translator.
# Contribution # 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,115 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlinx-serialization'
apply plugin: 'com.google.android.gms.oss-licenses-plugin'
if (file("google-services.json").exists() && file("src/debug/google-services.json").exists()) {
logger.lifecycle("Firebase Enabled")
apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics'
apply plugin: 'com.google.firebase.firebase-perf'
} else {
logger.lifecycle("Firebase Disabled")
}
android {
compileSdkVersion 30
defaultConfig {
applicationId "xyz.quaver.pupil"
minSdkVersion 16
targetSdkVersion 30
versionCode 58
versionName "5.0-hotfix3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
buildTypes {
debug {
minifyEnabled true
shrinkResources true
debuggable true
applicationIdSuffix ".debug"
versionNameSuffix "-DEBUG"
buildConfigField('Boolean', 'CENSOR', 'false')
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
ext.enableCrashlytics = false
ext.alwaysUpdateBuildId = false
}
release {
minifyEnabled true
shrinkResources true
buildConfigField('Boolean', 'CENSOR', 'false')
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += '-Xuse-experimental=kotlin.Experimental'
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
buildToolsVersion = '29.0.3'
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
//implementation "org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC"
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.1'
implementation 'androidx.preference:preference:1.1.1'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "androidx.biometric:biometric:1.0.1"
implementation 'androidx.fragment:fragment-ktx:1.2.5'
implementation "com.daimajia.swipelayout:library:1.2.0@aar"
implementation 'com.google.android.material:material:1.3.0-alpha02'
implementation 'com.google.firebase:firebase-core:17.5.0'
implementation 'com.google.firebase:firebase-analytics:17.5.0'
implementation 'com.google.firebase:firebase-crashlytics:17.2.1'
implementation 'com.google.firebase:firebase-perf:19.0.8'
implementation 'com.google.android.gms:play-services-oss-licenses:17.0.0'
implementation 'com.github.arimorty:floatingsearchview:2.1.1'
implementation 'com.github.clans:fab:1.6.4'
//implementation 'com.quiph.ui:recyclerviewfastscroller:0.2.1'
//noinspection GradleDependency
implementation 'com.squareup.okhttp3:okhttp:3.12.12'
implementation 'com.github.bumptech.glide:glide:4.11.0'
implementation ("com.github.bumptech.glide:okhttp3-integration:4.11.0") {
transitive = false
}
implementation 'com.github.bumptech.glide:annotations:4.11.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'
kapt 'com.github.bumptech.glide:compiler:4.11.0'
implementation ("com.github.bumptech.glide:recyclerview-integration:4.11.0") {
transitive = false
}
implementation 'com.tbuonomo.andrui:viewpagerdotsindicator:4.1.2'
implementation 'net.rdrei.android.dirchooser:library:3.2@aar'
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
implementation 'com.andrognito.patternlockview:patternlockview:1.0.0'
//implementation 'com.andrognito.pinlockview:pinlockview:2.1.0'
implementation "ru.noties.markwon:core:3.1.0"
implementation ("xyz.quaver:libpupil:1.6") {
exclude group: 'org.jetbrains.kotlinx', module: 'kotlinx-serialization-core-jvm'
}
implementation "xyz.quaver:documentfilex:0.2.15"
testImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test:rules:1.3.0'
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}
androidExtensions {
experimental = true
}

129
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,129 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlinx.serialization)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.gms.oss.licenses)
alias(libs.plugins.gms.google.services)
alias(libs.plugins.firebase.crashlytics)
alias(libs.plugins.firebase.perf)
id("kotlin-parcelize")
}
android {
namespace = "xyz.quaver.pupil"
compileSdk = 35
defaultConfig {
applicationId = "xyz.quaver.pupil"
minSdk = 21
targetSdk = 35
versionCode = 70
versionName = "5.3.22"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
buildFeatures {
buildConfig = true
compose = true
}
buildTypes {
debug {
isMinifyEnabled = false
isShrinkResources = false
isDebuggable = true
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
extra.apply {
set("enableCrashlytics", false)
set("alwaysUpdateBuildId", false)
}
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
buildFeatures {
viewBinding = true
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
implementation(libs.kotlin.stdlib.jdk8)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
implementation(libs.androidx.compose.runtime)
implementation(libs.core.ktx)
implementation(libs.appcompat)
implementation(libs.activity.ktx)
implementation(libs.fragment.ktx)
implementation(libs.preference.ktx)
implementation(libs.recyclerview)
implementation(libs.constraintlayout)
implementation(libs.gridlayout)
implementation(libs.biometric)
implementation(libs.work.runtime.ktx)
implementation(libs.library)
implementation(libs.material)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics.ktx)
implementation(libs.firebase.crashlytics.ktx)
implementation(libs.firebase.perf.ktx)
implementation(libs.play.services.oss.licenses)
implementation(libs.play.services.mlkit.face.detection)
implementation(libs.fab)
implementation(libs.bigimageviewer)
implementation(libs.frescoimageloader)
implementation(libs.frescoimageviewfactory)
implementation(libs.imagepipeline.okhttp3)
//noinspection GradleDependency
implementation(libs.okhttp)
implementation(libs.ktor.network)
implementation(libs.dotsindicator)
implementation(libs.pinlockview)
implementation(libs.patternlockview)
implementation(libs.core)
implementation(libs.ripplebackground.library)
implementation(libs.recyclerview.fastscroller)
implementation(libs.jsoup)
implementation(libs.documentfilex)
implementation(libs.floatingsearchview)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.ext.junit)
androidTestImplementation(libs.rules)
androidTestImplementation(libs.runner)
androidTestImplementation(libs.espresso.core)
}

View File

@@ -22,21 +22,6 @@
-dontobfuscate -dontobfuscate
-keep public class * implements com.bumptech.glide.module.GlideModule
-keep class * extends com.bumptech.glide.module.AppGlideModule {
<init>(...);
}
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
*** rewind();
}
-keep public class * extends com.bumptech.glide.module.AppGlideModule
-keep class com.bumptech.glide.GeneratedAppGlideModuleImpl
-keepattributes *Annotation*, InnerClasses -keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.SerializationKt -dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; } # <-- change package name to your app's -keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; } # <-- change package name to your app's
@@ -48,4 +33,5 @@
} }
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment -keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment -keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
-keep class xyz.quaver.pupil.util.Preferences -keep class xyz.quaver.pupil.** { *; }
-keep class app.cash.zipline.** { *; }

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,5 @@
{ {
"version": 1, "version": 3,
"artifactType": { "artifactType": {
"type": "APK", "type": "APK",
"kind": "Directory" "kind": "Directory"
@@ -10,11 +10,28 @@
{ {
"type": "SINGLE", "type": "SINGLE",
"filters": [], "filters": [],
"properties": [], "attributes": [],
"versionCode": 58, "versionCode": 70,
"versionName": "5.0-hotfix3", "versionName": "5.3.22",
"enabled": true,
"outputFile": "app-release.apk" "outputFile": "app-release.apk"
} }
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
] ]
} }
],
"minSdkVersionForDexing": 21
}

View File

@@ -20,10 +20,18 @@
package xyz.quaver.pupil package xyz.quaver.pupil
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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. * Instrumented test, which will execute on an Android device.
@@ -32,10 +40,144 @@ import org.junit.runner.RunWith
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest { 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 @Test
fun useAppContext() { fun test_empty() {
// Context of the app under test. print(
val appContext = InstrumentationRegistry.getInstrumentation().targetContext "".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,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?><!--
<!--
~ Pupil, Hitomi.la viewer for Android ~ Pupil, Hitomi.la viewer for Android
~ Copyright (C) 2020 tom5079 ~ Copyright (C) 2020 tom5079
~ ~
@@ -17,6 +16,4 @@
~ along with this program. If not, see <http://www.gnu.org/licenses/>. ~ along with this program. If not, see <http://www.gnu.org/licenses/>.
--> -->
<resources xmlns:tools="http://schemas.android.com/tools"> <resources></resources>
<string name="app_name" translatable="false" tools:override="true">Pupil-Debug</string>
</resources>

View File

@@ -1,15 +1,26 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools">
package="xyz.quaver.pupil">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="32"
tools:ignore="CoarseFineLocation" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application <application
android:name=".Pupil" android:name=".Pupil"
@@ -24,6 +35,10 @@
tools:replace="android:theme" tools:replace="android:theme"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="face" />
<provider <provider
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -37,7 +52,16 @@
</provider> </provider>
<service android:name=".services.DownloadService" <service android:name=".services.DownloadService"
android:exported="false"/> android:exported="false"
android:foregroundServiceType="dataSync" />
<service android:name=".services.TransferClientService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service android:name=".services.TransferServerService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<receiver <receiver
android:name=".receiver.UpdateBroadcastReceiver" android:name=".receiver.UpdateBroadcastReceiver"
@@ -51,190 +75,124 @@
<activity <activity
android:name=".ui.ReaderActivity" android:name=".ui.ReaderActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"
android:parentActivityName=".ui.MainActivity"> android:parentActivityName=".ui.MainActivity"
<intent-filter> android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="hitomi.la" <data android:scheme="https" />
android:pathPrefix="/galleries" <data android:host="*.hasha.in"/>
android:scheme="http" /> <data android:pathPrefix="/reader"/>
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="hitomi.la" <data android:scheme="https" />
android:pathPrefix="/manga" <data android:host="hitomi.la"/>
android:scheme="http" /> <data android:pathPrefix="/galleries"/>
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="hitomi.la" <data android:scheme="https" />
android:pathPrefix="/doujinshi" <data android:host="hitomi.la" />
android:scheme="http" /> <data android:pathPrefix="/manga" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="hitomi.la" <data android:scheme="https" />
android:pathPrefix="/cg" <data android:host="hitomi.la" />
android:scheme="http" /> <data android:pathPrefix="/doujinshi" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="hitomi.la" <data android:scheme="https" />
android:pathPrefix="/reader" <data android:host="hitomi.la" />
android:scheme="http" /> <data android:pathPrefix="/cg" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="hitomi.la" <data android:scheme="https" />
android:pathPrefix="/galleries" <data android:host="hitomi.la" />
android:scheme="https" /> <data android:pathPrefix="/imageset" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="hitomi.la" <data android:scheme="https" />
android:pathPrefix="/manga" <data android:host="hitomi.la" />
android:scheme="https" /> <data android:pathPrefix="/reader" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="http" />
android:host="hitomi.la" <data android:host="e-hentai.org" />
android:pathPrefix="/doujinshi" <data android:pathPrefix="/g" />
android:scheme="https" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data android:scheme="https" />
android:host="hitomi.la" <data android:host="e-hentai.org" />
android:pathPrefix="/cg" <data android:pathPrefix="/g" />
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPrefix="/reader"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hiyobi.me"
android:scheme="http"
android:pathPrefix="/reader" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hiyobi.me"
android:pathPrefix="/reader"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="e-hentai.org"
android:pathPrefix="/g"
android:scheme="http" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="e-hentai.org"
android:pathPrefix="/g"
android:scheme="https" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".ui.SettingsActivity" android:name=".ui.SettingsActivity"
android:label="@string/settings_title"> android:label="@string/settings_title">
<tools:validation testUrl="http://ix.io/eer" />
</activity> </activity>
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"
android:theme="@style/NoActionBarAppTheme"> android:theme="@style/NoActionBarAppTheme"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="http"
android:host="ix.io"
android:pathPattern="/..*" />
</intent-filter>
</activity> </activity>
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" /> <activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
<activity android:name=".ui.TransferActivity" />
</application> </application>
</manifest> </manifest>

View File

@@ -18,33 +18,47 @@
package xyz.quaver.pupil package xyz.quaver.pupil
import android.app.* import android.app.Application
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.facebook.imagepipeline.backends.okhttp3.OkHttpImagePipelineConfigFactory
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.fresco.FrescoImageLoader
import com.google.android.gms.common.GooglePlayServicesNotAvailableException import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException import com.google.android.gms.common.GooglePlayServicesRepairableException
import com.google.android.gms.security.ProviderInstaller import com.google.android.gms.security.ProviderInstaller
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import okhttp3.Dispatcher
import kotlinx.coroutines.launch
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Response import okhttp3.Response
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.pupil.hitomi.evaluationContext
import xyz.quaver.pupil.hitomi.readText
import xyz.quaver.pupil.types.Tag import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.* import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.setClient
import java.io.File import java.io.File
import java.net.URL
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateFactory
import java.util.* import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import kotlin.reflect.KClass import kotlin.reflect.KClass
typealias PupilInterceptor = (Interceptor.Chain) -> Response typealias PupilInterceptor = (Interceptor.Chain) -> Response
@@ -66,16 +80,49 @@ var clientHolder: OkHttpClient? = null
val client: OkHttpClient val client: OkHttpClient
get() = clientHolder ?: clientBuilder.build().also { get() = clientHolder ?: clientBuilder.build().also {
clientHolder = it clientHolder = it
setClient(it) }
fun getSSLContext(context: Context): SSLContext {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null)
val certificateFactory = CertificateFactory.getInstance("X.509")
val certificate = context.resources.openRawResource(R.raw.isrgrootx1).use {
certificateFactory.generateCertificate(it)
}
keyStore.setCertificateEntry("isrgrootx1", certificate)
val defaultTrustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
defaultTrustManagerFactory.init(null as KeyStore?)
defaultTrustManagerFactory.trustManagers.filterIsInstance(X509TrustManager::class.java).forEach { trustManager ->
trustManager.acceptedIssuers.forEach { acceptedIssuer ->
keyStore.setCertificateEntry(acceptedIssuer.subjectDN.name, acceptedIssuer)
}
}
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(keyStore)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustManagerFactory.trustManagers, SecureRandom())
return sslContext
} }
class Pupil : Application() { class Pupil : Application() {
companion object {
init { lateinit var instance: Pupil
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) private set
} }
override fun onCreate() { override fun onCreate() {
instance = this
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
preferences = PreferenceManager.getDefaultSharedPreferences(this) preferences = PreferenceManager.getDefaultSharedPreferences(this)
val userID = Preferences["user_id", ""].let { userID -> val userID = Preferences["user_id", ""].let { userID ->
@@ -83,24 +130,35 @@ class Pupil : Application() {
else userID else userID
} }
FirebaseApp.initializeApp(this)
FirebaseCrashlytics.getInstance().setUserId(userID) FirebaseCrashlytics.getInstance().setUserId(userID)
val proxyInfo = getProxyInfo() val proxyInfo = getProxyInfo()
clientBuilder = OkHttpClient.Builder() clientBuilder = OkHttpClient.Builder()
.connectTimeout(0, TimeUnit.SECONDS) // .connectTimeout(0, TimeUnit.SECONDS)
.sslSocketFactory(getSSLContext(this).socketFactory)
.readTimeout(0, TimeUnit.SECONDS) .readTimeout(0, TimeUnit.SECONDS)
.proxyInfo(proxyInfo) .proxyInfo(proxyInfo)
.addInterceptor { chain -> .addInterceptor { chain ->
val request = chain.request() val request = chain.request().newBuilder()
.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36")
.header("Referer", "https://hitomi.la/")
.build()
val tag = request.tag() ?: return@addInterceptor chain.proceed(request) val tag = request.tag() ?: return@addInterceptor chain.proceed(request)
interceptors[tag::class]?.invoke(chain) ?: chain.proceed(request) interceptors[tag::class]?.invoke(chain) ?: chain.proceed(request)
}.apply {
(Preferences.get<String>("max_concurrent_download").toIntOrNull() ?: 0).let {
if (it != 0)
dispatcher(Dispatcher(Executors.newFixedThreadPool(it)))
}
} }
try { try {
Preferences.get<String>("download_folder").also { Preferences.get<String>("download_folder").also {
if (it.startsWith("content") && Build.VERSION.SDK_INT > 19) if (it.startsWith("content://"))
contentResolver.takePersistableUriPermission( contentResolver.takePersistableUriPermission(
Uri.parse(it), Uri.parse(it),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
@@ -108,34 +166,28 @@ class Pupil : Application() {
if (!FileX(this, it).canWrite()) if (!FileX(this, it).canWrite())
throw Exception() throw Exception()
DownloadManager.getInstance(this).migrate()
} }
} catch (e: Exception) { } catch (e: Exception) {
Preferences.remove("download_folder") Preferences.remove("download_folder")
} }
if (!Preferences["reset_secure", false]) {
Preferences["security_mode"] = false
Preferences["reset_secure"] = true
}
histories = SavedSet(File(ContextCompat.getDataDir(this), "histories.json"), 0) histories = SavedSet(File(ContextCompat.getDataDir(this), "histories.json"), 0)
favorites = SavedSet(File(ContextCompat.getDataDir(this), "favorites.json"), 0) favorites = SavedSet(File(ContextCompat.getDataDir(this), "favorites.json"), 0)
favoriteTags = SavedSet(File(ContextCompat.getDataDir(this), "favorites_tags.json"), Tag.parse("")) favoriteTags = SavedSet(File(ContextCompat.getDataDir(this), "favorites_tags.json"), Tag.parse(""))
searchHistory = SavedSet(File(ContextCompat.getDataDir(this), "search_histories.json"), "") searchHistory = SavedSet(File(ContextCompat.getDataDir(this), "search_histories.json"), "")
if (Preferences["new_history"]) { favoriteTags.filter { it.tag.contains('_') }.forEach {
CoroutineScope(Dispatchers.IO).launch { favoriteTags.remove(it)
histories.reversed().let {
histories.clear()
histories.addAll(it)
}
favorites.reversed().let {
favorites.clear()
favorites.addAll(it)
}
}
Preferences["new_history"] = true
} }
/*
if (BuildConfig.DEBUG) if (BuildConfig.DEBUG)
FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false) FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)*/
try { try {
ProviderInstaller.installIfNeeded(this) ProviderInstaller.installIfNeeded(this)
@@ -145,6 +197,15 @@ class Pupil : Application() {
e.printStackTrace() e.printStackTrace()
} }
BigImageViewer.initialize(
FrescoImageLoader.with(
this,
OkHttpImagePipelineConfigFactory
.newBuilder(this, client)
.build()
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -175,6 +236,13 @@ class Pupil : Application() {
enableVibration(false) enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET lockscreenVisibility = Notification.VISIBILITY_SECRET
}) })
manager.createNotificationChannel(NotificationChannel("transfer", getString(R.string.channel_transfer), NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.channel_transfer_description)
enableLights(false)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
} }
AppCompatDelegate.setDefaultNightMode(when (Preferences.get<Boolean>("dark_mode")) { AppCompatDelegate.setDefaultNightMode(when (Preferences.get<Boolean>("dark_mode")) {

View File

@@ -18,119 +18,97 @@
package xyz.quaver.pupil.adapters package xyz.quaver.pupil.adapters
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.SparseBooleanArray
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.Toast
import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.vectordrawable.graphics.drawable.Animatable2Compat import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.daimajia.swipe.SwipeLayout import com.daimajia.swipe.SwipeLayout
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
import com.daimajia.swipe.interfaces.SwipeAdapterInterface import com.daimajia.swipe.interfaces.SwipeAdapterInterface
import kotlinx.android.synthetic.main.item_galleryblock.view.* import com.github.piasy.biv.loader.ImageLoader
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.quaver.hitomi.getReader
import xyz.quaver.io.util.getChild import xyz.quaver.io.util.getChild
import xyz.quaver.pupil.BuildConfig
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.GalleryblockItemBinding
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.favorites import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.hitomi.getGallery
import xyz.quaver.pupil.hitomi.getGalleryInfo
import xyz.quaver.pupil.types.Tag import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.view.TagChip import xyz.quaver.pupil.ui.view.ProgressCard
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.wordCapitalize import xyz.quaver.pupil.util.wordCapitalize
import java.util.* import java.io.File
import kotlin.collections.ArrayList
import kotlin.concurrent.schedule
class GalleryBlockAdapter(private val glide: RequestManager, private val galleries: List<Int>) : RecyclerSwipeAdapter<RecyclerView.ViewHolder>(), SwipeAdapterInterface { class GalleryBlockAdapter(private val galleries: List<Int>) : RecyclerSwipeAdapter<RecyclerView.ViewHolder>(), SwipeAdapterInterface {
enum class ViewType { var updateAll = true
NEXT, var thin: Boolean = Preferences["thin"]
GALLERY,
PREV
}
val timer = Timer() inner class GalleryViewHolder(val binding: GalleryblockItemBinding) : RecyclerView.ViewHolder(binding.root) {
private var galleryID: Int = 0
var isThin = false
inner class GalleryViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
var timerTask: TimerTask? = null
private fun updateProgress(context: Context, galleryID: Int) {
val cache = Cache.getInstance(context, galleryID)
init {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
if (cache.metadata.reader == null || Preferences["cache_disable"]) { while (updateAll) {
view.galleryblock_progressbar.visibility = View.GONE updateProgress(itemView.context)
view.galleryblock_progress_complete.visibility = View.GONE delay(1000)
return@launch }
}
} }
with(view.galleryblock_progressbar) { private fun updateProgress(context: Context) = CoroutineScope(Dispatchers.Main).launch {
val imageList = cache.metadata.imageList!! with(binding.galleryblockCard) {
val imageList = Cache.getInstance(context, galleryID).metadata.imageList
progress = imageList.filterNotNull().size if (imageList == null) {
max = 0
return@with
}
progress = imageList.count { it != null }
max = imageList.size max = imageList.size
if (visibility == View.GONE) this@GalleryViewHolder.binding.galleryblockId.setOnClickListener {
visibility = View.VISIBLE (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText("gallery_id", galleryID.toString())
)
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
if (progress == max) { type = if (!imageList.contains(null)) {
val downloadManager = DownloadManager.getInstance(context) val downloadManager = DownloadManager.getInstance(context)
if (completeFlag.get(galleryID, false)) { if (downloadManager.getDownloadFolder(galleryID) == null)
with(view.galleryblock_progress_complete) { ProgressCard.Type.CACHE
setImageResource( else
if (downloadManager.getDownloadFolder(galleryID) != null) ProgressCard.Type.DOWNLOAD
R.drawable.ic_progressbar
else R.drawable.ic_progressbar_cache
)
visibility = View.VISIBLE
}
} else {
with(view.galleryblock_progress_complete) {
setImageDrawable(AnimatedVectorDrawableCompat.create(context,
if (downloadManager.getDownloadFolder(galleryID) != null)
R.drawable.ic_progressbar_complete
else R.drawable.ic_progressbar_complete_cache
).apply {
this?.start()
})
visibility = View.VISIBLE
}
completeFlag.put(galleryID, true)
}
} else } else
view.galleryblock_progress_complete.visibility = View.INVISIBLE ProgressCard.Type.LOADING
}
} }
} }
fun bind(galleryID: Int) { fun bind(galleryID: Int) {
val cache = Cache.getInstance(view.context, galleryID) this.galleryID = galleryID
updateProgress(itemView.context)
val galleryBlock = cache.metadata.galleryBlock ?: return val cache = Cache.getInstance(itemView.context, galleryID)
with(view) { CoroutineScope(Dispatchers.IO).launch {
val resources = context.resources val galleryBlock = cache.getGalleryBlock() ?: return@launch
launch(Dispatchers.Main) {
val resources = itemView.context.resources
val languages = resources.getStringArray(R.array.languages).map { val languages = resources.getStringArray(R.array.languages).map {
it.split("|").let { split -> it.split("|").let { split ->
Pair(split[0], split[1]) Pair(split[0], split[1])
@@ -140,65 +118,60 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
val artists = galleryBlock.artists val artists = galleryBlock.artists
val series = galleryBlock.series val series = galleryBlock.series
if (isThin) binding.galleryblockThumbnail.apply {
galleryblock_thumbnail.layoutParams.width = context.resources.getDimensionPixelSize( setOnClickListener {
R.dimen.galleryblock_thumbnail_thin itemView.performClick()
) }
setOnLongClickListener {
itemView.performLongClick()
}
setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
setImageLoaderCallback(object: ImageLoader.Callback {
override fun onFail(error: Exception?) {
Cache.delete(context, galleryID)
}
galleryblock_thumbnail.setImageDrawable(CircularProgressDrawable(context).also { override fun onCacheHit(imageType: Int, image: File?) {}
it.start() override fun onCacheMiss(imageType: Int, image: File?) {}
override fun onFinish() {}
override fun onProgress(progress: Int) {}
override fun onStart() {}
override fun onSuccess(image: File?) {}
}) })
ssiv?.recycle()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val thumbnail = cache.getThumbnail() cache.getThumbnail().let { launch(Dispatchers.Main) {
showImage(it)
glide } }
.load(thumbnail)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.error(R.drawable.image_broken_variant)
.listener(object: RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
Cache.getInstance(context, galleryID).let {
it.cacheFolder.getChild(".thumbnail").let { if (it.exists()) it.delete() }
it.downloadFolder?.getChild(".thumbnail")?.let { if (it.exists()) it.delete() }
} }
return false
} }
override fun onResourceReady( binding.galleryblockTitle.text = galleryBlock.title
resource: Drawable?, with(binding.galleryblockArtist) {
model: Any?, text = artists.joinToString { it.wordCapitalize() }
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean = false
})
.apply {
if (BuildConfig.CENSOR)
override(5, 8)
}.let { launch(Dispatchers.Main) { it.into(galleryblock_thumbnail) } }
}
if (timerTask == null)
timerTask = timer.schedule(0, 1000) {
updateProgress(context, galleryID)
}
galleryblock_title.text = galleryBlock.title
with(galleryblock_artist) {
text = artists.joinToString(", ") { it.wordCapitalize() }
visibility = when { visibility = when {
artists.isNotEmpty() -> View.VISIBLE artists.isNotEmpty() -> View.VISIBLE
else -> View.GONE else -> View.GONE
} }
CoroutineScope(Dispatchers.IO).launch {
val gallery = runCatching {
getGallery(galleryID)
}.getOrNull()
if (gallery?.groups?.isNotEmpty() != true)
return@launch
launch(Dispatchers.Main) {
text = context.getString(
R.string.galleryblock_artist_with_group,
artists.joinToString { it.wordCapitalize() },
gallery.groups.joinToString { it.wordCapitalize() }
)
} }
with(galleryblock_series) { }
}
with(binding.galleryblockSeries) {
text = text =
resources.getString( resources.getString(
R.string.galleryblock_series, R.string.galleryblock_series,
@@ -208,38 +181,61 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
else -> View.GONE else -> View.GONE
} }
} }
galleryblock_type.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize() binding.galleryblockType.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
with(galleryblock_language) { with(binding.galleryblockLanguage) {
text = text =
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language]) resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
visibility = when { visibility = when {
galleryBlock.language.isNotEmpty() -> View.VISIBLE !galleryBlock.language.isNullOrEmpty() -> View.VISIBLE
else -> View.GONE else -> View.GONE
} }
} }
galleryblock_tag_group.removeAllViews() with(binding.galleryblockTagGroup) {
galleryBlock.relatedTags.forEach { onClickListener = {
galleryblock_tag_group.addView(TagChip(context, Tag.parse(it)).apply { onChipClickedHandler.forEach { callback ->
setOnClickListener { view -> callback.invoke(it)
for (callback in onChipClickedHandler)
callback.invoke((view as TagChip).tag)
} }
})
} }
galleryblock_id.text = galleryBlock.id.toString() tags.clear()
galleryblock_pagecount.text = "-"
CoroutineScope(Dispatchers.IO).launch {
tags.addAll(
galleryBlock.relatedTags.sortedBy {
val tag = Tag.parse(it)
if (favoriteTags.contains(tag))
-1
else
when(Tag.parse(it).area) {
"female" -> 0
"male" -> 1
else -> 2
}
}.map {
Tag.parse(it)
}
)
launch(Dispatchers.Main) {
refresh()
}
}
}
binding.galleryblockId.text = galleryBlock.id.toString()
binding.galleryblockPagecount.text = "-"
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val pageCount = kotlin.runCatching { val pageCount = kotlin.runCatching {
getReader(galleryBlock.id).galleryInfo.files.size getGalleryInfo(galleryBlock.id).files.size
}.getOrNull() ?: return@launch }.getOrNull() ?: return@launch
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
galleryblock_pagecount.text = context.getString(R.string.galleryblock_pagecount, pageCount) binding.galleryblockPagecount.text = itemView.context.getString(R.string.galleryblock_pagecount, pageCount)
} }
} }
with(galleryblock_favorite) { with(binding.galleryblockFavorite) {
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty) setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
setOnClickListener { setOnClickListener {
when { when {
@@ -267,93 +263,49 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
} }
}
}
// Make some views invisible to make it thinner // Make some views invisible to make it thinner
if (isThin) { if (thin) {
galleryblock_language.visibility = View.GONE binding.galleryblockTagGroup.visibility = View.GONE
galleryblock_type.visibility = View.GONE
galleryblock_tag_group.visibility = View.GONE
} }
} }
} }
}
class NextViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
class PrevViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
class ViewHolderFactory {
companion object {
fun getLayoutID(type: Int): Int {
return when(ViewType.values()[type]) {
ViewType.NEXT -> R.layout.item_next
ViewType.PREV -> R.layout.item_prev
ViewType.GALLERY -> R.layout.item_galleryblock
}
}
}
}
val completeFlag = SparseBooleanArray()
val onChipClickedHandler = ArrayList<((Tag) -> Unit)>() val onChipClickedHandler = ArrayList<((Tag) -> Unit)>()
var onDownloadClickedHandler: ((Int) -> Unit)? = null var onDownloadClickedHandler: ((Int) -> Unit)? = null
var onDeleteClickedHandler: ((Int) -> Unit)? = null var onDeleteClickedHandler: ((Int) -> Unit)? = null
var showNext = false
var showPrev = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return GalleryViewHolder(GalleryblockItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
fun getViewHolder(type: Int, view: View): RecyclerView.ViewHolder {
return when(ViewType.values()[type]) {
ViewType.NEXT -> NextViewHolder(view as LinearLayout)
ViewType.PREV -> PrevViewHolder(view as LinearLayout)
ViewType.GALLERY -> GalleryViewHolder(view as CardView)
}
}
return getViewHolder(
viewType,
LayoutInflater.from(parent.context).inflate(
ViewHolderFactory.getLayoutID(viewType),
parent,
false
)
)
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is GalleryViewHolder) { if (holder is GalleryViewHolder) {
val galleryID = galleries[position-(if (showPrev) 1 else 0)] val galleryID = galleries[position]
holder.bind(galleryID) holder.bind(galleryID)
with(holder.view.galleryblock_primary) { holder.binding.galleryblockCard.binding.download.setOnClickListener {
setOnClickListener {
holder.view.performClick()
}
setOnLongClickListener {
holder.view.performLongClick()
}
}
holder.view.galleryblock_download.setOnClickListener {
onDownloadClickedHandler?.invoke(position) onDownloadClickedHandler?.invoke(position)
} }
holder.view.galleryblock_delete.setOnClickListener { holder.binding.galleryblockCard.binding.delete.setOnClickListener {
onDeleteClickedHandler?.invoke(position) onDeleteClickedHandler?.invoke(position)
} }
mItemManger.bindView(holder.view, position) mItemManger.bindView(holder.binding.root, position)
holder.view.galleryblock_swipe_layout.addSwipeListener(object: SwipeLayout.SwipeListener { holder.binding.galleryblockCard.binding.swipeLayout.addSwipeListener(object: SwipeLayout.SwipeListener {
override fun onStartOpen(layout: SwipeLayout?) { override fun onStartOpen(layout: SwipeLayout?) {
mItemManger.closeAllExcept(layout) mItemManger.closeAllExcept(layout)
holder.view.galleryblock_download.text = holder.binding.galleryblockCard.binding.download.text =
if (DownloadManager.getInstance(holder.view.context).isDownloading(galleryID)) if (DownloadManager.getInstance(holder.binding.root.context).isDownloading(galleryID))
holder.view.context.getString(android.R.string.cancel) holder.binding.root.context.getString(android.R.string.cancel)
else else
holder.view.context.getString(R.string.main_download) holder.binding.root.context.getString(R.string.main_download)
} }
override fun onClose(layout: SwipeLayout?) {} override fun onClose(layout: SwipeLayout?) {}
@@ -365,27 +317,7 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
} }
} }
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { override fun getItemCount() = galleries.size
super.onViewDetachedFromWindow(holder)
if (holder is GalleryViewHolder) { override fun getSwipeLayoutResourceId(position: Int) = R.id.swipe_layout
holder.timerTask?.cancel()
holder.timerTask = null
}
}
override fun getItemCount() =
galleries.size +
(if (showNext) 1 else 0) +
(if (showPrev) 1 else 0)
override fun getItemViewType(position: Int): Int {
return when {
showPrev && position == 0 -> ViewType.PREV
showNext && position == galleries.size+(if (showPrev) 1 else 0) -> ViewType.NEXT
else -> ViewType.GALLERY
}.ordinal
}
override fun getSwipeLayoutResourceId(position: Int) = R.id.galleryblock_swipe_layout
} }

View File

@@ -1,86 +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.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.item_mirrors.view.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.Preferences
import java.util.*
class MirrorAdapter(context: Context) : RecyclerView.Adapter<MirrorAdapter.ViewHolder>() {
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view)
val mirrors = context.resources.getStringArray(R.array.mirrors).map {
it.split('|').let { split ->
Pair(split.first(), split.last())
}
}.toMap()
val list = mirrors.keys.toMutableList().apply {
Preferences.get<String>("mirrors")
.split(">")
.reversed()
.forEach {
if (this.contains(it)) {
this.remove(it)
this.add(0, it)
}
}
}
val onItemMove : ((Int, Int) -> Unit) = { from, to ->
Collections.swap(list, from, to)
notifyItemMoved(from, to)
onItemMoved?.invoke(list)
}
var onStartDrag : ((ViewHolder) -> Unit)? = null
var onItemMoved : ((List<String>) -> (Unit))? = null
@SuppressLint("ClickableViewAccessibility")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
with(holder.view) {
mirror_name.text = mirrors[list.elementAt(position)]
mirror_button.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN)
onStartDrag?.invoke(holder)
true
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return LayoutInflater.from(parent.context).inflate(
R.layout.item_mirrors, parent, false
).let {
ViewHolder(it)
}
}
override fun getItemCount() = mirrors.size
}

View File

@@ -18,177 +18,233 @@
package xyz.quaver.pupil.adapters package xyz.quaver.pupil.adapters
import android.graphics.drawable.Drawable import android.content.Context
import android.graphics.drawable.Animatable
import android.net.Uri
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.bumptech.glide.load.DataSource import com.facebook.drawee.backends.pipeline.Fresco
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.facebook.drawee.controller.BaseControllerListener
import com.bumptech.glide.load.engine.GlideException import com.facebook.drawee.drawable.ScalingUtils
import com.bumptech.glide.load.model.GlideUrl import com.facebook.drawee.interfaces.DraweeController
import com.bumptech.glide.load.model.LazyHeaders import com.facebook.drawee.view.SimpleDraweeView
import com.bumptech.glide.request.RequestListener import com.facebook.imagepipeline.image.ImageInfo
import com.bumptech.glide.request.target.Target import com.github.piasy.biv.view.BigImageView
import kotlinx.android.synthetic.main.item_reader.view.* import com.github.piasy.biv.view.ImageShownCallback
import kotlinx.coroutines.CoroutineScope import com.github.piasy.biv.view.ImageViewFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.launch
import xyz.quaver.Code
import xyz.quaver.hitomi.Reader
import xyz.quaver.hitomi.getReferer
import xyz.quaver.hitomi.imageUrlFromImage
import xyz.quaver.hiyobi.createImgList
import xyz.quaver.io.util.readBytes
import xyz.quaver.pupil.BuildConfig
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.services.DownloadService import xyz.quaver.pupil.databinding.ReaderItemBinding
import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.pupil.ui.ReaderActivity import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.Cache
import java.util.* import java.io.File
import kotlin.concurrent.schedule
import kotlin.math.roundToInt import kotlin.math.roundToInt
class ReaderAdapter(private val activity: ReaderActivity, class ReaderAdapter(
private val galleryID: Int) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() { private val activity: ReaderActivity,
private val galleryID: Int
var reader: Reader? = null ) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
val timer = Timer() var galleryInfo: GalleryInfo? = null
private val glide = Glide.with(activity)
var isFullScreen = false var isFullScreen = false
var onItemClickListener : ((Int) -> (Unit))? = null var onItemClickListener : (() -> (Unit))? = null
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
init {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { with (binding.image) {
return LayoutInflater.from(parent.context).inflate( setImageViewFactory(FrescoImageViewFactory().apply {
R.layout.item_reader, parent, false updateView = { imageInfo ->
).let { binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
ViewHolder(it) dimensionRatio = "${imageInfo.width}:${imageInfo.height}"
}
}
})
setImageShownCallback(object : ImageShownCallback {
override fun onMainImageShown() {
binding.image.mainView.let { v ->
when (v) {
is SubsamplingScaleImageView ->
if (!isFullScreen) binding.image.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT
}
} }
} }
private var cache: Cache? = null override fun onThumbnailShown() {}
override fun onBindViewHolder(holder: ViewHolder, position: Int) { })
holder.view as ConstraintLayout
setFailureImage(ContextCompat.getDrawable(itemView.context, R.drawable.image_broken_variant))
setOnClickListener {
onItemClickListener?.invoke()
}
}
binding.root.setOnClickListener {
onItemClickListener?.invoke()
}
}
fun bind(position: Int) {
if (cache == null) if (cache == null)
cache = Cache.getInstance(holder.view.context, galleryID) cache = Cache.getInstance(itemView.context, galleryID)
if (isFullScreen) { if (!isFullScreen) {
holder.view.layoutParams.height = RecyclerView.LayoutParams.MATCH_PARENT binding.root.setBackgroundResource(R.drawable.reader_item_boundary)
holder.view.container.layoutParams.height = ConstraintLayout.LayoutParams.MATCH_PARENT binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
} else { height = 0
holder.view.layoutParams.height = RecyclerView.LayoutParams.WRAP_CONTENT dimensionRatio =
holder.view.container.layoutParams.height = 0 "${galleryInfo!!.files[position].width}:${galleryInfo!!.files[position].height}"
(holder.view.container.layoutParams as ConstraintLayout.LayoutParams)
.dimensionRatio = "W,${reader!!.galleryInfo.files[position].width}:${reader!!.galleryInfo.files[position].height}"
}
holder.view.image.setOnPhotoTapListener { _, _, _ ->
onItemClickListener?.invoke(position)
}
holder.view.container.setOnClickListener {
onItemClickListener?.invoke(position)
}
holder.view.reader_index.text = (position+1).toString()
if (Preferences["cache_disable"]) {
val lowQuality: Boolean = Preferences["low_quality"]
val url = when (reader!!.code) {
Code.HITOMI ->
GlideUrl(
imageUrlFromImage(
galleryID,
reader!!.galleryInfo.files[position],
!lowQuality
)
, LazyHeaders.Builder().addHeader("Referer", getReferer(galleryID)).build())
Code.HIYOBI ->
GlideUrl(createImgList(galleryID, reader!!, lowQuality)[position].path)
else -> null
}
holder.view.image.post {
glide
.load(url!!)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(false)
.fitCenter()
.apply {
if (BuildConfig.CENSOR)
override(5, 8)
}
.error(R.drawable.image_broken_variant)
.into(holder.view.image)
} }
} else { } else {
binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = ConstraintLayout.LayoutParams.MATCH_PARENT
dimensionRatio = null
}
binding.root.background = null
}
binding.readerIndex.text = (position+1).toString()
val image = cache!!.getImage(position) val image = cache!!.getImage(position)
val progress = activity.downloader?.progress?.get(galleryID)?.get(position) val progress = activity.downloader?.progress?.get(galleryID)?.get(position)
if (progress?.isInfinite() == true && image != null) { if (progress?.isInfinite() == true && image != null) {
holder.view.reader_item_progressbar.visibility = View.INVISIBLE binding.progressGroup.visibility = View.INVISIBLE
binding.image.showImage(image.uri)
CoroutineScope(Dispatchers.IO).launch {
glide
.load(image.readBytes())
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.fitCenter()
.apply {
if (BuildConfig.CENSOR)
override(5, 8)
}
.error(R.drawable.image_broken_variant)
.listener(object: RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
cache!!.metadata.imageList?.set(position, null)
image.delete()
DownloadService.cancel(holder.view.context, galleryID)
DownloadService.download(holder.view.context, galleryID, true)
return true
}
override fun onResourceReady(resource: Drawable?, model: Any?, target: Target<Drawable>?, dataSource: DataSource?, isFirstResource: Boolean) =
false
}).let { launch(Dispatchers.Main) { it.into(holder.view.image) } }
}
} else { } else {
holder.view.reader_item_progressbar.visibility = View.VISIBLE binding.progressGroup.visibility = View.VISIBLE
binding.readerItemProgressbar.progress =
glide.clear(holder.view.image)
holder.view.reader_item_progressbar.progress =
if (progress?.isInfinite() == true) if (progress?.isInfinite() == true)
100 100
else else
progress?.roundToInt() ?: 0 progress?.roundToInt() ?: 0
holder.view.image.setImageDrawable(null) clear()
timer.schedule(1000) {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
delay(1000)
notifyItemChanged(position) notifyItemChanged(position)
} }
} }
} }
fun clear() {
binding.image.mainView.let {
when (it) {
is SubsamplingScaleImageView ->
it.recycle()
is SimpleDraweeView ->
it.controller = null
}
}
} }
} }
override fun getItemCount() = reader?.galleryInfo?.files?.size ?: 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ReaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
private var cache: Cache? = null
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(position)
}
override fun getItemCount() = galleryInfo?.files?.size ?: 0
override fun onViewRecycled(holder: ViewHolder) {
holder.clear()
}
} }
class FrescoImageViewFactory : ImageViewFactory() {
var updateView: ((ImageInfo) -> Unit)? = null
override fun createAnimatedImageView(
context: Context, imageType: Int,
initScaleType: Int
): View {
val view = SimpleDraweeView(context)
view.hierarchy.actualImageScaleType = scaleType(initScaleType)
return view
}
override fun loadAnimatedContent(
view: View, imageType: Int,
imageFile: File
) {
if (view is SimpleDraweeView) {
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
.setUri(Uri.parse("file://" + imageFile.absolutePath))
.setAutoPlayAnimations(true)
.setControllerListener(object: BaseControllerListener<ImageInfo>() {
override fun onIntermediateImageSet(id: String?, imageInfo: ImageInfo?) {
imageInfo?.let { updateView?.invoke(it) }
}
override fun onFinalImageSet(id: String?, imageInfo: ImageInfo?, animatable: Animatable?) {
imageInfo?.let { updateView?.invoke(it) }
}
})
.build()
view.controller = controller
}
}
override fun createThumbnailView(
context: Context,
scaleType: ImageView.ScaleType, willLoadFromNetwork: Boolean
): View {
return if (willLoadFromNetwork) {
val thumbnailView = SimpleDraweeView(context)
thumbnailView.hierarchy.actualImageScaleType = scaleType(scaleType)
thumbnailView
} else {
super.createThumbnailView(context, scaleType, false)
}
}
override fun loadThumbnailContent(view: View, thumbnail: Uri) {
if (view is SimpleDraweeView) {
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
.setUri(thumbnail)
.build()
view.controller = controller
}
}
private fun scaleType(value: Int): ScalingUtils.ScaleType {
return when (value) {
BigImageView.INIT_SCALE_TYPE_CENTER -> ScalingUtils.ScaleType.CENTER
BigImageView.INIT_SCALE_TYPE_CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
BigImageView.INIT_SCALE_TYPE_CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
BigImageView.INIT_SCALE_TYPE_FIT_END -> ScalingUtils.ScaleType.FIT_END
BigImageView.INIT_SCALE_TYPE_FIT_START -> ScalingUtils.ScaleType.FIT_START
BigImageView.INIT_SCALE_TYPE_FIT_XY -> ScalingUtils.ScaleType.FIT_XY
BigImageView.INIT_SCALE_TYPE_FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
else -> ScalingUtils.ScaleType.FIT_CENTER
}
}
private fun scaleType(scaleType: ImageView.ScaleType): ScalingUtils.ScaleType {
return when (scaleType) {
ImageView.ScaleType.CENTER -> ScalingUtils.ScaleType.CENTER
ImageView.ScaleType.CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
ImageView.ScaleType.CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
ImageView.ScaleType.FIT_END -> ScalingUtils.ScaleType.FIT_END
ImageView.ScaleType.FIT_START -> ScalingUtils.ScaleType.FIT_START
ImageView.ScaleType.FIT_XY -> ScalingUtils.ScaleType.FIT_XY
ImageView.ScaleType.FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
else -> ScalingUtils.ScaleType.FIT_CENTER
}
}
}

View File

@@ -18,32 +18,35 @@
package xyz.quaver.pupil.adapters package xyz.quaver.pupil.adapters
import android.net.Uri
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.RequestManager import com.github.piasy.biv.view.BigImageView
import com.bumptech.glide.load.engine.DiskCacheStrategy import xyz.quaver.pupil.R
import xyz.quaver.pupil.BuildConfig
class ThumbnailAdapter(private val glide: RequestManager, var thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailAdapter.ViewHolder>() { class ThumbnailAdapter(var thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailAdapter.ViewHolder>() {
class ViewHolder(val view: ImageView) : RecyclerView.ViewHolder(view) class ViewHolder(val view: BigImageView) : RecyclerView.ViewHolder(view) {
fun clear() {
view.ssiv?.recycle()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ImageView(parent.context)) return ViewHolder(BigImageView(parent.context).apply {
setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
})
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
glide holder.view.showImage(Uri.parse(thumbnails[position]))
.load(thumbnails[position])
.diskCacheStrategy(DiskCacheStrategy.NONE)
.apply {
if (BuildConfig.CENSOR)
override(5, 8)
}
.into(holder.view)
} }
override fun getItemCount() = thumbnails.size override fun getItemCount() = thumbnails.size
override fun onViewRecycled(holder: ViewHolder) {
holder.clear()
}
} }

View File

@@ -21,17 +21,19 @@ package xyz.quaver.pupil.adapters
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.RequestManager
import kotlin.math.min import kotlin.math.min
class ThumbnailPageAdapter(private val glide: RequestManager, private val thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailPageAdapter.ViewHolder>() { class ThumbnailPageAdapter(private val thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailPageAdapter.ViewHolder>() {
class ViewHolder(val view: RecyclerView) : RecyclerView.ViewHolder(view) class ViewHolder(val view: RecyclerView) : RecyclerView.ViewHolder(view)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(RecyclerView(parent.context).apply { return ViewHolder(RecyclerView(parent.context).apply {
layoutManager = GridLayoutManager(parent.context, 3) val layoutManager = GridLayoutManager(parent.context, 3)
adapter = ThumbnailAdapter(glide, listOf()) val adapter = ThumbnailAdapter(listOf())
this.layoutManager = layoutManager
this.adapter = adapter
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}) })
} }
@@ -41,7 +43,7 @@ class ThumbnailPageAdapter(private val glide: RequestManager, private val thumbn
thumbnails = this@ThumbnailPageAdapter.thumbnails.slice(9*position until min(9*position+9, this@ThumbnailPageAdapter.thumbnails.size)) thumbnails = this@ThumbnailPageAdapter.thumbnails.slice(9*position until min(9*position+9, this@ThumbnailPageAdapter.thumbnails.size))
notifyDataSetChanged() notifyDataSetChanged()
holder.view.layoutManager?.scrollToPosition(itemCount-1) (holder.view.layoutManager as GridLayoutManager).scrollToPosition(8)
} }
} }

View File

@@ -0,0 +1,44 @@
package xyz.quaver.pupil.adapters
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pDeviceList
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import androidx.recyclerview.widget.RecyclerView
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.TransferPeerListItemBinding
class TransferPeersAdapter(
private val devices: Collection<WifiP2pDevice>,
private val onDeviceSelected: (WifiP2pDevice) -> Unit
): RecyclerView.Adapter<TransferPeersAdapter.ViewHolder>() {
class ViewHolder(val binding: TransferPeerListItemBinding): RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = TransferPeerListItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val device = devices.elementAt(position)
holder.binding.deviceName.text = device.deviceName
holder.binding.deviceAddress.text = device.deviceAddress
holder.binding.root.setOnClickListener {
onDeviceSelected(device)
}
}
override fun getItemCount(): Int {
return devices.size
}
}

View File

@@ -0,0 +1,308 @@
/*
* Copyright 2019 tom5079
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.quaver.pupil.hitomi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import xyz.quaver.pupil.client
import java.io.IOException
import java.net.URL
import kotlin.coroutines.resumeWithException
import kotlin.time.ExperimentalTime
const val protocol = "https:"
@Serializable
data class Artist(
val artist: String,
val url: String,
)
@Serializable
data class Group(
val group: String,
val url: String,
)
@Serializable
data class Parody(
val parody: String,
val url: String,
)
@Serializable
data class Character(
val character: String,
val url: String,
)
@Serializable
data class Tag(
val tag: String,
val url: String,
val female: String? = null,
val male: String? = null,
)
@Serializable
data class Language(
val galleryid: String,
val url: String,
val language_localname: String,
val name: String,
)
@Serializable
data class GalleryInfo(
val id: String,
val title: String,
val japanese_title: String? = null,
val language: String? = null,
val type: String,
val date: String,
val artists: List<Artist>? = null,
val groups: List<Group>? = null,
val parodys: List<Parody>? = null,
val tags: List<Tag>? = null,
val related: List<Int> = emptyList(),
val languages: List<Language> = emptyList(),
val characters: List<Character>? = null,
val scene_indexes: List<Int>? = emptyList(),
val files: List<GalleryFiles> = emptyList(),
)
val json = Json {
isLenient = true
ignoreUnknownKeys = true
allowSpecialFloatingPointValues = true
useArrayPolymorphism = true
}
typealias HeaderSetter = (Request.Builder) -> Request.Builder
fun URL.readText(settings: HeaderSetter? = null): String {
val request = Request.Builder()
.url(this).let {
settings?.invoke(it) ?: it
}.build()
return client.newCall(request).execute()
.also { if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()
?.use { it.string() } ?: throw IOException()
}
fun URL.readBytes(settings: HeaderSetter? = null): ByteArray {
val request = Request.Builder()
.url(this).let {
settings?.invoke(it) ?: it
}.build()
return client.newCall(request).execute()
.also { if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()
?.use { it.bytes() } ?: throw IOException()
}
@Suppress("EXPERIMENTAL_API_USAGE")
fun getGalleryInfo(galleryID: Int) =
json.decodeFromString<GalleryInfo>(
URL("$protocol//$domain/galleries/$galleryID.js").readText()
.replace("var galleryinfo = ", "")
)
//common.js
const val domain = "ltn.gold-usergeneratedcontent.net"
const val galleryblockextension = ".html"
const val galleryblockdir = "galleryblock"
const val nozomiextension = ".nozomi"
val evaluationContext = Dispatchers.Main + Job()
object gg {
private var lastRetrieval: Long? = null
private val mutex = Mutex()
private var mDefault = 0
private val mMap = mutableMapOf<Int, Int>()
private var b = ""
@OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class)
private suspend fun refresh() = withContext(Dispatchers.IO) {
mutex.withLock {
if (lastRetrieval == null || (lastRetrieval!! + 60000) < System.currentTimeMillis()) {
val ggjs: String = suspendCancellableCoroutine { continuation ->
val call =
client.newCall(
Request.Builder().url("https://ltn.gold-usergeneratedcontent.net/gg.js")
.build()
)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) return
continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
if (!call.isCanceled) {
response.body()?.use {
continuation.resume(it.string()) {
call.cancel()
}
}
}
}
})
continuation.invokeOnCancellation {
call.cancel()
}
}
mDefault = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt()
val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt()
mMap.clear()
Regex("case (\\d+):").findAll(ggjs).forEach {
val case = it.groupValues[1].toInt()
mMap[case] = o
}
b = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1]
lastRetrieval = System.currentTimeMillis()
}
}
}
suspend fun m(g: Int): Int {
refresh()
return mMap[g] ?: mDefault
}
suspend fun b(): String {
refresh()
return b
}
fun s(h: String): String {
val m = Regex("(..)(.)$").find(h)
return m!!.groupValues.let { it[2] + it[1] }.toInt(16).toString(10)
}
}
suspend fun subdomainFromURL(url: String, base: String? = null, dir: String? = null): String {
var retval = ""
if (base.isNullOrBlank()) {
when {
dir == "webp" -> retval = "w"
dir == "avif" -> retval = "a"
}
}
val b = 16
val r = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""")
val m = r.find(url) ?: return ""
val g = m.groupValues.let { it[2] + it[1] }.toIntOrNull(b)
if (g != null) {
retval = if (base.isNullOrEmpty()) {
retval + (1 + gg.m(g)).toString()
} else {
(97 + gg.m(g)).toChar().toString() + base
}
}
return retval
}
suspend fun urlFromUrl(url: String, base: String? = null, dir: String? = null): String {
return url.replace(
Regex("""//..?\.(?:gold-usergeneratedcontent\.net|hitomi\.la)/"""),
"//${subdomainFromURL(url, base, dir)}.gold-usergeneratedcontent.net/"
)
}
suspend fun fullPathFromHash(hash: String): String =
"${gg.b()}${gg.s(hash)}/$hash"
fun realFullPathFromHash(hash: String): String =
hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1/$hash")
suspend fun urlFromHash(
galleryID: Int,
image: GalleryFiles,
dir: String? = null,
ext: String? = null,
): String {
val ext = ext ?: dir ?: image.name.takeLastWhile { it != '.' }
return buildString {
append("https://a.gold-usergeneratedcontent.net/")
if (dir != "webp" && dir != "avif") {
append(dir)
append("/")
}
append(fullPathFromHash(image.hash))
append(".")
append(ext)
}
}
suspend fun urlFromUrlFromHash(
galleryID: Int,
image: GalleryFiles,
dir: String? = null,
ext: String? = null,
base: String? = null,
) =
if (base == "tn")
urlFromUrl(
"https://a.gold-usergeneratedcontent.net/$dir/${realFullPathFromHash(image.hash)}.$ext",
base,
)
else
urlFromUrl(urlFromHash(galleryID, image, dir, ext), base, dir)
suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean): String {
return urlFromUrlFromHash(galleryID, image, "webp")
// return when {
// noWebp ->
// urlFromUrlFromHash(galleryID, image)
//// image.hasavif != 0 ->
//// urlFromUrlFromHash(galleryID, image, "avif", null, "a")
// image.haswebp != 0 ->
// urlFromUrlFromHash(galleryID, image, "webp", null, "a")
// else ->
// urlFromUrlFromHash(galleryID, image)
// }
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2019 tom5079
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.quaver.pupil.hitomi
import kotlinx.serialization.Serializable
@Serializable
data class Gallery(
val related: List<Int>,
val langList: List<Pair<String, String>>,
val cover: String,
val title: String,
val artists: List<String>,
val groups: List<String>,
val type: String,
val language: String,
val series: List<String>,
val characters: List<String>,
val tags: List<String>,
val thumbnails: List<String>
)
suspend fun getGallery(galleryID: Int) : Gallery {
val info = getGalleryInfo(galleryID)
return Gallery(
info.related,
info.languages.map { it.name to it.galleryid },
urlFromUrlFromHash(galleryID, info.files.first(), "webpbigtn", "webp", "tn"),
info.title,
info.artists?.map { it.artist }.orEmpty(),
info.groups?.map { it.group }.orEmpty(),
info.type,
info.language.orEmpty(),
info.parodys?.map { it.parody }.orEmpty(),
info.characters?.map { it.character }.orEmpty(),
info.tags?.map { "${if (it.female.isNullOrEmpty()) "" else "female:"}${if (it.male.isNullOrEmpty()) "" else "male:"}${it.tag}" }.orEmpty(),
info.files.map { urlFromUrlFromHash(galleryID, it, "webpsmalltn", "webp", "tn") }
)
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2019 tom5079
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.quaver.pupil.hitomi
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import java.net.URL
import java.net.URLDecoder
import java.nio.ByteBuffer
import java.nio.ByteOrder
import javax.net.ssl.HttpsURLConnection
import kotlin.io.readText
//galleryblock.js
fun fetchNozomi(area: String? = null, tag: String = "index", language: String = "all", start: Int = -1, count: Int = -1) : Pair<List<Int>, Int> {
val url = when(area) {
null -> "$protocol//$domain/$tag-$language$nozomiextension"
else -> "$protocol//$domain/$area/$tag-$language$nozomiextension"
}
with(URL(url).openConnection() as HttpsURLConnection) {
requestMethod = "GET"
if (start != -1 && count != -1) {
val startByte = start*4
val endByte = (start+count)*4-1
setRequestProperty("Range", "bytes=$startByte-$endByte")
}
connect()
val totalItems = getHeaderField("Content-Range")
.replace(Regex("^[Bb]ytes \\d+-\\d+/"), "").toInt() / 4
val nozomi = ArrayList<Int>()
val arrayBuffer = ByteBuffer
.wrap(inputStream.readBytes())
.order(ByteOrder.BIG_ENDIAN)
while (arrayBuffer.hasRemaining())
nozomi.add(arrayBuffer.int)
return Pair(nozomi, totalItems)
}
}
@Serializable
data class GalleryBlock(
val id: Int,
val galleryUrl: String,
val thumbnails: List<String>,
val title: String,
val artists: List<String>,
val series: List<String>,
val type: String,
val language: String,
val relatedTags: List<String>,
val groups: List<String> = emptyList()
)
suspend fun getGalleryBlock(galleryID: Int) : GalleryBlock {
val info = getGalleryInfo(galleryID)
return GalleryBlock(
galleryID,
"",
listOf(urlFromUrlFromHash(galleryID, info.files.first(), "webpbigtn", "webp", "tn")),
info.title,
info.artists?.map { it.artist }.orEmpty(),
info.parodys?.map { it.parody }.orEmpty(),
info.type,
info.language.orEmpty(),
info.tags?.map { "${if (it.female.isNullOrEmpty()) "" else "female:"}${if (it.male.isNullOrEmpty()) "" else "male:"}${it.tag}" }.orEmpty(),
info.groups?.map { it.group }.orEmpty()
)
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2019 tom5079
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.quaver.pupil.hitomi
import kotlinx.serialization.Serializable
import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.pupil.hitomi.getGalleryInfo
@Serializable
data class GalleryFiles(
val width: Int,
val hash: String,
val haswebp: Int = 0,
val name: String,
val height: Int,
val hasavif: Int = 0,
val hasavifsmalltn: Int? = 0
)
//Set header `Referer` to reader url to avoid 403 error
@Deprecated("", replaceWith = ReplaceWith("getGalleryInfo"))
fun getReader(galleryID: Int) : GalleryInfo {
return getGalleryInfo(galleryID)
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2019 tom5079
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.quaver.pupil.hitomi
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.util.LinkedList
suspend fun doSearch(query: String, sortMode: SortMode): List<Int> = coroutineScope {
val terms = query
.trim()
.replace(Regex("""^\?"""), "")
.lowercase()
.split(Regex("\\s+"))
.map {
it.replace('_', ' ')
}
val positiveTerms = LinkedList<String>()
val negativeTerms = LinkedList<String>()
for (term in terms) {
if (term.startsWith("-"))
negativeTerms.push(term.substring(1))
else if (term.isNotBlank())
positiveTerms.push(term)
}
val positiveResults = positiveTerms.map {
async {
runCatching {
getGalleryIDsForQuery(it, sortMode)
}.getOrElse { emptySet() }
}
}
val negativeResults = negativeTerms.map {
async {
runCatching {
getGalleryIDsForQuery(it, sortMode)
}.getOrElse { emptySet() }
}
}
val results = when {
positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(
SearchArgs("all", "index", "all"),
sortMode
)
else -> emptySet()
}.toMutableSet()
fun filterPositive(newResults: Set<Int>) {
when {
results.isEmpty() -> results.addAll(newResults)
else -> results.retainAll(newResults)
}
}
fun filterNegative(newResults: Set<Int>) {
results.removeAll(newResults)
}
//positive results
positiveResults.forEach {
filterPositive(it.await())
}
//negative results
negativeResults.forEach {
filterNegative(it.await())
}
return@coroutineScope if (sortMode != SortMode.RANDOM) {
results.toList()
} else {
results.shuffled()
}
}

View File

@@ -0,0 +1,409 @@
/*
* Copyright 2019 tom5079
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package xyz.quaver.pupil.hitomi
import kotlinx.serialization.json.jsonArray
import okhttp3.Request
import xyz.quaver.pupil.client
import xyz.quaver.pupil.util.content
import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
import kotlin.math.min
data class SearchArgs(
val area: String?,
val tag: String,
val language: String,
) {
companion object {
fun fromQuery(query: String): SearchArgs? {
if (!query.contains(':')) {
return null
}
val (left, right) = query.split(':')
return when (left) {
"male", "female" -> SearchArgs("tag", query, "all")
"language" -> SearchArgs("all", "index", right)
else -> SearchArgs(left, right, "all")
}
}
}
}
enum class SortMode {
DATE_ADDED,
DATE_PUBLISHED,
POPULAR_TODAY,
POPULAR_WEEK,
POPULAR_MONTH,
POPULAR_YEAR,
RANDOM;
val orderBy: String
get() = when (this) {
DATE_ADDED, DATE_PUBLISHED, RANDOM -> "date"
POPULAR_TODAY, POPULAR_WEEK, POPULAR_MONTH, POPULAR_YEAR -> "popular"
}
val orderByKey: String
get() = when (this) {
DATE_ADDED, RANDOM -> "added"
DATE_PUBLISHED -> "published"
POPULAR_TODAY -> "today"
POPULAR_WEEK -> "week"
POPULAR_MONTH -> "month"
POPULAR_YEAR -> "year"
}
}
//searchlib.js
const val separator = "-"
const val extension = ".html"
const val index_dir = "tagindex"
const val galleries_index_dir = "galleriesindex"
const val max_node_size = 464
const val B = 16
const val compressed_nozomi_prefix = "n"
val tag_index_version: String by lazy { getIndexVersion("tagindex") }
val galleries_index_version: String by lazy { getIndexVersion("galleriesindex") }
val tagIndexDomain = "tagindex.hitomi.la"
fun sha256(data: ByteArray): ByteArray {
return MessageDigest.getInstance("SHA-256").digest(data)
}
@OptIn(ExperimentalUnsignedTypes::class)
fun hashTerm(term: String): UByteArray {
return sha256(term.toByteArray()).toUByteArray().sliceArray(0 until 4)
}
fun sanitize(input: String): String {
return input.replace(Regex("[/#]"), "")
}
fun getIndexVersion(name: String) =
URL("$protocol//$domain/$name/version?_=${System.currentTimeMillis()}").readText()
//search.js
fun getGalleryIDsForQuery(query: String, sortMode: SortMode): Set<Int> {
val sanitizedQuery = query.replace("_", " ")
val args = SearchArgs.fromQuery(sanitizedQuery)
return if (args != null) {
getGalleryIDsFromNozomi(args, sortMode)
} else {
val key = hashTerm(sanitizedQuery)
val field = "galleries"
val node = getNodeAtAddress(field, 0)
val data = bSearch(field, key, node)
if (data != null)
return getGalleryIDsFromData(data)
return emptySet()
}
}
fun encodeSearchQueryForUrl(s: Char) =
when (s) {
' ' -> "_"
'/' -> "slash"
'.' -> "dot"
else -> s.toString()
}
fun getSuggestionsForQuery(query: String): List<Suggestion> {
query.replace('_', ' ').let {
var field = "global"
var term = it
if (term.indexOf(':') > -1) {
val sides = it.split(':')
field = sides[0]
term = sides[1]
}
val chars = term.map(::encodeSearchQueryForUrl)
val url =
"https://$tagIndexDomain/$field${if (chars.isNotEmpty()) "/${chars.joinToString("/")}" else ""}.json"
val request = Request.Builder()
.url(url)
.build()
val suggestions = json.parseToJsonElement(
client.newCall(request).execute().body()?.use { body -> body.string() }
?: return emptyList())
return buildList {
suggestions.jsonArray.forEach { suggestionRaw ->
val suggestion = suggestionRaw.jsonArray
if (suggestion.size < 3) {
return@forEach
}
val ns = suggestion[2].content ?: ""
val tagname = sanitize(suggestion[0].content ?: return@forEach)
val url = when (ns) {
"female", "male" -> "/tag/$ns:$tagname${separator}1$extension"
"language" -> "/index-$tagname${separator}1$extension"
else -> "/$ns/$tagname${separator}all${separator}1$extension"
}
add(
Suggestion(
suggestion[0].content ?: "",
suggestion[1].content?.toIntOrNull() ?: 0,
url,
ns
)
)
}
}
}
}
data class Suggestion(val s: String, val t: Int, val u: String, val n: String)
fun getSuggestionsFromData(field: String, data: Pair<Long, Int>): List<Suggestion> {
val url = "$protocol//$domain/$index_dir/$field.$tag_index_version.data"
val (offset, length) = data
if (length > 10000 || length <= 0)
throw Exception("length $length is too long")
val inbuf = getURLAtRange(url, offset.until(offset + length))
val suggestions = ArrayList<Suggestion>()
val buffer = ByteBuffer
.wrap(inbuf)
.order(ByteOrder.BIG_ENDIAN)
val numberOfSuggestions = buffer.int
if (numberOfSuggestions > 100 || numberOfSuggestions <= 0)
throw Exception("number of suggestions $numberOfSuggestions is too long")
for (i in 0.until(numberOfSuggestions)) {
var top = buffer.int
val ns = inbuf.sliceArray(buffer.position().until(buffer.position() + top))
.toString(charset("UTF-8"))
buffer.position(buffer.position() + top)
top = buffer.int
val tag = inbuf.sliceArray(buffer.position().until(buffer.position() + top))
.toString(charset("UTF-8"))
buffer.position(buffer.position() + top)
val count = buffer.int
val tagname = sanitize(tag)
val u =
when (ns) {
"female", "male" -> "/tag/$ns:$tagname${separator}1$extension"
"language" -> "/index-$tagname${separator}1$extension"
else -> "/$ns/$tagname${separator}all${separator}1$extension"
}
suggestions.add(Suggestion(tag, count, u, ns))
}
return suggestions
}
fun nozomiAddressFromArgs(args: SearchArgs, sortMode: SortMode) = when {
sortMode != SortMode.DATE_ADDED && sortMode != SortMode.RANDOM ->
if (args.area == "all") "$protocol//$domain/$compressed_nozomi_prefix/${sortMode.orderBy}/${sortMode.orderByKey}-${args.language}$nozomiextension"
else "$protocol//$domain/$compressed_nozomi_prefix/${args.area}/${sortMode.orderBy}/${sortMode.orderByKey}/${args.tag}-${args.language}$nozomiextension"
args.area == "all" -> "$protocol//$domain/$compressed_nozomi_prefix/${args.tag}-${args.language}$nozomiextension"
else -> "$protocol//$domain/$compressed_nozomi_prefix/${args.area}/${args.tag}-${args.language}$nozomiextension"
}
fun getGalleryIDsFromNozomi(args: SearchArgs, sortMode: SortMode): Set<Int> {
val nozomiAddress = nozomiAddressFromArgs(args, sortMode)
val bytes = URL(nozomiAddress).readBytes()
val nozomi = mutableSetOf<Int>()
val arrayBuffer = ByteBuffer
.wrap(bytes)
.order(ByteOrder.BIG_ENDIAN)
while (arrayBuffer.hasRemaining())
nozomi.add(arrayBuffer.int)
return nozomi
}
fun getGalleryIDsFromData(data: Pair<Long, Int>): Set<Int> {
val url = "$protocol//$domain/$galleries_index_dir/galleries.$galleries_index_version.data"
val (offset, length) = data
if (length > 100000000 || length <= 0)
throw Exception("length $length is too long")
val inbuf = getURLAtRange(url, offset.until(offset + length))
val galleryIDs = mutableSetOf<Int>()
val buffer = ByteBuffer
.wrap(inbuf)
.order(ByteOrder.BIG_ENDIAN)
val numberOfGalleryIDs = buffer.int
val expectedLength = numberOfGalleryIDs * 4 + 4
if (numberOfGalleryIDs > 10000000 || numberOfGalleryIDs <= 0)
throw Exception("number_of_galleryids $numberOfGalleryIDs is too long")
else if (inbuf.size != expectedLength)
throw Exception("inbuf.byteLength ${inbuf.size} != expected_length $expectedLength")
for (i in 0.until(numberOfGalleryIDs))
galleryIDs.add(buffer.int)
return galleryIDs
}
fun getNodeAtAddress(field: String, address: Long): Node {
val url =
when (field) {
"galleries" -> "$protocol//$domain/$galleries_index_dir/galleries.$galleries_index_version.index"
"languages" -> "$protocol//$domain/$galleries_index_dir/languages.$galleries_index_version.index"
"nozomiurl" -> "$protocol//$domain/$galleries_index_dir/nozomiurl.$galleries_index_version.index"
else -> "$protocol//$domain/$index_dir/$field.$tag_index_version.index"
}
val nodedata = getURLAtRange(url, address.until(address + max_node_size))
return decodeNode(nodedata)
}
fun getURLAtRange(url: String, range: LongRange): ByteArray {
val request = Request.Builder()
.url(url)
.header("Range", "bytes=${range.first}-${range.last}")
.build()
return client.newCall(request).execute().body()?.use { it.bytes() } ?: byteArrayOf()
}
@OptIn(ExperimentalUnsignedTypes::class)
data class Node(
val keys: List<UByteArray>,
val datas: List<Pair<Long, Int>>,
val subNodeAddresses: List<Long>
)
@OptIn(ExperimentalUnsignedTypes::class)
fun decodeNode(data: ByteArray): Node {
val buffer = ByteBuffer
.wrap(data)
.order(ByteOrder.BIG_ENDIAN)
val uData = data.toUByteArray()
val numberOfKeys = buffer.int
val keys = ArrayList<UByteArray>()
for (i in 0.until(numberOfKeys)) {
val keySize = buffer.int
if (keySize == 0 || keySize > 32)
throw Exception("fatal: !keySize || keySize > 32")
keys.add(uData.sliceArray(buffer.position().until(buffer.position() + keySize)))
buffer.position(buffer.position() + keySize)
}
val numberOfDatas = buffer.int
val datas = ArrayList<Pair<Long, Int>>()
for (i in 0.until(numberOfDatas)) {
val offset = buffer.long
val length = buffer.int
datas.add(Pair(offset, length))
}
val numberOfSubNodeAddresses = B + 1
val subNodeAddresses = ArrayList<Long>()
for (i in 0.until(numberOfSubNodeAddresses)) {
val subNodeAddress = buffer.long
subNodeAddresses.add(subNodeAddress)
}
return Node(keys, datas, subNodeAddresses)
}
@OptIn(ExperimentalUnsignedTypes::class)
fun bSearch(field: String, key: UByteArray, node: Node): Pair<Long, Int>? {
fun compareArrayBuffers(dv1: UByteArray, dv2: UByteArray): Int {
val top = min(dv1.size, dv2.size)
for (i in 0.until(top)) {
if (dv1[i] < dv2[i])
return -1
else if (dv1[i] > dv2[i])
return 1
}
return 0
}
fun locateKey(key: UByteArray, node: Node): Pair<Boolean, Int> {
for (i in node.keys.indices) {
val cmpResult = compareArrayBuffers(key, node.keys[i])
if (cmpResult <= 0)
return Pair(cmpResult == 0, i)
}
return Pair(false, node.keys.size)
}
fun isLeaf(node: Node): Boolean {
for (subnode in node.subNodeAddresses)
if (subnode != 0L)
return false
return true
}
if (node.keys.isEmpty())
return null
val (there, where) = locateKey(key, node)
if (there)
return node.datas[where]
else if (isLeaf(node))
return null
val nextNode = getNodeAtAddress(field, node.subNodeAddresses[where])
return bSearch(field, key, nextNode)
}

View File

@@ -24,6 +24,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -54,13 +55,15 @@ class UpdateBroadcastReceiver : BroadcastReceiver() {
val uri = downloadManager.query(query).use { cursor -> val uri = downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).let { cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))?.let {
val uri = Uri.parse(it) val uri = Uri.parse(it)
when (uri.scheme) { when (uri.scheme) {
"file" -> "file" ->
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
) FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!))
else
uri
"content" -> uri "content" -> uri
else -> null else -> null
} }
@@ -73,10 +76,10 @@ class UpdateBroadcastReceiver : BroadcastReceiver() {
val notificationManager = NotificationManagerCompat.from(context) val notificationManager = NotificationManagerCompat.from(context)
val pendingIntent = PendingIntent.getActivity(context, 0, Intent(Intent.ACTION_VIEW).apply { val pendingIntent = PendingIntent.getActivity(context, System.currentTimeMillis().toInt(), Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK 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")) setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
}, 0) }, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else 0)
val notification = NotificationCompat.Builder(context, "update") val notification = NotificationCompat.Builder(context, "update")
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(android.R.drawable.stat_sys_download_done)

View File

@@ -0,0 +1,64 @@
package xyz.quaver.pupil.receiver
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.wifi.p2p.WifiP2pManager
import android.os.Build
import android.os.Parcelable
import android.util.Log
import androidx.core.app.ActivityCompat
import xyz.quaver.pupil.ui.ErrorType
import xyz.quaver.pupil.ui.TransferStep
import xyz.quaver.pupil.ui.TransferViewModel
private inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String): T? = when {
Build.VERSION.SDK_INT >= 33 -> getParcelableExtra(key, T::class.java)
else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T
}
class WifiDirectBroadcastReceiver(
private val manager: WifiP2pManager,
private val channel: WifiP2pManager.Channel,
private val viewModel: TransferViewModel
): BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context?, intent: Intent?) {
context!!
when (intent?.action) {
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
Log.d("PUPILD", "Wifi P2P state changed: $state")
viewModel.setWifiP2pEnabled(state == WifiP2pManager.WIFI_P2P_STATE_ENABLED)
}
WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION -> {
Log.d("PUPILD", "Wifi P2P peers changed")
manager.requestPeers(channel) { peers ->
viewModel.setPeers(peers)
}
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
// Respond to new connection or disconnections
val networkInfo = intent.getParcelableExtraCompat<android.net.NetworkInfo>(WifiP2pManager.EXTRA_NETWORK_INFO)
Log.d("PUPILD", "Wifi P2P connection changed: $networkInfo ${networkInfo?.isConnected}")
if (networkInfo?.isConnected == true) {
manager.requestConnectionInfo(channel) { info ->
viewModel.setConnectionInfo(info)
}
} else {
viewModel.setConnectionInfo(null)
}
}
WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> {
// Respond to this device's wifi state changing
Log.d("PUPILD", "Wifi P2P this device changed")
viewModel.setThisDevice(intent.getParcelableExtraCompat(WifiP2pManager.EXTRA_WIFI_P2P_DEVICE))
}
}
}
}

View File

@@ -23,11 +23,14 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.SparseArray import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -37,17 +40,15 @@ import okhttp3.Callback
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okio.* import okio.*
import xyz.quaver.pupil.PupilInterceptor import xyz.quaver.pupil.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.interceptors
import xyz.quaver.pupil.ui.ReaderActivity import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.ellipsize
import xyz.quaver.pupil.util.normalizeID
import xyz.quaver.pupil.util.requestBuilders
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.ceil
import kotlin.math.log10
private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit private typealias ProgressListener = (DownloadService.Tag, Long, Long, Boolean) -> Unit
class DownloadService : Service() { class DownloadService : Service() {
@@ -66,7 +67,7 @@ class DownloadService : Service() {
.setOngoing(true) .setOngoing(true)
} }
private val notification = SparseArray<NotificationCompat.Builder?>() private val notification = ConcurrentHashMap<Int, NotificationCompat.Builder?>()
private fun initNotification(galleryID: Int) { private fun initNotification(galleryID: Int) {
val intent = Intent(this, ReaderActivity::class.java) val intent = Intent(this, ReaderActivity::class.java)
@@ -74,7 +75,7 @@ class DownloadService : Service() {
val pendingIntent = TaskStackBuilder.create(this).run { val pendingIntent = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(intent) addNextIntentWithParentStack(intent)
getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT) getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
} }
val action = val action =
NotificationCompat.Action.Builder(0, getText(android.R.string.cancel), NotificationCompat.Action.Builder(0, getText(android.R.string.cancel),
@@ -84,10 +85,10 @@ class DownloadService : Service() {
Intent(this, DownloadService::class.java) Intent(this, DownloadService::class.java)
.putExtra(KEY_COMMAND, COMMAND_CANCEL) .putExtra(KEY_COMMAND, COMMAND_CANCEL)
.putExtra(KEY_ID, galleryID), .putExtra(KEY_ID, galleryID),
PendingIntent.FLAG_UPDATE_CURRENT), PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0),
).build() ).build()
notification.put(galleryID, NotificationCompat.Builder(this, "download").apply { notification[galleryID] = NotificationCompat.Builder(this, "download").apply {
setContentTitle(getString(R.string.reader_loading)) setContentTitle(getString(R.string.reader_loading))
setContentText(getString(R.string.reader_notification_text)) setContentText(getString(R.string.reader_notification_text))
setSmallIcon(R.drawable.ic_notification) setSmallIcon(R.drawable.ic_notification)
@@ -95,18 +96,20 @@ class DownloadService : Service() {
addAction(action) addAction(action)
setProgress(0, 0, true) setProgress(0, 0, true)
setOngoing(true) setOngoing(true)
}) }
notify(galleryID) notify(galleryID)
} }
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi", "MissingPermission")
private fun notify(galleryID: Int) { private fun notify(galleryID: Int) {
val max = progress[galleryID]?.size ?: 0 val max = progress[galleryID]?.size ?: 0
val progress = progress[galleryID]?.count { it == Float.POSITIVE_INFINITY } ?: 0 val progress = progress[galleryID]?.count { it == Float.POSITIVE_INFINITY } ?: 0
val notification = notification[galleryID] ?: return val notification = notification[galleryID] ?: return
if (!checkNotificationEnabled(this)) return
if (isCompleted(galleryID)) { if (isCompleted(galleryID)) {
notification notification
.setContentText(getString(R.string.reader_notification_complete)) .setContentText(getString(R.string.reader_notification_complete))
@@ -120,7 +123,7 @@ class DownloadService : Service() {
.setProgress(max, progress, false) .setProgress(max, progress, false)
.setContentText("$progress/$max") .setContentText("$progress/$max")
if (DownloadManager.getInstance(this).getDownloadFolder(galleryID) != null) if (DownloadManager.getInstance(this).getDownloadFolder(galleryID) != null || galleryID == priority)
notification.let { notificationManager.notify(galleryID, it.build()) } notification.let { notificationManager.notify(galleryID, it.build()) }
else else
notificationManager.cancel(galleryID) notificationManager.cancel(galleryID)
@@ -167,15 +170,27 @@ class DownloadService : Service() {
private val interceptor: PupilInterceptor = { chain -> private val interceptor: PupilInterceptor = { chain ->
val request = chain.request() val request = chain.request()
var response = chain.proceed(request)
var retry = 5 var response = kotlin.runCatching {
while (!response.isSuccessful && retry > 0) { chain.proceed(request)
response = chain.proceed(request) }.getOrNull()
retry-- var limit = 10
while (response?.isSuccessful != true) {
if (response?.code() == 503) {
Thread.sleep(200)
} else if (--limit < 0)
break
response = kotlin.runCatching {
chain.proceed(request)
}.getOrNull()
} }
response.newBuilder() if (response == null)
response = chain.proceed(request)
response!!.newBuilder()
.body(response.body()?.let { .body(response.body()?.let {
ProgressResponseBody(request.tag(), it, progressListener) ProgressResponseBody(request.tag(), it, progressListener)
}).build() }).build()
@@ -194,19 +209,19 @@ class DownloadService : Service() {
* 0 <= value < 100 -> Download in progress * 0 <= value < 100 -> Download in progress
* Float.POSITIVE_INFINITY -> Download completed * Float.POSITIVE_INFINITY -> Download completed
*/ */
val progress = SparseArray<MutableList<Float>?>() val progress = ConcurrentHashMap<Int, MutableList<Float>>()
var priority = 0
fun isCompleted(galleryID: Int) = progress[galleryID]?.toList()?.all { it == Float.POSITIVE_INFINITY } == true fun isCompleted(galleryID: Int) = progress[galleryID]?.toList()?.all { it == Float.POSITIVE_INFINITY } == true
private val callback = object: Callback { private val callback = object: Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.d("PUPILD", "ONFAILURE ${call.request().tag()}, ${e}")
FirebaseCrashlytics.getInstance().recordException(e)
if (e.message?.contains("cancel", true) == false) { if (e.message?.contains("cancel", true) == false) {
val galleryID = (call.request().tag() as Tag).galleryID val galleryID = (call.request().tag() as Tag).galleryID
// Retry
cancel(galleryID)
download(galleryID)
} }
} }
@@ -214,27 +229,27 @@ class DownloadService : Service() {
val (galleryID, index, startId) = call.request().tag() as Tag val (galleryID, index, startId) = call.request().tag() as Tag
val ext = call.request().url().encodedPath().split('.').last() val ext = call.request().url().encodedPath().split('.').last()
kotlin.runCatching {
val image = response.also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() } ?: throw Exception()
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
kotlin.runCatching { runCatching {
Cache.getInstance(this@DownloadService, galleryID).putImage(index, "$index.$ext", image) val image = response.also { if (it.code() != 200) throw IOException( "$galleryID $index ${response.request().url()} CODE ${it.code()}" ) }.body()?.use { it.bytes() } ?: throw Exception("Response null")
}.onSuccess { val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt()
Cache.getInstance(this@DownloadService, galleryID)
.putImage(index, "${index.toString().padStart(padding, '0')}.$ext", image)
progress[galleryID]?.set(index, Float.POSITIVE_INFINITY) progress[galleryID]?.set(index, Float.POSITIVE_INFINITY)
notify(galleryID) notify(galleryID)
if (isCompleted(galleryID)) { if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService) if (DownloadManager.getInstance(this@DownloadService)
.getDownloadFolder(galleryID) != null) .getDownloadFolder(galleryID) != null
)
Cache.getInstance(this@DownloadService, galleryID).moveToDownload() Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
startId?.let { stopSelf(it) } startId?.let { stopSelf(it) }
} }
}.onFailure { }.onFailure {
cancel(galleryID) FirebaseCrashlytics.getInstance().recordException(it)
download(galleryID)
}
} }
} }
} }
@@ -285,41 +300,50 @@ class DownloadService : Service() {
fun delete(galleryID: Int, startId: Int? = null) = CoroutineScope(Dispatchers.IO).launch { fun delete(galleryID: Int, startId: Int? = null) = CoroutineScope(Dispatchers.IO).launch {
cancel(galleryID) cancel(galleryID)
DownloadManager.getInstance(this@DownloadService).deleteDownloadFolder(galleryID) DownloadManager.getInstance(this@DownloadService).deleteDownloadFolder(galleryID)
Cache.delete(galleryID) Cache.delete(this@DownloadService, galleryID)
startId?.let { stopSelf(it) } startId?.let { stopSelf(it) }
} }
fun download(galleryID: Int, priority: Boolean = false, startId: Int? = null): Job = CoroutineScope(Dispatchers.IO).launch { fun download(galleryID: Int, priority: Boolean = false, startId: Int? = null): Job = CoroutineScope(Dispatchers.IO).launch {
if (progress.indexOfKey(galleryID) >= 0) if (DownloadManager.getInstance(this@DownloadService).isDownloading(galleryID))
cancel(galleryID) return@launch
cleanCache(this@DownloadService)
val cache = Cache.getInstance(this@DownloadService, galleryID) val cache = Cache.getInstance(this@DownloadService, galleryID)
initNotification(galleryID) initNotification(galleryID)
val reader = cache.getReader() val galleryInfo = cache.getGalleryInfo()
// Gallery doesn't exist // Gallery doesn't exist
if (reader == null) { if (galleryInfo == null) {
delete(galleryID) delete(galleryID)
progress.put(galleryID, null) progress[galleryID] = mutableListOf()
return@launch return@launch
} }
progress.put(galleryID, MutableList(reader.galleryInfo.files.size) { 0F }) histories.add(galleryID)
cache.metadata.imageList?.forEachIndexed { index, image -> progress[galleryID] = MutableList(galleryInfo.files.size) { 0F }
cache.metadata.imageList?.let {
it.forEachIndexed { index, image ->
progress[galleryID]?.set(index, if (image != null) Float.POSITIVE_INFINITY else 0F) progress[galleryID]?.set(index, if (image != null) Float.POSITIVE_INFINITY else 0F)
} }
}
if (isCompleted(galleryID)) { if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService).getDownloadFolder(galleryID) != null)
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
notificationManager.cancel(galleryID) notificationManager.cancel(galleryID)
startId?.let { stopSelf(it) } startId?.let { stopSelf(it) }
return@launch return@launch
} }
notification[galleryID]?.setContentTitle(reader.galleryInfo.title?.ellipsize(30)) notification[galleryID]?.setContentTitle(galleryInfo.title.ellipsize(32))
notify(galleryID) notify(galleryID)
val queued = mutableSetOf<Int>() val queued = mutableSetOf<Int>()
@@ -333,8 +357,8 @@ class DownloadService : Service() {
} }
} }
reader.requestBuilders.forEachIndexed { index, it -> galleryInfo.getRequestBuilders().forEachIndexed { index, it ->
if (progress[galleryID]?.get(index)?.isInfinite() != true) { if (progress[galleryID]?.get(index)?.isInfinite() == false) {
val request = it.tag(Tag(galleryID, index, startId)).build() val request = it.tag(Tag(galleryID, index, startId)).build()
client.newCall(request).enqueue(callback) client.newCall(request).enqueue(callback)
} }
@@ -381,7 +405,11 @@ class DownloadService : Service() {
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
startForeground(R.id.downloader_notification_id, serviceNotification.build()) startForeground(R.id.downloader_notification_id, serviceNotification.build())
} else {
startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
when (intent?.getStringExtra(KEY_COMMAND)) { when (intent?.getStringExtra(KEY_COMMAND)) {
COMMAND_DOWNLOAD -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0) COMMAND_DOWNLOAD -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0)
@@ -402,7 +430,11 @@ class DownloadService : Service() {
override fun onBind(p0: Intent?) = binder override fun onBind(p0: Intent?) = binder
override fun onCreate() { override fun onCreate() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
startForeground(R.id.downloader_notification_id, serviceNotification.build()) startForeground(R.id.downloader_notification_id, serviceNotification.build())
} else {
startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
interceptors[Tag::class] = interceptor interceptors[Tag::class] = interceptor
} }

View File

@@ -0,0 +1,128 @@
package xyz.quaver.pupil.services
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import xyz.quaver.pupil.R
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class TransferClientService : Service() {
private val selectorManager = SelectorManager(Dispatchers.IO)
private val channel = Channel<Pair<TransferPacket, Continuation<TransferPacket>>>()
private var job: Job? = null
private fun startForeground() = runCatching {
val notification = NotificationCompat.Builder(this, "transfer")
.setContentTitle("Pupil")
.setContentText("Transfer server is running")
.setSmallIcon(R.drawable.ic_notification)
.build()
ServiceCompat.startForeground(
this,
1,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else 0
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val address = intent?.getStringExtra("address") ?: run {
stopSelf(startId)
return START_STICKY
}
startForeground()
Log.d("PUPILD", "Starting service with address $address")
job?.cancel()
job = CoroutineScope(Dispatchers.IO).launch {
Log.d("PUPILD", "Connecting to $address")
val socket = aSocket(selectorManager).tcp().connect(address, 12221)
Log.d("PUPILD", "Connected to $address")
val readChannel = socket.openReadChannel()
val writeChannel = socket.openWriteChannel(autoFlush = true)
runCatching {
TransferPacket.Hello().writeToChannel(writeChannel)
val handshake = TransferPacket.readFromChannel(readChannel)
if (handshake !is TransferPacket.Hello || handshake.version != TRANSFER_PROTOCOL_VERSION) {
throw IllegalStateException("Invalid handshake")
}
while (true) {
val (packet, continuation) = channel.receive()
Log.d("PUPILD", "Sending packet $packet")
packet.writeToChannel(writeChannel)
val response = TransferPacket.readFromChannel(readChannel).also {
Log.d("PUPILD", "Received packet $it")
}
continuation.resume(response)
}
}.onFailure {
Log.d("PUPILD", "Connection closed with error $it")
channel.close()
socket.close()
stopSelf(startId)
}
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job?.cancel()
}
inner class Binder: android.os.Binder() {
@OptIn(DelicateCoroutinesApi::class)
suspend fun sendPacket(packet: TransferPacket): Result<TransferPacket.ListResponse> = runCatching {
check(job != null) { "Service not running" }
check(!channel.isClosedForSend) { "Service not running" }
val response = suspendCoroutine { continuation ->
check (channel.trySend(packet to continuation).isSuccess) { "Service not running" }
}
check (response is TransferPacket.ListResponse) { "Invalid response" }
response
}
}
private val binder = Binder()
override fun onBind(intent: Intent?): IBinder = binder
}

View File

@@ -0,0 +1,94 @@
package xyz.quaver.pupil.services
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
const val TRANSFER_PROTOCOL_VERSION: UByte = 1u
enum class TransferType(val value: UByte) {
INVALID(255u),
HELLO(0u),
PING(1u),
PONG(2u),
LIST_REQUEST(3u),
LIST_RESPONSE(4u),
}
sealed interface TransferPacket {
val type: TransferType
suspend fun writeToChannel(channel: ByteWriteChannel)
data class Hello(val version: UByte = TRANSFER_PROTOCOL_VERSION): TransferPacket {
override val type = TransferType.HELLO
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
channel.writeByte(version.toByte())
}
}
data object Ping: TransferPacket {
override val type = TransferType.PING
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
}
}
data object Pong: TransferPacket {
override val type = TransferType.PONG
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
}
}
data object ListRequest: TransferPacket {
override val type = TransferType.LIST_REQUEST
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
}
}
data object Invalid: TransferPacket {
override val type = TransferType.INVALID
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
}
}
data class ListResponse(
val favoritesCount: Int,
val historyCount: Int,
val downloadsCount: Int,
): TransferPacket {
override val type = TransferType.LIST_RESPONSE
override suspend fun writeToChannel(channel: ByteWriteChannel) {
channel.writeByte(type.value.toByte())
channel.writeInt(favoritesCount)
channel.writeInt(historyCount)
channel.writeInt(downloadsCount)
}
}
companion object {
suspend fun readFromChannel(channel: ByteReadChannel): TransferPacket {
return when(val type = channel.readByte().toUByte()) {
TransferType.HELLO.value -> {
val version = channel.readByte().toUByte()
Hello(version)
}
TransferType.PING.value -> Ping
TransferType.PONG.value -> Pong
TransferType.LIST_REQUEST.value -> ListRequest
TransferType.LIST_RESPONSE.value -> {
val favoritesCount = channel.readInt()
val historyCount = channel.readInt()
val downloadsCount = channel.readInt()
ListResponse(favoritesCount, historyCount, downloadsCount)
}
else -> throw IllegalArgumentException("Unknown packet type: $type")
}
}
}
}

View File

@@ -0,0 +1,123 @@
package xyz.quaver.pupil.services
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.ServerSocket
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.histories
import xyz.quaver.pupil.util.downloader.DownloadManager
class TransferServerService : Service() {
private val selectorManager = SelectorManager(Dispatchers.IO)
private var serverSocket: ServerSocket? = null
private val job = Job()
private fun startForeground() = runCatching {
val notification = NotificationCompat.Builder(this, "transfer")
.setContentTitle("Pupil")
.setContentText("Transfer server is running")
.setSmallIcon(R.drawable.ic_notification)
.build()
ServiceCompat.startForeground(
this,
1,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else 0
)
}
private fun generateListResponse(): TransferPacket.ListResponse {
val favoritesCount = favorites.size
val historyCount = histories.size
val downloadsCount = DownloadManager.getInstance(this).downloadFolderMap.size
return TransferPacket.ListResponse(favoritesCount, historyCount, downloadsCount)
}
private suspend fun handleConnection(socket: Socket) {
val readChannel = socket.openReadChannel()
val writeChannel = socket.openWriteChannel(autoFlush = true)
runCatching {
while (true) {
val packet = TransferPacket.readFromChannel(readChannel)
Log.d("PUPILD", "Received packet $packet")
binder.channel.trySend(packet)
val response = when (packet) {
is TransferPacket.Hello -> TransferPacket.Hello()
is TransferPacket.Ping -> TransferPacket.Pong
is TransferPacket.ListRequest -> generateListResponse()
else -> TransferPacket.Invalid
}
response.writeToChannel(writeChannel)
}
}.onFailure {
socket.close()
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val address = intent?.getStringExtra("address") ?: run {
stopSelf(startId)
return START_STICKY
}
if (serverSocket != null) {
return START_STICKY
}
startForeground()
val serverSocket = aSocket(selectorManager).tcp().bind(address, 12221).also {
this@TransferServerService.serverSocket = it
}
CoroutineScope(Dispatchers.IO + job).launch {
while (true) {
Log.d("PUPILD", "Waiting for connection")
val socket = serverSocket.accept()
Log.d("PUPILD", "Accepted connection from ${socket.remoteAddress}")
launch { handleConnection(socket) }
}
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
job.cancel()
serverSocket?.close()
}
inner class Binder: android.os.Binder() {
val channel = Channel<TransferPacket>()
}
private val binder = Binder()
override fun onBind(intent: Intent?): IBinder = binder
}

View File

@@ -1,6 +1,6 @@
/* /*
* Pupil, Hitomi.la viewer for Android * Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079 * Copyright (C) 2022 tom5079
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU General Public License as published by
@@ -16,26 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package xyz.quaver.pupil package xyz.quaver.pupil.types
import android.content.Context class SendLogException : Exception()
import com.bumptech.glide.Glide class JavascriptException(message: String?) : Exception(message)
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.AppGlideModule
import java.io.InputStream
@GlideModule
class PupilGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(
GlideUrl::class.java,
InputStream::class.java,
OkHttpUrlLoader.Factory(client)
)
}
}

View File

@@ -18,36 +18,33 @@
package xyz.quaver.pupil.types package xyz.quaver.pupil.types
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.android.parcel.Parcelize import kotlinx.parcelize.Parcelize
import xyz.quaver.hitomi.Suggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.hitomi.Suggestion
import xyz.quaver.pupil.util.translations
@Parcelize @Parcelize
data class TagSuggestion(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion { data class TagSuggestion(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion {
constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n) constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n)
override fun getBody(): String { @IgnoredOnParcel
return s override val body =
} if (translations[s] != null)
"${translations[s]} ($s)"
else
s
} }
@Parcelize @Parcelize
class Suggestion(val str: String) : SearchSuggestion { class Suggestion(override val body: String) : SearchSuggestion
override fun getBody() = str
}
@Parcelize @Parcelize
class NoResultSuggestion(val str: String) : SearchSuggestion { class NoResultSuggestion(override val body: String) : SearchSuggestion
override fun getBody() = str
}
@Parcelize @Parcelize
class LoadingSuggestion(val str: String) : SearchSuggestion { class LoadingSuggestion(override val body: String) : SearchSuggestion
override fun getBody() = str
}
@Parcelize @Parcelize
@Suppress("PARCELABLE_PRIMARY_CONSTRUCTOR_IS_EMPTY") @Suppress("PARCELABLE_PRIMARY_CONSTRUCTOR_IS_EMPTY")
class FavoriteHistorySwitch(private val body: String) : SearchSuggestion { class FavoriteHistorySwitch(override val body: String) : SearchSuggestion
override fun getBody() = body
}

View File

@@ -23,6 +23,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.PersistableBundle import android.os.PersistableBundle
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
@@ -34,6 +35,13 @@ open class BaseActivity : AppCompatActivity() {
private var locked: Boolean = true private var locked: Boolean = true
private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK)
locked = false
else
finish()
}
@CallSuper @CallSuper
override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
super.onCreate(savedInstanceState, persistentState) super.onCreate(savedInstanceState, persistentState)
@@ -53,20 +61,7 @@ open class BaseActivity : AppCompatActivity() {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (locked) if (locked)
startActivityForResult(Intent(this, LockActivity::class.java), R.id.request_lock.normalizeID()) lockLauncher.launch(Intent(this, LockActivity::class.java))
}
@CallSuper
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when(requestCode) {
R.id.request_lock.normalizeID() -> {
if (resultCode == Activity.RESULT_OK)
locked = false
else
finish()
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
} }
} }

View File

@@ -29,10 +29,8 @@ import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.andrognito.patternlockview.PatternLockView import com.andrognito.patternlockview.PatternLockView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_lock.*
import kotlinx.android.synthetic.main.fragment_pattern_lock.*
import kotlinx.android.synthetic.main.fragment_pin_lock.*
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.LockActivityBinding
import xyz.quaver.pupil.ui.fragment.PINLockFragment import xyz.quaver.pupil.ui.fragment.PINLockFragment
import xyz.quaver.pupil.ui.fragment.PatternLockFragment import xyz.quaver.pupil.ui.fragment.PatternLockFragment
import xyz.quaver.pupil.util.Lock import xyz.quaver.pupil.util.Lock
@@ -45,6 +43,8 @@ class LockActivity : AppCompatActivity() {
private lateinit var lockManager: LockManager private lateinit var lockManager: LockManager
private var mode: String? = null private var mode: String? = null
private lateinit var binding: LockActivityBinding
private val patternLockFragment = PatternLockFragment().apply { private val patternLockFragment = PatternLockFragment().apply {
var lastPass = "" var lastPass = ""
onPatternDrawn = { onPatternDrawn = {
@@ -57,7 +57,7 @@ class LockActivity : AppCompatActivity() {
setResult(Activity.RESULT_OK) setResult(Activity.RESULT_OK)
finish() finish()
} else } else
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG) binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
} }
"add_lock" -> { "add_lock" -> {
if (lastPass.isEmpty()) { if (lastPass.isEmpty()) {
@@ -69,7 +69,7 @@ class LockActivity : AppCompatActivity() {
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it)) LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
finish() finish()
} else { } else {
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG) binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
lastPass = "" lastPass = ""
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show() Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
@@ -92,15 +92,15 @@ class LockActivity : AppCompatActivity() {
setResult(Activity.RESULT_OK) setResult(Activity.RESULT_OK)
finish() finish()
} else { } else {
indicator_dots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply { binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
setAnimationListener(object: Animation.AnimationListener { setAnimationListener(object: Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) { override fun onAnimationEnd(animation: Animation?) {
pin_lock_view.resetPinLockView() binding.pinLockView.resetPinLockView()
pin_lock_view.isEnabled = true binding.pinLockView.isEnabled = true
} }
override fun onAnimationStart(animation: Animation?) { override fun onAnimationStart(animation: Animation?) {
pin_lock_view.isEnabled = false binding.pinLockView.isEnabled = false
} }
override fun onAnimationRepeat(animation: Animation?) { override fun onAnimationRepeat(animation: Animation?) {
@@ -114,22 +114,22 @@ class LockActivity : AppCompatActivity() {
if (lastPass.isEmpty()) { if (lastPass.isEmpty()) {
lastPass = it lastPass = it
pin_lock_view.resetPinLockView() binding.pinLockView.resetPinLockView()
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show() Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
} else { } else {
if (lastPass == it) { if (lastPass == it) {
LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it)) LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it))
finish() finish()
} else { } else {
indicator_dots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply { binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
setAnimationListener(object: Animation.AnimationListener { setAnimationListener(object: Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) { override fun onAnimationEnd(animation: Animation?) {
pin_lock_view.resetPinLockView() binding.pinLockView.resetPinLockView()
pin_lock_view.isEnabled = true binding.pinLockView.isEnabled = true
} }
override fun onAnimationStart(animation: Animation?) { override fun onAnimationStart(animation: Animation?) {
pin_lock_view.isEnabled = false binding.pinLockView.isEnabled = false
} }
override fun onAnimationRepeat(animation: Animation?) { override fun onAnimationRepeat(animation: Animation?) {
@@ -173,7 +173,8 @@ class LockActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_lock) binding = LockActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
lockManager = try { lockManager = try {
LockManager(this) LockManager(this)
@@ -210,7 +211,7 @@ class LockActivity : AppCompatActivity() {
Preferences["lock_fingerprint"] Preferences["lock_fingerprint"]
&& BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS && BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
) { ) {
lock_fingerprint.apply { binding.fingerprintBtn.apply {
isEnabled = true isEnabled = true
setOnClickListener { setOnClickListener {
showBiometricPrompt() showBiometricPrompt()
@@ -219,7 +220,7 @@ class LockActivity : AppCompatActivity() {
showBiometricPrompt() showBiometricPrompt()
} }
lock_pattern.apply { binding.patternBtn.apply {
isEnabled = lockManager.contains(Lock.Type.PATTERN) isEnabled = lockManager.contains(Lock.Type.PATTERN)
setOnClickListener { setOnClickListener {
supportFragmentManager.beginTransaction().replace( supportFragmentManager.beginTransaction().replace(
@@ -227,7 +228,7 @@ class LockActivity : AppCompatActivity() {
).commit() ).commit()
} }
} }
lock_pin.apply { binding.pinBtn.apply {
isEnabled = lockManager.contains(Lock.Type.PIN) isEnabled = lockManager.contains(Lock.Type.PIN)
setOnClickListener { setOnClickListener {
supportFragmentManager.beginTransaction().replace( supportFragmentManager.beginTransaction().replace(
@@ -235,7 +236,7 @@ class LockActivity : AppCompatActivity() {
).commit() ).commit()
} }
} }
lock_password.isEnabled = false binding.passwordBtn.isEnabled = false
when (lockManager.locks!!.first().type) { when (lockManager.locks!!.first().type) {
Lock.Type.PIN -> { Lock.Type.PIN -> {
@@ -253,20 +254,20 @@ class LockActivity : AppCompatActivity() {
} }
} }
"add_lock" -> { "add_lock" -> {
lock_pattern.isEnabled = false binding.patternBtn.isEnabled = false
lock_pin.isEnabled = false binding.pinBtn.isEnabled = false
lock_fingerprint.isEnabled = false binding.fingerprintBtn.isEnabled = false
lock_password.isEnabled = false binding.passwordBtn.isEnabled = false
when(intent.getStringExtra("type")!!) { when(intent.getStringExtra("type")!!) {
"pattern" -> { "pattern" -> {
lock_pattern.isEnabled = true binding.patternBtn.isEnabled = true
supportFragmentManager.beginTransaction().add( supportFragmentManager.beginTransaction().add(
R.id.lock_content, patternLockFragment R.id.lock_content, patternLockFragment
).commit() ).commit()
} }
"pin" -> { "pin" -> {
lock_pin.isEnabled = true binding.pinBtn.isEnabled = true
supportFragmentManager.beginTransaction().add( supportFragmentManager.beginTransaction().add(
R.id.lock_content, pinLockFragment R.id.lock_content, pinLockFragment
).commit() ).commit()

File diff suppressed because it is too large Load Diff

View File

@@ -18,44 +18,52 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.Manifest
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.view.* import android.view.*
import android.widget.Toast import android.view.animation.Animation
import android.view.animation.AnticipateInterpolator
import android.view.animation.OvershootInterpolator
import android.view.animation.TranslateAnimation
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.Animatable2Compat import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.bumptech.glide.Glide
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.android.synthetic.main.activity_reader.* import com.google.mlkit.vision.face.Face
import kotlinx.android.synthetic.main.activity_reader.view.* import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import kotlinx.android.synthetic.main.dialog_numberpicker.view.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import xyz.quaver.Code
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.ReaderAdapter import xyz.quaver.pupil.adapters.ReaderAdapter
import xyz.quaver.pupil.databinding.NumberpickerDialogBinding
import xyz.quaver.pupil.databinding.ReaderActivityBinding
import xyz.quaver.pupil.favorites import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.histories
import xyz.quaver.pupil.services.DownloadService import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.camera
import xyz.quaver.pupil.util.checkNotificationEnabled
import xyz.quaver.pupil.util.closeCamera
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.downloader.DownloadManager
import java.util.* import xyz.quaver.pupil.util.requestNotificationPermission
import kotlin.concurrent.schedule import xyz.quaver.pupil.util.showNotificationPermissionExplanationDialog
import kotlin.concurrent.timer import xyz.quaver.pupil.util.startCamera
class ReaderActivity : BaseActivity() { class ReaderActivity : BaseActivity() {
@@ -67,19 +75,19 @@ class ReaderActivity : BaseActivity() {
set(value) { set(value) {
field = value field = value
(reader_recyclerview.adapter as ReaderAdapter).isFullScreen = value (binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value
reader_progressbar.visibility = when {
value -> View.VISIBLE
else -> View.GONE
}
} }
private lateinit var cache: Cache private lateinit var cache: Cache
var downloader: DownloadService? = null var downloader: DownloadService? = null
private val conn = object: ServiceConnection { private val conn = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
downloader = (service as DownloadService.Binder).service downloader = (service as DownloadService.Binder).service.also {
it.priority = 0
if (!it.progress.containsKey(galleryID))
DownloadService.download(this@ReaderActivity, galleryID, true)
}
} }
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
@@ -87,16 +95,41 @@ class ReaderActivity : BaseActivity() {
} }
} }
private val timer = Timer()
private var autoTimer: Timer? = null
private val snapHelper = PagerSnapHelper() private val snapHelper = PagerSnapHelper()
private var menu: Menu? = null private var menu: Menu? = null
private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted)
toggleCamera()
else
AlertDialog.Builder(this)
.setTitle(R.string.error)
.setMessage(R.string.camera_denied)
.setPositiveButton(android.R.string.ok) { _, _ ->}
.show()
}
enum class Eye {
LEFT,
RIGHT
}
private var cameraEnabled = false
private var eyeType: Eye? = null
private var eyeTime: Long = 0L
private lateinit var binding: ReaderActivityBinding
private val requestNotificationPermssionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (!isGranted) {
showNotificationPermissionExplanationDialog(this)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_reader) binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
title = getString(R.string.reader_loading) title = getString(R.string.reader_loading)
supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(false)
@@ -110,38 +143,7 @@ class ReaderActivity : BaseActivity() {
return return
} }
if (Preferences["cache_disable"]) { initDownloadListener()
reader_download_progressbar.visibility = View.GONE
CoroutineScope(Dispatchers.IO).launch {
val reader = cache.getReader()
launch(Dispatchers.Main) initDownloader@{
if (reader == null) {
Snackbar
.make(reader_layout, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
.show()
return@initDownloader
}
histories.add(galleryID)
(reader_recyclerview.adapter as ReaderAdapter).apply {
this.reader = reader
notifyDataSetChanged()
}
title = reader.galleryInfo.title ?: ""
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${reader.galleryInfo.files.size}"
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity,
when (reader.code) {
Code.HITOMI -> R.drawable.hitomi
Code.HIYOBI -> R.drawable.ic_hiyobi
else -> android.R.color.transparent
})
}
}
} else
initDownloader()
initView() initView()
} }
@@ -155,10 +157,11 @@ class ReaderActivity : BaseActivity() {
val uri = intent.data val uri = intent.data
val lastPathSegment = uri?.lastPathSegment val lastPathSegment = uri?.lastPathSegment
if (uri != null && lastPathSegment != null) { if (uri != null && lastPathSegment != null) {
galleryID = when (uri.host) { galleryID = if (uri.host?.endsWith("hasha.in") == true) {
lastPathSegment?.toInt() ?: 0
} else when (uri.host) {
"hitomi.la" -> "hitomi.la" ->
Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1)?.toIntOrNull() ?: 0 Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1)?.toIntOrNull() ?: 0
"hiyobi.me" -> lastPathSegment.toInt()
"e-hentai.org" -> uri.pathSegments[1].toInt() "e-hentai.org" -> uri.pathSegments[1].toInt()
else -> 0 else -> 0
} }
@@ -168,12 +171,10 @@ class ReaderActivity : BaseActivity() {
} }
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.reader, menu) menuInflater.inflate(R.menu.reader, menu)
with(menu?.findItem(R.id.reader_menu_favorite)) { with(menu.findItem(R.id.reader_menu_favorite)) {
this ?: return@with
if (favorites.contains(galleryID)) if (favorites.contains(galleryID))
(icon as Animatable).start() (icon as Animatable).start()
} }
@@ -185,17 +186,19 @@ class ReaderActivity : BaseActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when(item.itemId) { when(item.itemId) {
R.id.reader_menu_page_indicator -> { R.id.reader_menu_page_indicator -> {
val view = LayoutInflater.from(this).inflate(R.layout.dialog_numberpicker, reader_layout, false) // TODO: Switch to DialogFragment
with(view.dialog_number_picker) { val binding = NumberpickerDialogBinding.inflate(layoutInflater, binding.root, false)
with(binding.numberPicker) {
minValue = 1 minValue = 1
maxValue = cache.metadata.reader?.galleryInfo?.files?.size ?: 0 maxValue = cache.metadata.galleryInfo?.files?.size ?: 0
value = currentPage value = currentPage
} }
val dialog = AlertDialog.Builder(this).apply { val dialog = AlertDialog.Builder(this).apply {
setView(view) setView(binding.root)
}.create() }.create()
view.dialog_ok.setOnClickListener { binding.okButton.setOnClickListener {
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(view.dialog_number_picker.value-1, 0) (this@ReaderActivity.binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(binding.numberPicker.value-1, 0)
dialog.dismiss() dialog.dismiss()
} }
@@ -218,17 +221,32 @@ class ReaderActivity : BaseActivity() {
return true return true
} }
override fun onDestroy() { override fun onResume() {
super.onDestroy() super.onResume()
timer.cancel() bindService(Intent(this, DownloadService::class.java), conn, BIND_AUTO_CREATE)
(reader_recyclerview?.adapter as? ReaderAdapter)?.timer?.cancel()
if (!DownloadManager.getInstance(this).isDownloading(galleryID)) if (cameraEnabled)
DownloadService.cancel(this, galleryID) startCamera(this, cameraCallback)
}
override fun onPause() {
super.onPause()
closeCamera()
if (downloader != null) if (downloader != null)
unbindService(conn) unbindService(conn)
downloader?.priority = galleryID
}
override fun onDestroy() {
super.onDestroy()
update = false
if (!DownloadManager.getInstance(this).isDownloading(galleryID))
DownloadService.cancel(this, galleryID)
} }
override fun onBackPressed() { override fun onBackPressed() {
@@ -250,12 +268,12 @@ class ReaderActivity : BaseActivity() {
//currentPage is 1-based //currentPage is 1-based
return when(keyCode) { return when(keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> { KeyEvent.KEYCODE_VOLUME_UP -> {
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage-2, 0) (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-2, 0)
true true
} }
KeyEvent.KEYCODE_VOLUME_DOWN -> { KeyEvent.KEYCODE_VOLUME_DOWN -> {
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0) (binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0)
true true
} }
@@ -263,53 +281,49 @@ class ReaderActivity : BaseActivity() {
} }
} }
private fun initDownloader() { private var update = true
DownloadService.download(this, galleryID, true) private fun initDownloadListener() {
bindService(Intent(this, DownloadService::class.java), conn, BIND_AUTO_CREATE) CoroutineScope(Dispatchers.Main).launch {
while (update) {
delay(1000)
timer.schedule(1000, 1000) { val downloader = downloader ?: continue
val downloader = downloader ?: return@schedule
if (downloader.progress.indexOfKey(galleryID) < 0) //loading if (!downloader.progress.containsKey(galleryID)) //loading
return@schedule continue
if (downloader.progress[galleryID] == null) { //Gallery not found if (downloader.progress[galleryID]?.isEmpty() == true) { //Gallery not found
timer.cancel() update = false
Snackbar Snackbar
.make(reader_layout, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE) .make(binding.root, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
.show() .show()
return@launch
} }
histories.add(galleryID) binding.downloadProgressbar.max = binding.recyclerview.adapter?.itemCount ?: 0
binding.downloadProgressbar.progress =
runOnUiThread { downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
reader_download_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0
reader_download_progressbar.progress = downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
reader_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0
if (title == getString(R.string.reader_loading)) { if (title == getString(R.string.reader_loading)) {
val reader = cache.metadata.reader val galleryInfo = cache.metadata.galleryInfo
if (reader != null) { if (galleryInfo != null) {
with (reader_recyclerview.adapter as ReaderAdapter) { with(binding.recyclerview.adapter as ReaderAdapter) {
this.reader = reader this.galleryInfo = galleryInfo
notifyDataSetChanged() notifyDataSetChanged()
} }
title = reader.galleryInfo.title title = galleryInfo.title
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${reader.galleryInfo.files.size}" menu?.findItem(R.id.reader_menu_page_indicator)?.title =
"$currentPage/${galleryInfo.files.size}"
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.hitomi)
when (reader.code) {
Code.HITOMI -> R.drawable.hitomi
Code.HIYOBI -> R.drawable.ic_hiyobi
else -> android.R.color.transparent
})
} }
} }
if (downloader.isCompleted(galleryID)) { //Download finished if (downloader.isCompleted(galleryID)) { //Download finished
reader_download_progressbar.visibility = View.GONE binding.downloadProgressbar.visibility = View.GONE
animateDownloadFAB(false) animateDownloadFAB(false)
} }
@@ -318,7 +332,7 @@ class ReaderActivity : BaseActivity() {
} }
private fun initView() { private fun initView() {
with(reader_recyclerview) { with(binding.recyclerview) {
adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply { adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
onItemClickListener = { onItemClickListener = {
if (isScroll) { if (isScroll) {
@@ -328,7 +342,7 @@ class ReaderActivity : BaseActivity() {
scrollMode(false) scrollMode(false)
fullscreen(true) fullscreen(true)
} else { } else {
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPosition(currentPage) //Moves to next page because currentPage is 1-based indexing (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing
} }
} }
} }
@@ -338,9 +352,9 @@ class ReaderActivity : BaseActivity() {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
if (dy < 0) if (dy < 0)
this@ReaderActivity.reader_fab.showMenuButton(true) binding.fab.showMenuButton(true)
else if (dy > 0) else if (dy > 0)
this@ReaderActivity.reader_fab.hideMenuButton(true) binding.fab.hideMenuButton(true)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager val layoutManager = recyclerView.layoutManager as LinearLayoutManager
@@ -348,18 +362,18 @@ class ReaderActivity : BaseActivity() {
return return
currentPage = layoutManager.findFirstVisibleItemPosition()+1 currentPage = layoutManager.findFirstVisibleItemPosition()+1
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}" menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}"
this@ReaderActivity.reader_progressbar.progress = currentPage
} }
}) })
} }
with(reader_fab_download) { with(binding.downloadFab) {
animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button
setOnClickListener { setOnClickListener {
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean("cache_disable", false)) requestNotificationPermission(
Toast.makeText(context, R.string.settings_download_when_cache_disable_warning, Toast.LENGTH_SHORT).show() this@ReaderActivity,
else { requestNotificationPermssionLauncher
) {
val downloadManager = DownloadManager.getInstance(this@ReaderActivity) val downloadManager = DownloadManager.getInstance(this@ReaderActivity)
if (downloadManager.isDownloading(galleryID)) { if (downloadManager.isDownloading(galleryID)) {
@@ -367,51 +381,47 @@ class ReaderActivity : BaseActivity() {
animateDownloadFAB(false) animateDownloadFAB(false)
} else { } else {
downloadManager.addDownloadFolder(galleryID) downloadManager.addDownloadFolder(galleryID)
DownloadService.download(context, galleryID, true)
animateDownloadFAB(true) animateDownloadFAB(true)
} }
} }
} }
} }
with(reader_fab_retry) { with(binding.retryFab) {
setImageResource(R.drawable.refresh) setImageResource(R.drawable.refresh)
setOnClickListener { setOnClickListener {
downloader?.cancel(galleryID) DownloadService.download(context, galleryID)
downloader?.download(galleryID)
} }
} }
with(reader_fab_auto) { with(binding.autoFab) {
setImageResource(R.drawable.clock_start) setImageResource(R.drawable.eye_white)
setOnClickListener { setOnClickListener {
if (autoTimer == null) { when {
autoTimer = timer(initialDelay = 10000L, period = 10000L) { ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
CoroutineScope(Dispatchers.Main).launch { toggleCamera()
with(this@ReaderActivity.reader_recyclerview) {
val lastItem =
(layoutManager as LinearLayoutManager).findLastCompletelyVisibleItemPosition()
if (lastItem < adapter!!.itemCount - 1)
(layoutManager as LinearLayoutManager).scrollToPosition(lastItem + 1)
} }
Build.VERSION.SDK_INT >= 23 && shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
AlertDialog.Builder(this@ReaderActivity)
.setTitle(R.string.warning)
.setMessage(R.string.camera_denied)
.setPositiveButton(android.R.string.ok) { _, _ ->}
.show()
} }
} else ->
setImageResource(R.drawable.clock_end) requestPermissionLauncher.launch(Manifest.permission.CAMERA)
} else {
autoTimer?.cancel()
autoTimer = null
setImageResource(R.drawable.clock_start)
} }
} }
} }
with(reader_fab_fullscreen) { with(binding.fullscreenFab) {
setImageResource(R.drawable.ic_fullscreen) setImageResource(R.drawable.ic_fullscreen)
setOnClickListener { setOnClickListener {
isFullscreen = true isFullscreen = true
fullscreen(isFullscreen) fullscreen(isFullscreen)
this@ReaderActivity.reader_fab.close(true) binding.fab.close(true)
} }
} }
} }
@@ -421,33 +431,49 @@ class ReaderActivity : BaseActivity() {
if (isFullscreen) { if (isFullscreen) {
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
supportActionBar?.hide() supportActionBar?.hide()
this@ReaderActivity.reader_fab.visibility = View.INVISIBLE binding.fab.visibility = View.INVISIBLE
binding.scroller.let {
it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_height)
it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_width)
it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb_horizontal)
it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.HORIZONTAL
}
} else { } else {
flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv() flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
supportActionBar?.show() supportActionBar?.show()
this@ReaderActivity.reader_fab.visibility = View.VISIBLE binding.fab.visibility = View.VISIBLE
binding.scroller.let {
it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_width)
it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_height)
it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb)
it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.VERTICAL
}
} }
window.attributes = this window.attributes = this
} }
reader_recyclerview.adapter = reader_recyclerview.adapter // Force to redraw binding.recyclerview.adapter = binding.recyclerview.adapter // Force to redraw
} }
private fun scrollMode(isScroll: Boolean) { private fun scrollMode(isScroll: Boolean) {
if (isScroll) { if (isScroll) {
snapHelper.attachToRecyclerView(null) snapHelper.attachToRecyclerView(null)
reader_recyclerview.layoutManager = LinearLayoutManager(this) binding.recyclerview.layoutManager = LinearLayoutManager(this)
} else { } else {
snapHelper.attachToRecyclerView(reader_recyclerview) snapHelper.attachToRecyclerView(binding.recyclerview)
reader_recyclerview.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, Preferences["rtl", false]) binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) {
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
extraLayoutSpace.fill(600)
}
}
} }
(reader_recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0) (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
} }
private fun animateDownloadFAB(animate: Boolean) { private fun animateDownloadFAB(animate: Boolean) {
with(reader_fab_download) { with(binding.downloadFab) {
if (animate) { if (animate) {
val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading) val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading)
@@ -475,13 +501,119 @@ class ReaderActivity : BaseActivity() {
} }
} }
override fun onLowMemory() { val cameraCallback: (List<Face>) -> Unit = callback@{ faces ->
super.onLowMemory() binding.eyeCard.dot.let {
Glide.get(this).onLowMemory() it.visibility = View.VISIBLE
CoroutineScope(Dispatchers.Main).launch {
delay(50)
it.visibility = View.INVISIBLE
}
} }
override fun onTrimMemory(level: Int) { if (faces.size != 1)
super.onTrimMemory(level) ContextCompat.getDrawable(this, R.drawable.eye_off).let {
Glide.get(this).onTrimMemory(level) with(binding.eyeCard) {
leftEye.setImageDrawable(it)
rightEye.setImageDrawable(it)
}
return@callback
}
val (left, right) = Pair(
faces[0].rightEyeOpenProbability?.let { it > 0.4 } == true,
faces[0].leftEyeOpenProbability?.let { it > 0.4 } == true
)
with(binding.eyeCard) {
leftEye.setImageDrawable(
ContextCompat.getDrawable(
leftEye.context,
if (left) R.drawable.eye else R.drawable.eye_closed
)
)
rightEye.setImageDrawable(
ContextCompat.getDrawable(
rightEye.context,
if (right) R.drawable.eye else R.drawable.eye_closed
)
)
}
when {
// Both closed / opened
!left.xor(right) -> {
eyeType = null
eyeTime = 0L
}
!left -> {
if (eyeType != Eye.LEFT) {
eyeType = Eye.LEFT
eyeTime = System.currentTimeMillis()
}
}
!right -> {
if (eyeType != Eye.RIGHT) {
eyeType = Eye.RIGHT
eyeTime = System.currentTimeMillis()
}
}
}
if (eyeType != null && System.currentTimeMillis() - eyeTime > 100) {
(binding.recyclerview.layoutManager as LinearLayoutManager).let {
it.scrollToPositionWithOffset(when(eyeType!!) {
Eye.RIGHT -> {
if (it.reverseLayout) currentPage - 2 else currentPage
}
Eye.LEFT -> {
if (it.reverseLayout) currentPage else currentPage - 2
}
}, 0)
}
eyeTime = System.currentTimeMillis() + 500
}
}
private fun toggleCamera() {
val eyes = binding.eyeCard.root
when (camera) {
null -> {
binding.autoFab.labelText = getString(R.string.reader_fab_auto_cancel)
binding.autoFab.setImageResource(R.drawable.eye_off_white)
eyes.apply {
visibility = View.VISIBLE
TranslateAnimation(0F, 0F, -100F, 0F).apply {
duration = 500
fillAfter = false
interpolator = OvershootInterpolator()
}.let { startAnimation(it) }
}
startCamera(this, cameraCallback)
cameraEnabled = true
}
else -> {
binding.autoFab.labelText = getString(R.string.reader_fab_auto)
binding.autoFab.setImageResource(R.drawable.eye_white)
eyes.apply {
TranslateAnimation(0F, 0F, 0F, -100F).apply {
duration = 500
fillAfter = false
interpolator = AnticipateInterpolator()
setAnimationListener(object: Animation.AnimationListener {
override fun onAnimationStart(p0: Animation?) {}
override fun onAnimationRepeat(p0: Animation?) {}
override fun onAnimationEnd(p0: Animation?) {
eyes.visibility = View.GONE
}
})
}.let { startAnimation(it) }
}
closeCamera()
cameraEnabled = false
}
}
} }
} }

View File

@@ -18,24 +18,10 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.snackbar.Snackbar
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.ui.fragment.LockSettingsFragment
import xyz.quaver.pupil.ui.fragment.SettingsFragment import xyz.quaver.pupil.ui.fragment.SettingsFragment
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.normalizeID
import java.nio.charset.Charset
class SettingsActivity : BaseActivity() { class SettingsActivity : BaseActivity() {
@@ -56,19 +42,4 @@ class SettingsActivity : BaseActivity() {
return true return true
} }
@SuppressLint("InlinedApi")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
when (requestCode) {
R.id.request_write_permission_and_saf.normalizeID() -> {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra("android.content.extra.SHOW_ADVANCED", true)
}
startActivityForResult(intent, R.id.request_download_folder.normalizeID())
}
}
}
}
} }

View File

@@ -0,0 +1,382 @@
package xyz.quaver.pupil.ui
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.net.wifi.WpsInfo
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pDeviceList
import android.net.wifi.p2p.WifiP2pInfo
import android.net.wifi.p2p.WifiP2pManager
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.commit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.receiver.WifiDirectBroadcastReceiver
import xyz.quaver.pupil.services.TransferClientService
import xyz.quaver.pupil.services.TransferPacket
import xyz.quaver.pupil.services.TransferServerService
import xyz.quaver.pupil.ui.fragment.TransferConnectedFragment
import xyz.quaver.pupil.ui.fragment.TransferDirectionFragment
import xyz.quaver.pupil.ui.fragment.TransferPermissionFragment
import xyz.quaver.pupil.ui.fragment.TransferSelectDataFragment
import xyz.quaver.pupil.ui.fragment.TransferTargetFragment
import xyz.quaver.pupil.ui.fragment.TransferWaitForConnectionFragment
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class TransferActivity : AppCompatActivity(R.layout.transfer_activity) {
private val viewModel: TransferViewModel by viewModels()
private val intentFilter = IntentFilter().apply {
addAction(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_PEERS_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
addAction(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION)
}
private lateinit var manager: WifiP2pManager
private lateinit var channel: WifiP2pManager.Channel
private var receiver: BroadcastReceiver? = null
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
viewModel.setStep(TransferStep.TARGET)
} else {
viewModel.setStep(TransferStep.PERMISSION)
}
}
private var clientServiceBinder: TransferClientService.Binder? = null
private val clientServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
clientServiceBinder = service as TransferClientService.Binder
}
override fun onServiceDisconnected(name: ComponentName?) {
clientServiceBinder = null
}
}
private fun checkPermission(force: Boolean = false): Boolean {
val permissionRequired = if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) {
Manifest.permission.ACCESS_FINE_LOCATION
} else {
Manifest.permission.NEARBY_WIFI_DEVICES
}
val permissionGranted =
ActivityCompat.checkSelfPermission(this, permissionRequired) == PackageManager.PERMISSION_GRANTED
val shouldShowRationale =
ActivityCompat.shouldShowRequestPermissionRationale(this, permissionRequired)
if (!permissionGranted) {
if (shouldShowRationale && force) {
viewModel.setStep(TransferStep.PERMISSION)
} else {
requestPermissionLauncher.launch(permissionRequired)
}
return false
}
return true
}
private fun handleServerResponse(response: TransferPacket?) {
when (response) {
is TransferPacket.ListResponse -> {
Log.d("PUPILD", "Received list response $response")
}
else -> {
Log.d("PUPILD", "Received invalid response $response")
}
}
}
private suspend fun WifiP2pManager.disconnect() {
suspendCoroutine { continuation ->
removeGroup(channel, object : WifiP2pManager.ActionListener {
override fun onSuccess() {
continuation.resume(Unit)
}
override fun onFailure(reason: Int) {
continuation.resume(Unit)
}
})
}
suspendCoroutine { continuation ->
cancelConnect(channel, object: WifiP2pManager.ActionListener {
override fun onSuccess() {
continuation.resume(Unit)
}
override fun onFailure(reason: Int) {
continuation.resume(Unit)
}
})
}
}
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.hide()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
manager = getSystemService(WIFI_P2P_SERVICE) as WifiP2pManager
channel = manager.initialize(this, mainLooper, null)
viewModel.peerToConnect.observe(this) { peer ->
if (peer == null) { return@observe }
if (!checkPermission()) { return@observe }
val config = WifiP2pConfig().apply {
deviceAddress = peer.deviceAddress
wps.setup = WpsInfo.PBC
}
manager.connect(channel, config, object: WifiP2pManager.ActionListener {
override fun onSuccess() { }
override fun onFailure(reason: Int) {
viewModel.connect(null)
}
})
}
lifecycleScope.launch {
viewModel.messageQueue.consumeEach {
clientServiceBinder?.sendPacket(it)?.getOrNull()?.let(::handleServerResponse)
}
}
lifecycleScope.launch {
viewModel.step.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).collectLatest step@{ step ->
when (step) {
TransferStep.TARGET,
TransferStep.TARGET_FORCE -> {
if (!checkPermission(step == TransferStep.TARGET_FORCE)) {
return@step
}
manager.discoverPeers(channel, object: WifiP2pManager.ActionListener {
override fun onSuccess() { }
override fun onFailure(reason: Int) {
}
})
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferTargetFragment())
}
val hostAddress = viewModel.connectionInfo.filterNotNull().first {
it.groupFormed
}.groupOwnerAddress.hostAddress
val intent = Intent(this@TransferActivity, TransferClientService::class.java).also {
it.putExtra("address", hostAddress)
}
ContextCompat.startForegroundService(this@TransferActivity, intent)
bindService(intent, clientServiceConnection, BIND_AUTO_CREATE)
viewModel.setStep(TransferStep.SELECT_DATA)
}
TransferStep.DIRECTION -> {
manager.disconnect()
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferDirectionFragment())
}
}
TransferStep.PERMISSION -> {
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferPermissionFragment())
}
}
TransferStep.WAIT_FOR_CONNECTION -> {
Log.d("PUPILD", "wait for connection")
if (!checkPermission()) { return@step }
runCatching {
suspendCoroutine { continuation ->
manager.createGroup(channel, object: WifiP2pManager.ActionListener {
override fun onSuccess() {
continuation.resume(Unit)
}
override fun onFailure(reason: Int) {
continuation.resumeWithException(Exception("Failed to create group $reason"))
}
})
}
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferWaitForConnectionFragment())
}
val address = viewModel.connectionInfo.filterNotNull().first {
it.groupFormed && it.isGroupOwner
}.groupOwnerAddress.hostAddress
val intent = Intent(this@TransferActivity, TransferServerService::class.java).also {
it.putExtra("address", address)
}
ContextCompat.startForegroundService(this@TransferActivity, intent)
val binder: TransferServerService.Binder = suspendCoroutine { continuation ->
bindService(intent, object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
continuation.resume(service as TransferServerService.Binder)
}
override fun onServiceDisconnected(name: ComponentName?) { }
}, BIND_AUTO_CREATE)
}
binder.channel.receive()
viewModel.setStep(TransferStep.CONNECTED)
}.onFailure {
Log.e("PUPILD", "Failed to create group", it)
}
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferWaitForConnectionFragment())
}
}
TransferStep.CONNECTED -> {
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferConnectedFragment())
}
}
TransferStep.SELECT_DATA -> {
supportFragmentManager.commit(true) {
replace(R.id.fragment_container_view, TransferSelectDataFragment())
}
}
}
}
}
}
override fun onResume() {
super.onResume()
bindService(Intent(this, TransferClientService::class.java), clientServiceConnection, BIND_AUTO_CREATE)
WifiDirectBroadcastReceiver(manager, channel, viewModel).also {
receiver = it
registerReceiver(it, intentFilter)
}
}
override fun onPause() {
super.onPause()
unbindService(clientServiceConnection)
receiver?.let { unregisterReceiver(it) }
receiver = null
}
}
enum class TransferStep {
TARGET, TARGET_FORCE, DIRECTION, PERMISSION, WAIT_FOR_CONNECTION, CONNECTED, SELECT_DATA
}
enum class ErrorType {
}
class TransferViewModel : ViewModel() {
private val _step: MutableStateFlow<TransferStep> = MutableStateFlow(TransferStep.DIRECTION)
val step: StateFlow<TransferStep> = _step
private val _error = MutableLiveData<ErrorType?>(null)
val error: LiveData<ErrorType?> = _error
private val _wifiP2pEnabled: MutableLiveData<Boolean> = MutableLiveData(false)
val wifiP2pEnabled: LiveData<Boolean> = _wifiP2pEnabled
private val _thisDevice: MutableLiveData<WifiP2pDevice?> = MutableLiveData(null)
val thisDevice: LiveData<WifiP2pDevice?> = _thisDevice
private val _peers: MutableLiveData<WifiP2pDeviceList?> = MutableLiveData(null)
val peers: LiveData<WifiP2pDeviceList?> = _peers
private val _connectionInfo: MutableStateFlow<WifiP2pInfo?> = MutableStateFlow(null)
val connectionInfo: StateFlow<WifiP2pInfo?> = _connectionInfo
private val _peerToConnect: MutableLiveData<WifiP2pDevice?> = MutableLiveData(null)
val peerToConnect: LiveData<WifiP2pDevice?> = _peerToConnect
val messageQueue: Channel<TransferPacket> = Channel()
fun setStep(step: TransferStep) {
Log.d("PUPILD", "Set step: $step")
_step.value = step
}
fun setWifiP2pEnabled(enabled: Boolean) {
_wifiP2pEnabled.value = enabled
}
fun setThisDevice(device: WifiP2pDevice?) {
_thisDevice.value = device
}
fun setPeers(peers: WifiP2pDeviceList?) {
_peers.value = peers
}
fun setConnectionInfo(info: WifiP2pInfo?) {
_connectionInfo.value = info
}
fun setError(error: ErrorType?) {
_error.value = error
}
fun connect(device: WifiP2pDevice?) {
_peerToConnect.value = device
}
fun ping() {
messageQueue.trySend(TransferPacket.Ping)
}
fun list() {
messageQueue.trySend(TransferPacket.ListRequest)
}
}

View File

@@ -18,30 +18,30 @@
package xyz.quaver.pupil.ui.dialog package xyz.quaver.pupil.ui.dialog
import android.annotation.SuppressLint
import android.app.Dialog import android.app.Dialog
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import kotlinx.android.synthetic.main.dialog_default_query.* import androidx.fragment.app.DialogFragment
import kotlinx.android.synthetic.main.dialog_default_query.view.*
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.DefaultQueryDialogBinding
import xyz.quaver.pupil.types.Tags import xyz.quaver.pupil.types.Tags
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
class DefaultQueryDialog(context : Context) : AlertDialog(context) { class DefaultQueryDialog : DialogFragment() {
private val languages = context.resources.getStringArray(R.array.languages).map { private val languages: Map<String, String> by lazy {
requireContext().resources.getStringArray(R.array.languages).map {
it.split("|").let { split -> it.split("|").let { split ->
Pair(split[0], split[1]) Pair(split[0], split[1])
} }
}.toMap() }.toMap()
private val reverseLanguages = languages.entries.associate { (k, v) -> v to k } }
private val reverseLanguages: Map<String, String> by lazy {
languages.entries.associate { (k, v) -> v to k }
}
private val excludeBL = "-male:yaoi" private val excludeBL = "-male:yaoi"
private val excludeGuro = listOf("-female:guro", "-male:guro") private val excludeGuro = listOf("-female:guro", "-male:guro")
@@ -49,46 +49,15 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
var onPositiveButtonClickListener : ((Tags) -> (Unit))? = null var onPositiveButtonClickListener : ((Tags) -> (Unit))? = null
@SuppressLint("InflateParams") private var _binding: DefaultQueryDialogBinding? = null
override fun onCreate(savedInstanceState: Bundle?) { private val binding get() = _binding!!
setTitle(R.string.default_query_dialog_title)
setView(build())
setButton(Dialog.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ ->
val newTags = Tags.parse(default_query_dialog_edittext.text.toString())
with(default_query_dialog_language_selector) { private fun initView() {
if (selectedItemPosition != 0)
newTags.add("language:${reverseLanguages[selectedItem]}")
}
if (default_query_dialog_BL_checkbox.isChecked)
newTags.add(excludeBL)
if (default_query_dialog_guro_checkbox.isChecked)
excludeGuro.forEach { tag ->
newTags.add(tag)
}
if (default_query_dialog_loli_checkbox.isChecked)
excludeLoli.forEach { tag ->
newTags.add(tag)
}
onPositiveButtonClickListener?.invoke(newTags)
}
super.onCreate(savedInstanceState)
}
@SuppressLint("InflateParams")
private fun build() : View {
val tags = Tags.parse( val tags = Tags.parse(
Preferences["default_query"] Preferences["default_query"]
) )
val view = LayoutInflater.from(context).inflate(R.layout.dialog_default_query, null) with(binding.languageSelector) {
with(view.default_query_dialog_language_selector) {
adapter = adapter =
ArrayAdapter( ArrayAdapter(
context, context,
@@ -111,13 +80,13 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
} }
} }
with(view.default_query_dialog_BL_checkbox) { with(binding.BLCheckbox) {
isChecked = tags.contains(excludeBL) isChecked = tags.contains(excludeBL)
if (tags.contains(excludeBL)) if (tags.contains(excludeBL))
tags.remove(excludeBL) tags.remove(excludeBL)
} }
with(view.default_query_dialog_guro_checkbox) { with(binding.guroCheckbox) {
isChecked = excludeGuro.all { tags.contains(it) } isChecked = excludeGuro.all { tags.contains(it) }
if (excludeGuro.all { tags.contains(it) }) if (excludeGuro.all { tags.contains(it) })
excludeGuro.forEach { excludeGuro.forEach {
@@ -125,7 +94,7 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
} }
} }
with(view.default_query_dialog_loli_checkbox) { with(binding.loliCheckbox) {
isChecked = excludeLoli.all { tags.contains(it) } isChecked = excludeLoli.all { tags.contains(it) }
if (excludeLoli.all { tags.contains(it) }) if (excludeLoli.all { tags.contains(it) })
excludeLoli.forEach { excludeLoli.forEach {
@@ -133,7 +102,7 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
} }
} }
with(view.default_query_dialog_edittext) { with(binding.edittext) {
setText(tags.toString(), android.widget.TextView.BufferType.EDITABLE) setText(tags.toString(), android.widget.TextView.BufferType.EDITABLE)
addTextChangedListener(object : TextWatcher { addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged( override fun beforeTextChanged(
@@ -153,13 +122,50 @@ class DefaultQueryDialog(context : Context) : AlertDialog(context) {
s.replace( s.replace(
0, 0,
s.length, s.length,
s.toString().toLowerCase(java.util.Locale.getDefault()) s.toString().lowercase()
) )
} }
}) })
} }
}
return view override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = DefaultQueryDialogBinding.inflate(layoutInflater)
initView()
return AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.default_query_dialog_title)
setView(binding.root)
setPositiveButton(android.R.string.ok) { _, _ ->
val newTags = Tags.parse(binding.edittext.text.toString())
with(binding.languageSelector) {
if (selectedItemPosition != 0)
newTags.add("language:${reverseLanguages[selectedItem]}")
}
if (binding.BLCheckbox.isChecked)
newTags.add(excludeBL)
if (binding.guroCheckbox.isChecked)
excludeGuro.forEach { tag ->
newTags.add(tag)
}
if (binding.loliCheckbox.isChecked)
excludeLoli.forEach { tag ->
newTags.add(tag)
}
onPositiveButtonClickListener?.invoke(newTags)
}
}.create()
}
override fun onDestroy() {
super.onDestroy()
_binding = null
} }
} }

View File

@@ -18,17 +18,17 @@
package xyz.quaver.pupil.ui.dialog package xyz.quaver.pupil.ui.dialog
import android.annotation.SuppressLint
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.dialog_download_folder_name.view.* import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.DownloadFolderNameDialogBinding
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.formatDownloadFolder import xyz.quaver.pupil.util.formatDownloadFolder
@@ -37,38 +37,49 @@ import xyz.quaver.pupil.util.formatMap
class DownloadFolderNameDialogFragment : DialogFragment() { class DownloadFolderNameDialogFragment : DialogFragment() {
@SuppressLint("InflateParams") private var _binding: DownloadFolderNameDialogBinding? = null
private fun build(): View { private val binding get() = _binding!!
val galleryID = Cache.instances.let { if (it.size() == 0) 1199708 else it.keyAt((0 until it.size()).random()) }
val galleryBlock = runBlocking { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
Cache.getInstance(requireContext(), galleryID).getGalleryBlock() _binding = DownloadFolderNameDialogBinding.inflate(layoutInflater)
initView()
return Dialog(requireContext()).apply {
setContentView(binding.root)
window?.attributes?.width = ViewGroup.LayoutParams.MATCH_PARENT
}
} }
return layoutInflater.inflate(R.layout.dialog_download_folder_name, null).apply { override fun onDestroy() {
message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolder() ?: "") super.onDestroy()
edittext.setText(Preferences["download_folder_name", "[-id-] -title-"]) _binding = null
edittext.addTextChangedListener {
message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolderTest(it.toString()) ?: "")
} }
ok_button.setOnClickListener {
val newValue = edittext.text.toString() private fun initView() {
val galleryID = Cache.instances.let { if (it.size == 0) 1199708 else it.keys.elementAt((0 until it.size).random()) }
CoroutineScope(Dispatchers.IO).launch {
val galleryBlock = Cache.getInstance(requireContext(), galleryID).getGalleryBlock()
binding.message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolder() ?: "")
binding.edittext.addTextChangedListener {
binding.message.text = requireContext().getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolderTest(it.toString()) ?: "")
}
}
binding.edittext.setText(Preferences["download_folder_name", "[-id-] -title-"])
binding.okButton.setOnClickListener {
val newValue = binding.edittext.text.toString()
if ((newValue as? String)?.contains("/") != false) { if ((newValue as? String)?.contains("/") != false) {
Snackbar.make(this, R.string.settings_invalid_download_folder_name, Snackbar.LENGTH_SHORT).show() Snackbar.make(binding.root, R.string.settings_invalid_download_folder_name, Snackbar.LENGTH_SHORT).show()
return@setOnClickListener return@setOnClickListener
} }
Preferences["download_folder_name"] = edittext.text.toString() Preferences["download_folder_name"] = binding.edittext.text.toString()
dismiss() dismiss()
} }
} }
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
Dialog(requireContext()).apply {
setContentView(build())
window?.attributes?.width = ViewGroup.LayoutParams.MATCH_PARENT
}
} }

View File

@@ -18,179 +18,139 @@
package xyz.quaver.pupil.ui.dialog package xyz.quaver.pupil.ui.dialog
import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.Dialog import android.app.Dialog
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import androidx.activity.result.contract.ActivityResultContracts
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.item_download_folder.view.*
import net.rdrei.android.dirchooser.DirectoryChooserActivity
import net.rdrei.android.dirchooser.DirectoryChooserConfig
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.toFile
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.DownloadLocationDialogBinding
import xyz.quaver.pupil.databinding.DownloadLocationItemBinding
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.byteToString import xyz.quaver.pupil.util.byteToString
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.migrate
import xyz.quaver.pupil.util.normalizeID
import java.io.File import java.io.File
class DownloadLocationDialogFragment : DialogFragment() { class DownloadLocationDialogFragment : DialogFragment() {
private val entries = mutableMapOf<File?, View>()
@SuppressLint("InflateParams") private var _binding: DownloadLocationDialogBinding? = null
private fun build() : View? { private val binding get() = _binding!!
val context = context ?: return null
val view = layoutInflater.inflate(R.layout.dialog_download_folder, null) as LinearLayout private val entries = mutableMapOf<File?, DownloadLocationItemBinding>()
val externalFilesDirs = ContextCompat.getExternalFilesDirs(context, null) private val requestDownloadFolderLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
val context = context ?: return@registerForActivityResult
val dialog = dialog ?: return@registerForActivityResult
it.data?.data?.also { uri ->
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
if (kotlin.runCatching { FileX(context, uri).canWrite() }.getOrDefault(false)) {
entries[null]?.locationAvailable?.text = uri.toFile(context)?.canonicalPath
Preferences["download_folder"] = uri.toString()
} else {
Snackbar.make(
dialog.window!!.decorView.rootView,
R.string.settings_download_folder_not_writable,
Snackbar.LENGTH_LONG
).show()
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
}
}
} else {
val downloadFolder = DownloadManager.getInstance(context ?: return@registerForActivityResult).downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
if (key == null)
entries[key]!!.locationAvailable.text = downloadFolder
else {
entries[null]!!.button.isChecked = false
entries[key]!!.button.isChecked = true
}
}
}
private fun initView() {
val externalFilesDirs = ContextCompat.getExternalFilesDirs(requireContext(), null)
externalFilesDirs.forEachIndexed { index, dir -> externalFilesDirs.forEachIndexed { index, dir ->
dir ?: return@forEachIndexed dir ?: return@forEachIndexed
view.addView(layoutInflater.inflate(R.layout.item_download_folder, view, false).apply { DownloadLocationItemBinding.inflate(layoutInflater, binding.root, true).apply {
location_type.text = context.getString(when (index) { locationType.text = requireContext().getString(when (index) {
0 -> R.string.settings_download_folder_internal 0 -> R.string.settings_download_folder_internal
else -> R.string.settings_download_folder_removable else -> R.string.settings_download_folder_removable
}) })
location_available.text = context.getString( locationAvailable.text = requireContext().getString(
R.string.settings_download_folder_available, R.string.settings_download_folder_available,
byteToString(dir.freeSpace) byteToString(dir.freeSpace)
) )
setOnClickListener { root.setOnClickListener {
entries.values.forEach { entries.values.forEach { entry ->
it.button.isChecked = false entry.button.isChecked = false
} }
button.performClick() button.performClick()
Preferences["download_folder"] = dir.toUri().toString() Preferences["download_folder"] = dir.toUri().toString()
} }
entries[dir] = this entries[dir] = this
}) }
} }
view.addView(layoutInflater.inflate(R.layout.item_download_folder, view, false).apply { DownloadLocationItemBinding.inflate(layoutInflater, binding.root, true).apply {
location_type.text = context.getString(R.string.settings_download_folder_custom) locationType.text = requireContext().getString(R.string.settings_download_folder_custom)
setOnClickListener { root.setOnClickListener {
entries.values.forEach { entries.values.forEach { entry ->
it.button.isChecked = false entry.button.isChecked = false
} }
button.performClick() button.performClick()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra("android.content.extra.SHOW_ADVANCED", true) putExtra("android.content.extra.SHOW_ADVANCED", true)
} }
startActivityForResult(intent, R.id.request_download_folder.normalizeID()) requestDownloadFolderLauncher.launch(intent)
} else { // Can't use SAF on old Androids!
val config = DirectoryChooserConfig.builder()
.newDirectoryName("Pupil")
.allowNewDirectoryNameModification(true)
.build()
val intent = Intent(context, DirectoryChooserActivity::class.java).apply {
putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config)
}
startActivityForResult(intent, R.id.request_download_folder_old.normalizeID())
}
} }
entries[null] = this entries[null] = this
}) }
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath val downloadFolder = DownloadManager.getInstance(requireContext()).downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder } val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.location_available.text = downloadFolder if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
return view
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(requireContext()) _binding = DownloadLocationDialogBinding.inflate(layoutInflater)
builder initView()
.setTitle(R.string.settings_download_folder)
.setView(build()) return AlertDialog.Builder(requireContext()).apply {
.setPositiveButton(requireContext().getText(android.R.string.ok)) { _, _ -> setTitle(R.string.settings_download_folder)
setView(binding.root)
setPositiveButton(requireContext().getText(android.R.string.ok)) { _, _ ->
if (Preferences["download_folder", ""].isEmpty()) if (Preferences["download_folder", ""].isEmpty())
Preferences["download_folder"] = context?.getExternalFilesDir(null)?.toUri()?.toString() ?: "" Preferences["download_folder"] = context.getExternalFilesDir(null)?.toUri()?.toString() ?: ""
DownloadManager.getInstance(requireContext()).migrate()
} }
isCancelable = false isCancelable = false
}.create()
return builder.create()
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onDestroy() {
when (requestCode) { super.onDestroy()
R.id.request_download_folder.normalizeID() -> { _binding = null
if (resultCode == Activity.RESULT_OK) {
val activity = activity ?: return
val context = context ?: return
val dialog = dialog ?: return
data?.data?.also { uri ->
val takeFlags: Int =
activity.intent.flags and
(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
if (kotlin.runCatching { FileX(context, uri).canWrite() }.getOrDefault(false))
Preferences["download_folder"] = uri.toString()
else {
Snackbar.make(
dialog.window!!.decorView.rootView,
R.string.settings_download_folder_not_writable,
Snackbar.LENGTH_LONG
).show()
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.location_available.text = downloadFolder
} }
} }
}
}
R.id.request_download_folder_old.normalizeID() -> {
val context = context ?: return
val dialog = dialog ?: return
if (resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
val directory = data?.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR)!!
if (!File(directory).canWrite()) {
Snackbar.make(
dialog.window!!.decorView.rootView,
R.string.settings_download_folder_not_writable,
Snackbar.LENGTH_LONG
).show()
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.location_available.text = downloadFolder
}
else
Preferences["download_folder"] = File(directory).canonicalPath
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
}

View File

@@ -18,48 +18,49 @@
package xyz.quaver.pupil.ui.dialog package xyz.quaver.pupil.ui.dialog
import android.app.Dialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout.LayoutParams import android.widget.LinearLayout.LayoutParams
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.RequestManager
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.dialog_gallery.*
import kotlinx.android.synthetic.main.dialog_gallery_details.view.*
import kotlinx.android.synthetic.main.dialog_gallery_dotindicator.view.*
import kotlinx.android.synthetic.main.item_gallery_details.view.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import xyz.quaver.hitomi.Gallery import xyz.quaver.pupil.hitomi.Gallery
import xyz.quaver.hitomi.getGallery import xyz.quaver.pupil.hitomi.getGallery
import xyz.quaver.pupil.BuildConfig
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.GalleryBlockAdapter import xyz.quaver.pupil.adapters.GalleryBlockAdapter
import xyz.quaver.pupil.adapters.ThumbnailPageAdapter import xyz.quaver.pupil.adapters.ThumbnailPageAdapter
import xyz.quaver.pupil.histories import xyz.quaver.pupil.databinding.*
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.types.Tag import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.ReaderActivity import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.ui.view.TagChip import xyz.quaver.pupil.ui.view.TagChip
import xyz.quaver.pupil.util.ItemClickSupport import xyz.quaver.pupil.util.ItemClickSupport
import xyz.quaver.pupil.util.downloader.Cache import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.wordCapitalize import xyz.quaver.pupil.util.wordCapitalize
import java.util.*
import kotlin.collections.ArrayList
class GalleryDialog(context: Context, private val glide: RequestManager, private val galleryID: Int) : Dialog(context) { class GalleryDialog(context: Context, private val galleryID: Int) : AlertDialog(context) {
val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>() val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>()
private lateinit var binding: GalleryDialogBinding
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.dialog_gallery) binding = GalleryDialogBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.attributes.apply { window?.attributes.apply {
this ?: return@apply this ?: return@apply
@@ -68,13 +69,12 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
height = LayoutParams.MATCH_PARENT height = LayoutParams.MATCH_PARENT
} }
with(gallery_fab) { with(binding.fab) {
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.arrow_right)) setImageDrawable(ContextCompat.getDrawable(context, R.drawable.arrow_right))
setOnClickListener { setOnClickListener {
context.startActivity(Intent(context, ReaderActivity::class.java).apply { context.startActivity(Intent(context, ReaderActivity::class.java).apply {
putExtra("galleryID", galleryID) putExtra("galleryID", galleryID)
}) })
histories.add(galleryID)
} }
} }
@@ -82,12 +82,12 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
try { try {
val gallery = getGallery(galleryID) val gallery = getGallery(galleryID)
gallery_cover.post { launch (Dispatchers.Main) {
gallery_progressbar.visibility = View.GONE binding.progressbar.visibility = View.GONE
gallery_title.text = gallery.title binding.title.text = gallery.title
gallery_artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() } binding.artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() }
with(gallery_type) { with(binding.type) {
text = gallery.type.wordCapitalize() text = gallery.type.wordCapitalize()
setOnClickListener { setOnClickListener {
gallery.type.let { gallery.type.let {
@@ -104,28 +104,26 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
} }
} }
glide binding.cover.showImage(Uri.parse(gallery.cover))
.load(gallery.cover)
.apply {
if (BuildConfig.CENSOR)
override(5, 8)
}.into(gallery_cover)
addDetails(gallery) addDetails(gallery)
addThumbnails(gallery) addThumbnails(gallery)
addRelated(gallery) addRelated(gallery)
} }
} catch (e: Exception) { } catch (e: Exception) {
Snackbar.make(gallery_layout, R.string.unable_to_connect, Snackbar.LENGTH_INDEFINITE).show() Snackbar.make(binding.root, R.string.unable_to_connect, Snackbar.LENGTH_INDEFINITE).apply {
if (Locale.getDefault().language == "ko")
setAction(context.getText(R.string.https_text)) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.https))))
}
}.show()
} }
} }
} }
private fun addDetails(gallery: Gallery) { private fun addDetails(gallery: Gallery) {
val inflater = LayoutInflater.from(context) GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
type.setText(R.string.gallery_details)
inflater.inflate(R.layout.dialog_gallery_details, gallery_contents, false).apply {
gallery_details.setText(R.string.gallery_details)
listOf( listOf(
R.string.gallery_artists, R.string.gallery_artists,
@@ -141,7 +139,18 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
listOf(gallery.language).map { Tag("language", it) }, listOf(gallery.language).map { Tag("language", it) },
gallery.series.map { Tag("series", it) }, gallery.series.map { Tag("series", it) },
gallery.characters.map { Tag("character", it) }, gallery.characters.map { Tag("character", it) },
gallery.tags.map { gallery.tags.sortedBy {
val tag = Tag.parse(it)
if (favoriteTags.contains(tag))
-1
else
when(Tag.parse(it).area) {
"female" -> 0
"male" -> 1
else -> 2
}
}.map {
Tag.parse(it).let { tag -> Tag.parse(it).let { tag ->
when { when {
tag.area != null -> tag tag.area != null -> tag
@@ -153,11 +162,11 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
).filter { ).filter {
(_, content) -> content.isNotEmpty() (_, content) -> content.isNotEmpty()
}.forEach { (title, content) -> }.forEach { (title, content) ->
inflater.inflate(R.layout.item_gallery_details, gallery_details_contents, false).apply { GalleryDialogTagsBinding.inflate(layoutInflater, contents, true).apply {
gallery_details_type.setText(title) type.setText(title)
content.forEach { tag -> content.forEach { tag ->
gallery_details_tags.addView( tags.addView(
TagChip(context, tag).apply { TagChip(context, tag).apply {
setOnClickListener { setOnClickListener {
onChipClickedHandler.forEach { handler -> onChipClickedHandler.forEach { handler ->
@@ -167,43 +176,36 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
} }
) )
} }
}.let {
gallery_details_contents.addView(it)
} }
} }
}.let {
gallery_contents.addView(it)
} }
} }
private fun addThumbnails(gallery: Gallery) { private fun addThumbnails(gallery: Gallery) {
val inflater = LayoutInflater.from(context) GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
type.setText(R.string.gallery_thumbnails)
inflater.inflate(R.layout.dialog_gallery_details, gallery_contents, false).apply {
gallery_details.setText(R.string.gallery_thumbnails)
val pager = ViewPager2(context).apply { val pager = ViewPager2(context).apply {
adapter = ThumbnailPageAdapter(glide, gallery.thumbnails) adapter = ThumbnailPageAdapter(gallery.thumbnails)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
} }
gallery_details_contents.addView( contents.addView(
pager, pager,
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
) )
LayoutInflater.from(context).inflate(R.layout.dialog_gallery_dotindicator, gallery_details_contents) // TODO: Change to direct allocation
GalleryDialogDotindicatorBinding.inflate(layoutInflater, contents, true).apply {
gallery_dotindicator.setViewPager2(pager) dotindicator.setViewPager2(pager)
}.let { }
gallery_contents.addView(it)
} }
} }
private fun addRelated(gallery: Gallery) { private fun addRelated(gallery: Gallery) {
val inflater = LayoutInflater.from(context) val galleries = mutableListOf<Int>()
val galleries = ArrayList<Int>()
val adapter = GalleryBlockAdapter(glide, galleries).apply { val adapter = GalleryBlockAdapter(galleries).apply {
onChipClickedHandler.add { tag -> onChipClickedHandler.add { tag ->
this@GalleryDialog.onChipClickedHandler.forEach { handler -> this@GalleryDialog.onChipClickedHandler.forEach { handler ->
handler.invoke(tag) handler.invoke(tag)
@@ -211,10 +213,10 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
} }
} }
inflater.inflate(R.layout.dialog_gallery_details, gallery_contents, false).apply { GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
gallery_details.setText(R.string.gallery_related) type.setText(R.string.gallery_related)
RecyclerView(context).apply { contents.addView(RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
this.adapter = adapter this.adapter = adapter
@@ -223,14 +225,9 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
context.startActivity(Intent(context, ReaderActivity::class.java).apply { context.startActivity(Intent(context, ReaderActivity::class.java).apply {
putExtra("galleryID", galleries[position]) putExtra("galleryID", galleries[position])
}) })
histories.add(galleries[position])
} }
onItemLongClickListener = { _, position, _ -> onItemLongClickListener = { _, position, _ ->
GalleryDialog( GalleryDialog(context, galleries[position]).apply {
context,
glide,
galleries[position]
).apply {
onChipClickedHandler.add { tag -> onChipClickedHandler.add { tag ->
this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) } this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) }
} }
@@ -239,12 +236,7 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
true true
} }
} }
}.let { })
gallery_details_contents.addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
}
}.let {
gallery_contents.addView(it)
}
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
gallery.related.forEach { galleryID -> gallery.related.forEach { galleryID ->
@@ -258,5 +250,6 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
} }
} }
} }
}
} }

View File

@@ -1,91 +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.ui.dialog
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.MirrorAdapter
import xyz.quaver.pupil.util.Preferences
class MirrorDialog(context: Context) : AlertDialog(context) {
class ItemTouchHelperCallback : ItemTouchHelper.Callback() {
var onMoveItem : ((Int, Int) -> (Unit))? = null
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
onMoveItem?.invoke(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}
}
@SuppressLint("InflateParams")
override fun onCreate(savedInstanceState: Bundle?) {
setTitle(R.string.settings_mirror_title)
setView(build())
setButton(Dialog.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> }
super.onCreate(savedInstanceState)
}
private fun build() : View {
return RecyclerView(context).apply recyclerview@{
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(context)
adapter = MirrorAdapter(context).apply adapter@{
val itemTouchHelper = ItemTouchHelper(ItemTouchHelperCallback().apply {
onMoveItem = this@adapter.onItemMove
}).apply {
attachToRecyclerView(this@recyclerview)
}
onStartDrag = {
itemTouchHelper.startDrag(it)
}
onItemMoved = {
Preferences["mirrors"] = it.joinToString(">")
}
}
}
}
}

View File

@@ -18,58 +18,60 @@
package xyz.quaver.pupil.ui.dialog package xyz.quaver.pupil.ui.dialog
import android.annotation.SuppressLint
import android.app.Dialog import android.app.Dialog
import android.content.Context import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import kotlinx.android.synthetic.main.dialog_proxy.view.* import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.client import xyz.quaver.pupil.client
import xyz.quaver.pupil.clientBuilder import xyz.quaver.pupil.clientBuilder
import xyz.quaver.pupil.clientHolder import xyz.quaver.pupil.clientHolder
import xyz.quaver.pupil.databinding.ProxyDialogBinding
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.ProxyInfo import xyz.quaver.pupil.util.ProxyInfo
import xyz.quaver.pupil.util.getProxyInfo import xyz.quaver.pupil.util.getProxyInfo
import xyz.quaver.pupil.util.proxyInfo import xyz.quaver.pupil.util.proxyInfo
import java.net.Proxy import java.net.Proxy
class ProxyDialog(context: Context) : Dialog(context) { class ProxyDialogFragment : DialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) { private var _binding: ProxyDialogBinding? = null
setContentView(build()) private val binding get() = _binding!!
window?.attributes?.width = ViewGroup.LayoutParams.MATCH_PARENT
super.onCreate(savedInstanceState) override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = ProxyDialogBinding.inflate(layoutInflater)
initView()
return AlertDialog.Builder(requireContext()).apply {
setView(binding.root)
}.create()
} }
@SuppressLint("InflateParams") private fun initView() {
private fun build() : View {
val proxyInfo = getProxyInfo() val proxyInfo = getProxyInfo()
val view = LayoutInflater.from(context).inflate(R.layout.dialog_proxy, null)
val enabler = { enable: Boolean -> val enabler = { enable: Boolean ->
view?.proxy_addr?.isEnabled = enable binding.addr.isEnabled = enable
view?.proxy_port?.isEnabled = enable binding.port.isEnabled = enable
view?.proxy_username?.isEnabled = enable binding.username.isEnabled = enable
view?.proxy_password?.isEnabled = enable binding.password.isEnabled = enable
if (!enable) { if (!enable) {
view?.proxy_addr?.text = null binding.addr.text = null
view?.proxy_port?.text = null binding.port.text = null
view?.proxy_username?.text = null binding.username.text = null
view?.proxy_password?.text = null binding.password.text = null
} }
} }
with(view.proxy_type_selector) { with(binding.typeSelector) {
adapter = ArrayAdapter( adapter = ArrayAdapter(
context, context,
android.R.layout.simple_spinner_dropdown_item, android.R.layout.simple_spinner_dropdown_item,
@@ -87,29 +89,29 @@ class ProxyDialog(context: Context) : Dialog(context) {
} }
} }
view.proxy_addr.setText(proxyInfo.host) binding.addr.setText(proxyInfo.host)
view.proxy_port.setText(proxyInfo.port?.toString()) binding.port.setText(proxyInfo.port?.toString())
view.proxy_username.setText(proxyInfo.username) binding.username.setText(proxyInfo.username)
view.proxy_password.setText(proxyInfo.password) binding.password.setText(proxyInfo.password)
enabler.invoke(proxyInfo.type != Proxy.Type.DIRECT) enabler.invoke(proxyInfo.type != Proxy.Type.DIRECT)
view.proxy_cancel.setOnClickListener { binding.cancelButton.setOnClickListener {
dismiss() dismiss()
} }
view.proxy_ok.setOnClickListener { binding.okButton.setOnClickListener {
val type = Proxy.Type.values()[view.proxy_type_selector.selectedItemPosition] val type = Proxy.Type.values()[binding.typeSelector.selectedItemPosition]
val addr = view.proxy_addr.text?.toString() val addr = binding.addr.text?.toString()
val port = view.proxy_port.text?.toString()?.toIntOrNull() val port = binding.port.text?.toString()?.toIntOrNull()
val username = view.proxy_username.text?.toString() val username = binding.username.text?.toString()
val password = view.proxy_password.text?.toString() val password = binding.password.text?.toString()
if (type != Proxy.Type.DIRECT) { if (type != Proxy.Type.DIRECT) {
if (addr == null || addr.isEmpty()) if (addr == null || addr.isEmpty())
view.proxy_addr.error = context.getText(R.string.proxy_dialog_error) binding.addr.error = requireContext().getText(R.string.proxy_dialog_error)
if (port == null) if (port == null)
view.proxy_port.error = context.getText(R.string.proxy_dialog_error) binding.port.error = requireContext().getText(R.string.proxy_dialog_error)
if (addr == null || addr.isEmpty() || port == null) if (addr == null || addr.isEmpty() || port == null)
return@setOnClickListener return@setOnClickListener
@@ -126,8 +128,6 @@ class ProxyDialog(context: Context) : Dialog(context) {
dismiss() dismiss()
} }
return view
} }
} }

View File

@@ -18,25 +18,72 @@
package xyz.quaver.pupil.ui.fragment package xyz.quaver.pupil.ui.fragment
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.res.Resources
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.EditText import android.widget.EditText
import android.widget.TextView import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.DrawableCompat
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import okhttp3.* import okhttp3.*
import xyz.quaver.io.FileX
import xyz.quaver.io.util.readText
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.client import xyz.quaver.pupil.client
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.favorites import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.get
import xyz.quaver.pupil.util.restore import xyz.quaver.pupil.util.restore
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.math.roundToInt
class ManageFavoritesFragment : PreferenceFragmentCompat() { class ManageFavoritesFragment : PreferenceFragmentCompat() {
private val requestBackupFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) {
return@registerForActivityResult
}
val uri = result.data?.data ?: return@registerForActivityResult
val context = context ?: return@registerForActivityResult
val view = view ?: return@registerForActivityResult
val backupData = runCatching {
FileX(context, uri).readText()?.let { Json.parseToJsonElement(it) }
}.getOrNull() ?: run{
Snackbar.make(view, context.getString(R.string.error), Toast.LENGTH_LONG).show()
return@registerForActivityResult
}
val newFavorites = backupData["favorites"]?.let { Json.decodeFromJsonElement<List<Int>>(it) }.orEmpty()
val newFavoriteTags = backupData["favorite_tags"]?.let { Json.decodeFromJsonElement<List<Tag>>(it) }.orEmpty()
favorites.addAll(newFavorites)
favoriteTags.addAll(newFavoriteTags)
Snackbar.make(view, context.getString(R.string.settings_restore_success, newFavorites.size + newFavoriteTags.size), Snackbar.LENGTH_LONG).show()
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.manage_favorites_preferences, rootKey) setPreferencesFromResource(R.xml.manage_favorites_preferences, rootKey)
@@ -47,57 +94,44 @@ class ManageFavoritesFragment : PreferenceFragmentCompat() {
val context = context ?: return val context = context ?: return
findPreference<Preference>("backup")?.setOnPreferenceClickListener { findPreference<Preference>("backup")?.setOnPreferenceClickListener {
val request = Request.Builder() val favorites = runCatching {
.url(context.getString(R.string.backup_url)) Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites.json").readText())
.post( }.getOrNull()
FormBody.Builder() val favoriteTags = kotlin.runCatching {
.add("f:1", File(ContextCompat.getDataDir(context), "favorites.json").readText()) Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites_tags.json").readText())
.build() }.getOrNull()
).build()
client.newCall(request).enqueue(object: Callback { val favoriteJson = buildJsonObject {
override fun onFailure(call: Call, e: IOException) { favorites?.let {
val view = view ?: return put("favorites", it)
Snackbar.make(view, R.string.settings_backup_failed, Snackbar.LENGTH_LONG).show() }
favoriteTags?.let {
put("favorite_tags", it)
}
} }
override fun onResponse(call: Call, response: Response) { val backupFile = File(context.filesDir, "pupil-backup.json").also {
if (response.code() != 200) { it.writeText(favoriteJson.toString())
response.close()
return
} }
Intent(Intent.ACTION_SEND).apply { Intent(Intent.ACTION_SEND).apply {
type = "text/plain" val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", backupFile)
putExtra(Intent.EXTRA_TEXT, response.body()?.use { it.string() }?.replace("\n", "")) setDataAndType(uri, "application/json")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
}.let { }.let {
getContext()?.startActivity(Intent.createChooser(it, getString(R.string.settings_backup_share))) context.startActivity(Intent.createChooser(it, getString(R.string.settings_backup_share)))
} }
}
})
true true
} }
findPreference<Preference>("restore")?.setOnPreferenceClickListener { findPreference<Preference>("restore")?.setOnPreferenceClickListener {
val editText = EditText(context).apply { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
setText(context.getString(R.string.backup_url), TextView.BufferType.EDITABLE) addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
} }
AlertDialog.Builder(context) requestBackupFileLauncher.launch(intent)
.setTitle(R.string.settings_restore_title)
.setView(editText)
.setPositiveButton(android.R.string.ok) { _, _ ->
restore(editText.text.toString(),
onFailure = onFailure@{
val view = view ?: return@onFailure
Snackbar.make(view, R.string.settings_restore_failed, Snackbar.LENGTH_LONG).show()
}, onSuccess = onSuccess@{
val view = view ?: return@onSuccess
Snackbar.make(view, context.getString(R.string.settings_restore_success, it.size), Snackbar.LENGTH_LONG).show()
})
}.setNegativeButton(android.R.string.cancel) { _, _ ->
// Do Nothing
}.show()
true true
} }

View File

@@ -18,21 +18,38 @@
package xyz.quaver.pupil.ui.fragment package xyz.quaver.pupil.ui.fragment
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.SAFileX
import xyz.quaver.io.util.deleteRecursively import xyz.quaver.io.util.deleteRecursively
import xyz.quaver.io.util.getChild
import xyz.quaver.io.util.readText
import xyz.quaver.io.util.writeText
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.histories import xyz.quaver.pupil.histories
import xyz.quaver.pupil.hitomi.json
import xyz.quaver.pupil.util.byteToString import xyz.quaver.pupil.util.byteToString
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.downloader.Metadata
import java.io.File import java.io.File
import kotlin.math.roundToInt
class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener { class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener {
@@ -44,12 +61,10 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
initPreferences() initPreferences()
} }
override fun onPreferenceClick(preference: Preference?): Boolean { override fun onPreferenceClick(preference: Preference): Boolean {
val context = context ?: return false val context = context ?: return false
with(preference) { with(preference) {
this ?: return false
when (key) { when (key) {
"delete_cache" -> { "delete_cache" -> {
val dir = File(context.cacheDir, "imageCache") val dir = File(context.cacheDir, "imageCache")
@@ -61,6 +76,8 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
if (dir.exists()) if (dir.exists())
dir.deleteRecursively() dir.deleteRecursively()
Cache.instances.clear()
summary = context.getString(R.string.settings_storage_usage, byteToString(0)) summary = context.getString(R.string.settings_storage_usage, byteToString(0))
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
var size = 0L var size = 0L
@@ -77,6 +94,48 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
setNegativeButton(android.R.string.cancel) { _, _ -> } setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show() }.show()
} }
"recover_downloads" -> {
val density = context.resources.displayMetrics.density
this.icon = object: CircularProgressDrawable(context) {
override fun getIntrinsicHeight() = (24*density).roundToInt()
override fun getIntrinsicWidth() = (24*density).roundToInt()
}.apply {
setStyle(CircularProgressDrawable.DEFAULT)
colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.colorAccent), PorterDuff.Mode.SRC_IN)
start()
}
val downloadManager = DownloadManager.getInstance(context)
val downloadFolderMap = downloadManager.downloadFolderMap
downloadFolderMap.clear()
downloadManager.downloadFolder.listFiles { file -> file.isDirectory }?.forEach { folder ->
val metadataFile = FileX(context, folder, ".metadata")
if (!metadataFile.exists()) return@forEach
val metadata = metadataFile.readText()?.let {
runCatching {
json.decodeFromString<Metadata>(it)
}.getOrNull()
} ?: return@forEach
val galleryID = metadata.galleryBlock?.id ?: metadata.galleryInfo?.id?.toIntOrNull() ?: return@forEach
downloadFolderMap[galleryID] = folder.name
}
downloadManager.downloadFolderMap.putAll(downloadFolderMap)
val downloads = FileX(context, downloadManager.downloadFolder, ".download")
if (!downloads.exists()) downloads.createNewFile()
downloads.writeText(Json.encodeToString(downloadFolderMap))
this.icon = null
Toast.makeText(context, android.R.string.ok, Toast.LENGTH_SHORT).show()
}
"delete_downloads" -> { "delete_downloads" -> {
val dir = DownloadManager.getInstance(context).downloadFolder val dir = DownloadManager.getInstance(context).downloadFolder
@@ -91,7 +150,12 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
} }
if (dir.exists()) if (dir.exists())
dir.listFiles()?.forEach { (it as? FileX)?.deleteRecursively() } dir.listFiles()?.forEach {
when (it) {
is FileX -> it.deleteRecursively()
else -> it.deleteRecursively()
}
}
job = launch { job = launch {
var size = 0L var size = 0L
@@ -183,6 +247,12 @@ class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenc
onPreferenceClickListener = this@ManageStorageFragment onPreferenceClickListener = this@ManageStorageFragment
} }
with(findPreference<Preference>("recover_downloads")) {
this ?: return@with
onPreferenceClickListener = this@ManageStorageFragment
}
} }
override fun onDestroy() { override fun onDestroy() {

View File

@@ -24,30 +24,34 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.andrognito.pinlockview.PinLockListener import com.andrognito.pinlockview.PinLockListener
import kotlinx.android.synthetic.main.fragment_pin_lock.view.* import xyz.quaver.pupil.databinding.PinLockFragmentBinding
import xyz.quaver.pupil.R
class PINLockFragment : Fragment(), PinLockListener { class PINLockFragment : Fragment() {
private var _binding: PinLockFragmentBinding? = null
val binding get() = _binding!!
var onPINEntered: ((String) -> Unit)? = null var onPINEntered: ((String) -> Unit)? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.fragment_pin_lock, container, false).apply { _binding = PinLockFragmentBinding.inflate(inflater, container, false)
pin_lock_view.attachIndicatorDots(indicator_dots)
pin_lock_view.setPinLockListener(this@PINLockFragment) binding.pinLockView.attachIndicatorDots(binding.indicatorDots)
} binding.pinLockView.setPinLockListener(object: PinLockListener {
override fun onComplete(p0: String?) {
onPINEntered?.invoke(p0 ?: "")
} }
override fun onComplete(pin: String?) { override fun onEmpty() {}
onPINEntered?.invoke(pin!!) override fun onPinChange(p0: Int, p1: String?) {}
})
return binding.root
} }
override fun onEmpty() { override fun onDestroy() {
super.onDestroy()
} _binding = null
override fun onPinChange(pinLength: Int, intermediatePin: String?) {
} }
} }

View File

@@ -26,38 +26,36 @@ import androidx.fragment.app.Fragment
import com.andrognito.patternlockview.PatternLockView import com.andrognito.patternlockview.PatternLockView
import com.andrognito.patternlockview.listener.PatternLockViewListener import com.andrognito.patternlockview.listener.PatternLockViewListener
import com.andrognito.patternlockview.utils.PatternLockUtils import com.andrognito.patternlockview.utils.PatternLockUtils
import kotlinx.android.synthetic.main.fragment_pattern_lock.* import xyz.quaver.pupil.databinding.PatternLockFragmentBinding
import kotlinx.android.synthetic.main.fragment_pattern_lock.view.*
import xyz.quaver.pupil.R
class PatternLockFragment : Fragment(), PatternLockViewListener { class PatternLockFragment : Fragment() {
private var _binding: PatternLockFragmentBinding? = null
val binding get() = _binding!!
var onPatternDrawn: ((String) -> Unit)? = null var onPatternDrawn: ((String) -> Unit)? = null
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View {
return inflater.inflate(R.layout.fragment_pattern_lock, container, false).apply { _binding = PatternLockFragmentBinding.inflate(inflater, container, false)
lock_pattern_view.addPatternLockListener(this@PatternLockFragment) binding.patternLockView.addPatternLockListener(object: PatternLockViewListener {
}
}
override fun onCleared() {
}
override fun onComplete(pattern: MutableList<PatternLockView.Dot>?) { override fun onComplete(pattern: MutableList<PatternLockView.Dot>?) {
val password = PatternLockUtils.patternToMD5(lock_pattern_view, pattern) val password = PatternLockUtils.patternToMD5(binding.patternLockView, pattern)
onPatternDrawn?.invoke(password) onPatternDrawn?.invoke(password)
} }
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) { override fun onCleared() {}
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) {}
override fun onStarted() {}
})
return binding.root
} }
override fun onStarted() { override fun onDestroy() {
super.onDestroy()
_binding = null
} }
} }

View File

@@ -21,26 +21,33 @@ package xyz.quaver.pupil.ui.fragment
import android.app.Activity import android.app.Activity
import android.content.* import android.content.*
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.Preference import androidx.preference.*
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import com.google.android.material.snackbar.Snackbar import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.serialization.decodeFromString import com.google.firebase.crashlytics.internal.model.CrashlyticsReport
import kotlinx.serialization.json.Json import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.Dispatcher
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.getChild import xyz.quaver.io.util.getChild
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.favorites import xyz.quaver.pupil.client
import xyz.quaver.pupil.clientBuilder
import xyz.quaver.pupil.clientHolder
import xyz.quaver.pupil.types.SendLogException
import xyz.quaver.pupil.ui.LockActivity import xyz.quaver.pupil.ui.LockActivity
import xyz.quaver.pupil.ui.SettingsActivity import xyz.quaver.pupil.ui.SettingsActivity
import xyz.quaver.pupil.ui.TransferActivity
import xyz.quaver.pupil.ui.dialog.* import xyz.quaver.pupil.ui.dialog.*
import xyz.quaver.pupil.util.* import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.DownloadManager import xyz.quaver.pupil.util.downloader.DownloadManager
import java.nio.charset.Charset import java.util.*
import java.util.concurrent.Executors
class SettingsFragment : class SettingsFragment :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
@@ -48,6 +55,16 @@ class SettingsFragment :
Preference.OnPreferenceChangeListener, Preference.OnPreferenceChangeListener,
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener {
private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
parentFragmentManager
.beginTransaction()
.replace(R.id.settings, LockSettingsFragment())
.addToBackStack("Lock")
.commitAllowingStateLoss()
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@@ -66,10 +83,8 @@ class SettingsFragment :
} }
} }
override fun onPreferenceClick(preference: Preference?): Boolean { override fun onPreferenceClick(preference: Preference): Boolean {
with (preference) { with (preference) {
this ?: return false
when (key) { when (key) {
"app_version" -> { "app_version" -> {
checkUpdate(activity as SettingsActivity, true) checkUpdate(activity as SettingsActivity, true)
@@ -78,32 +93,31 @@ class SettingsFragment :
DownloadLocationDialogFragment().show(parentFragmentManager, "Download Location Dialog") DownloadLocationDialogFragment().show(parentFragmentManager, "Download Location Dialog")
} }
"default_query" -> { "default_query" -> {
DefaultQueryDialog(requireContext()).apply { DefaultQueryDialog().apply {
onPositiveButtonClickListener = { newTags -> onPositiveButtonClickListener = { newTags ->
Preferences["default_query"] = newTags.toString() Preferences["default_query"] = newTags.toString()
summary = newTags.toString() summary = newTags.toString()
} }
}.show() }.show(parentFragmentManager, "Default Query Dialog")
} }
"app_lock" -> { "app_lock" -> {
val intent = Intent(requireContext(), LockActivity::class.java).apply { val intent = Intent(requireContext(), LockActivity::class.java).apply {
putExtra("force", true) putExtra("force", true)
} }
startActivityForResult(intent, R.id.request_lock.normalizeID()) lockLauncher.launch(intent)
}
"mirrors" -> {
MirrorDialog(requireContext())
.show()
} }
"proxy" -> { "proxy" -> {
ProxyDialog(requireContext()) ProxyDialogFragment().show(parentFragmentManager, "Proxy Dialog")
.show()
} }
"user_id" -> { "user_id" -> {
FirebaseCrashlytics.getInstance().recordException(SendLogException())
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip( (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText("user_id", Preferences.get<String>("user_id")) ClipData.newPlainText("user_id", Preferences.get<String>("user_id"))
) )
Toast.makeText(context, R.string.settings_user_id_toast, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
"transfer_data" -> {
activity?.startActivity(Intent(activity, TransferActivity::class.java))
} }
else -> return false else -> return false
} }
@@ -112,11 +126,12 @@ class SettingsFragment :
return true return true
} }
override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean { override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean {
with (preference) { with (preference) {
this ?: return false
when (key) { when (key) {
"tag_translation" -> {
updateTranslations()
}
"nomedia" -> { "nomedia" -> {
val create = (newValue as? Boolean) ?: return false val create = (newValue as? Boolean) ?: return false
@@ -150,7 +165,7 @@ class SettingsFragment :
when (key) { when (key) {
"proxy" -> { "proxy" -> {
summary = context?.let { getProxyInfo().type.name } summary = context.let { getProxyInfo().type.name }
} }
"download_folder" -> { "download_folder" -> {
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
@@ -158,6 +173,18 @@ class SettingsFragment :
"download_folder_name" -> { "download_folder_name" -> {
summary = Preferences["download_folder_name", "[-id-] -title-"] summary = Preferences["download_folder_name", "[-id-] -title-"]
} }
"max_concurrent_download" -> {
val newValue = Preferences.get<String>(key).toIntOrNull() ?: 0
if (newValue == 0)
clientBuilder.dispatcher(Dispatcher())
else
clientBuilder.dispatcher((Dispatcher(Executors.newFixedThreadPool(newValue))))
clientHolder = null
client
}
else -> return
} }
} }
} }
@@ -237,14 +264,32 @@ class SettingsFragment :
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
"mirrors" -> {
onPreferenceClickListener = this@SettingsFragment
}
"proxy" -> { "proxy" -> {
summary = getProxyInfo().type.name summary = getProxyInfo().type.name
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
"tag_translation" -> {
this as ListPreference
isEnabled = false
CoroutineScope(Dispatchers.IO).launch {
kotlin.runCatching {
val languages = getAvailableLanguages().distinct().toTypedArray()
entries = languages.map { Locale(it).let { loc -> loc.getDisplayLanguage(loc) } }.toTypedArray()
entryValues = languages
launch(Dispatchers.Main) {
isEnabled = true
}
}
}
onPreferenceChangeListener = this@SettingsFragment
}
"dark_mode" -> { "dark_mode" -> {
onPreferenceChangeListener = this@SettingsFragment onPreferenceChangeListener = this@SettingsFragment
} }
@@ -257,29 +302,17 @@ class SettingsFragment :
} }
"oss" -> { "oss" -> {
setOnPreferenceClickListener { setOnPreferenceClickListener {
context?.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) context.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
true true
} }
} }
} "transfer_data" -> {
onPreferenceClickListener = this@SettingsFragment
}
}
} }
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { }
when(requestCode) { }
R.id.request_lock.normalizeID() -> {
if (resultCode == Activity.RESULT_OK) {
parentFragmentManager
.beginTransaction()
.replace(R.id.settings, LockSettingsFragment())
.addToBackStack("Lock")
.commitAllowingStateLoss()
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
} }
} }
} }

View File

@@ -0,0 +1,10 @@
package xyz.quaver.pupil.ui.fragment
import androidx.fragment.app.Fragment
import xyz.quaver.pupil.R
class TransferConnectedFragment: Fragment(R.layout.transfer_connected_fragment) {
}

View File

@@ -0,0 +1,44 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.TransferDirectionFragmentBinding
import xyz.quaver.pupil.ui.TransferStep
import xyz.quaver.pupil.ui.TransferViewModel
class TransferDirectionFragment : Fragment(R.layout.transfer_direction_fragment) {
private var _binding: TransferDirectionFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = TransferDirectionFragmentBinding.inflate(inflater, container, false)
binding.inButton.setOnClickListener {
viewModel.setStep(TransferStep.TARGET)
}
binding.outButton.setOnClickListener {
viewModel.setStep(TransferStep.WAIT_FOR_CONNECTION)
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,38 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.databinding.TransferPermissionFragmentBinding
import xyz.quaver.pupil.ui.TransferStep
import xyz.quaver.pupil.ui.TransferViewModel
class TransferPermissionFragment: Fragment() {
private var _binding: TransferPermissionFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = TransferPermissionFragmentBinding.inflate(inflater, container, false)
binding.permissionsButton.setOnClickListener {
viewModel.setStep(TransferStep.TARGET_FORCE)
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,37 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.databinding.TransferSelectDataFragmentBinding
import xyz.quaver.pupil.ui.TransferViewModel
class TransferSelectDataFragment: Fragment() {
private var _binding: TransferSelectDataFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = TransferSelectDataFragmentBinding.inflate(inflater, container, false)
binding.checkAll.setOnCheckedChangeListener { _, isChecked ->
viewModel.list()
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,69 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.TransferPeersAdapter
import xyz.quaver.pupil.databinding.TransferTargetFragmentBinding
import xyz.quaver.pupil.ui.TransferStep
import xyz.quaver.pupil.ui.TransferViewModel
class TransferTargetFragment : Fragment() {
private var _binding: TransferTargetFragmentBinding? = null
private val binding get() = _binding!!
private val viewModel: TransferViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = TransferTargetFragmentBinding.inflate(inflater, container, false)
viewModel.thisDevice.observe(viewLifecycleOwner) { device ->
if (device == null) {
return@observe
}
if (device.status == 3) {
binding.ripple.startRippleAnimation()
binding.retryButton.visibility = View.INVISIBLE
} else {
binding.ripple.stopRippleAnimation()
binding.retryButton.visibility = View.VISIBLE
}
}
viewModel.peers.observe(viewLifecycleOwner) { peers ->
if (peers == null) {
return@observe
}
binding.deviceList.adapter = TransferPeersAdapter(peers.deviceList) {
viewModel.connect(it)
}
}
binding.ripple.startRippleAnimation()
binding.retryButton.setOnClickListener {
viewModel.setStep(TransferStep.TARGET)
}
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,32 @@
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import xyz.quaver.pupil.databinding.TransferWaitForConnectionFragmentBinding
class TransferWaitForConnectionFragment : Fragment() {
private var _binding: TransferWaitForConnectionFragmentBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = TransferWaitForConnectionFragmentBinding.inflate(layoutInflater)
binding.ripple.startRippleAnimation()
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -16,16 +16,16 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package com.arlib.floatingsearchview package xyz.quaver.pupil.ui.view
import android.content.Context import android.content.Context
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.os.Parcelable
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.util.AttributeSet import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
@@ -36,32 +36,32 @@ import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.arlib.floatingsearchview.suggestions.SearchSuggestionsAdapter import xyz.quaver.floatingsearchview.FloatingSearchView
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import com.arlib.floatingsearchview.util.view.SearchInputView import xyz.quaver.floatingsearchview.util.view.SearchInputView
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.favoriteTags import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.types.* import xyz.quaver.pupil.types.*
import java.util.* import java.util.*
class FloatingSearchViewDayNight @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : class FloatingSearchView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FloatingSearchView(context, attrs), FloatingSearchView(context, attrs),
FloatingSearchView.OnSearchListener, FloatingSearchView.OnSearchListener,
SearchSuggestionsAdapter.OnBindSuggestionCallback,
TextWatcher TextWatcher
{ {
private val searchInputView = findViewById<SearchInputView>(R.id.search_bar_text) private val searchInputView = findViewById<SearchInputView>(R.id.search_bar_text)
var onHistoryDeleteClickedListener: ((String) -> Unit)? = null var onHistoryDeleteClickedListener: ((String) -> Unit)? = null
var onFavoriteHistorySwitchClickListener: (() -> Unit)? = null var onFavoriteHistorySwitchClickListener: (() -> Unit)? = null
init { init {
searchInputView.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI searchInputView.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or searchInputView.imeOptions
searchInputView.addTextChangedListener(this) searchInputView.addTextChangedListener(this)
setOnSearchListener(this) onSearchListener = this
setOnBindSuggestionCallback(this) onBindSuggestionCallback = { binding, item, itemPosition ->
onBindSuggestion(binding.root, binding.leftIcon, binding.body, item, itemPosition)
}
} }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
@@ -76,21 +76,24 @@ class FloatingSearchViewDayNight @JvmOverloads constructor(context: Context, att
s ?: return s ?: return
if (s.any { it.isUpperCase() }) if (s.any { it.isUpperCase() })
s.replace(0, s.length, s.toString().toLowerCase(Locale.getDefault())) s.replace(0, s.length, s.toString().lowercase())
} }
override fun onSuggestionClicked(searchSuggestion: SearchSuggestion?) { override fun onSuggestionClicked(searchSuggestion: SearchSuggestion?) {
when (searchSuggestion) { when (searchSuggestion) {
is TagSuggestion -> { is TagSuggestion -> {
with(searchInputView.text) { val tag = "${searchSuggestion.n}:${searchSuggestion.s.replace(Regex("\\s"), "_")}"
with(searchInputView.text!!) {
delete(if (lastIndexOf(' ') == -1) 0 else lastIndexOf(' ') + 1, length) delete(if (lastIndexOf(' ') == -1) 0 else lastIndexOf(' ') + 1, length)
append("${searchSuggestion.n}:${searchSuggestion.s.replace(Regex("\\s"), "_")} ")
if (!this.contains(tag))
append("$tag ")
} }
} }
is Suggestion -> { is Suggestion -> {
with(searchInputView.text) { with(searchInputView.text!!) {
clear() clear()
append(searchSuggestion.str) append(searchSuggestion.body)
} }
} }
is FavoriteHistorySwitch -> onFavoriteHistorySwitchClickListener?.invoke() is FavoriteHistorySwitch -> onFavoriteHistorySwitchClickListener?.invoke()
@@ -99,7 +102,7 @@ class FloatingSearchViewDayNight @JvmOverloads constructor(context: Context, att
override fun onSearchAction(currentQuery: String?) {} override fun onSearchAction(currentQuery: String?) {}
override fun onBindSuggestion( fun onBindSuggestion(
suggestionView: View?, suggestionView: View?,
leftIcon: ImageView?, leftIcon: ImageView?,
textView: TextView?, textView: TextView?,
@@ -108,7 +111,7 @@ class FloatingSearchViewDayNight @JvmOverloads constructor(context: Context, att
) { ) {
when(item) { when(item) {
is TagSuggestion -> { is TagSuggestion -> {
val tag = "${item.n}:${item.s.replace(Regex("\\s"), "_")}" val tag = "${item.n}:${item.s}"
leftIcon?.setImageDrawable( leftIcon?.setImageDrawable(
ResourcesCompat.getDrawable( ResourcesCompat.getDrawable(
@@ -159,9 +162,7 @@ class FloatingSearchViewDayNight @JvmOverloads constructor(context: Context, att
} }
} }
if (item.t == -1) { if (item.t > 0) {
textView?.text = item.s
} else {
(suggestionView as? LinearLayout)?.let { (suggestionView as? LinearLayout)?.let {
val count = it.findViewById<TextView>(R.id.count) val count = it.findViewById<TextView>(R.id.count)
if (count == null) if (count == null)
@@ -196,7 +197,7 @@ class FloatingSearchViewDayNight @JvmOverloads constructor(context: Context, att
isClickable = true isClickable = true
setOnClickListener { setOnClickListener {
onHistoryDeleteClickedListener?.invoke(item.str) onHistoryDeleteClickedListener?.invoke(item.body)
} }
} }
} }
@@ -212,10 +213,4 @@ class FloatingSearchViewDayNight @JvmOverloads constructor(context: Context, att
} }
} }
} }
// hack to remove color attributes which should not be reused
override fun onSaveInstanceState(): Parcelable? {
super.onSaveInstanceState()
return null
}
} }

View File

@@ -0,0 +1,462 @@
/*
* 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.ui.view;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.os.Vibrator;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.DecelerateInterpolator;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.core.view.NestedScrollingChild;
import androidx.core.view.NestedScrollingChildHelper;
import androidx.core.view.NestedScrollingParent;
import androidx.core.view.NestedScrollingParentHelper;
import androidx.core.view.ViewCompat;
import androidx.core.widget.TextViewCompat;
import xyz.quaver.pupil.R;
@SuppressWarnings("NullableProblems")
public class MainView extends ViewGroup implements NestedScrollingChild, NestedScrollingParent {
private static final int PAGE_TURN_LAYOUT_SIZE = 48;
private static final int PAGE_TURN_ANIM_DURATION = 500;
private static final int PREV_OFFSET = 64;
private static final int RIPPLE_GIVE = 4;
private final float adjustedPageTurnLayoutSize;
private final float adjustedPrevOffset;
private final float adjustedRippleGive;
final private NestedScrollingParentHelper mNestedScrollingParentHelper;
final private NestedScrollingChildHelper mNestedScrollingChildHelper;
final private Vibrator mVibrator;
private View mTarget;
private TextView mPrev;
private TextView mNext;
private final Paint mRipplePaint = new Paint();
private final Rect mRippleBound = new Rect();
private int mRippleSize = 0;
private final int mRippleTargetSize;
private final ValueAnimator mRippleAnimator = new ValueAnimator();
private int mCurrentOverScroll = 0;
private int mCurrentPage = 1;
private boolean mShowPrev;
private boolean mShowNext;
private OnPageTurnListener mOnPageTurnListener;
public MainView(@NonNull Context context) {
this(context, null);
}
public MainView(@NonNull Context context, AttributeSet attr) {
this(context, attr, 0);
}
public MainView(@NonNull Context context, AttributeSet attr, int defStyle) {
super(context, attr, defStyle);
setWillNotDraw(false);
DisplayMetrics metrics = getResources().getDisplayMetrics();
adjustedPageTurnLayoutSize = PAGE_TURN_LAYOUT_SIZE * metrics.density;
adjustedPrevOffset = PREV_OFFSET * metrics.density;
adjustedRippleGive = RIPPLE_GIVE * metrics.density;
mRippleTargetSize = metrics.widthPixels;
mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);
mNestedScrollingChildHelper = new NestedScrollingChildHelper(this);
mVibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
mRippleAnimator.addUpdateListener(animation -> {
mRippleSize = (int) animation.getAnimatedValue();
invalidate();
});
mRippleAnimator.setDuration(PAGE_TURN_ANIM_DURATION);
initPageTurnView();
}
public void setCurrentPage(int currentPage, boolean showNext) {
mCurrentPage = currentPage;
mShowPrev = currentPage > 1;
mShowNext = showNext;
mPrev.setText(getContext().getString(R.string.main_move_to_page, mCurrentPage-1));
mNext.setText(getContext().getString(R.string.main_move_to_page, mCurrentPage+1));
}
public void setOnPageTurnListener(OnPageTurnListener listener) {
mOnPageTurnListener = listener;
}
private void initPageTurnView() {
TextView prev = new TextView(getContext());
TextView next = new TextView(getContext());
prev.setGravity(Gravity.CENTER_VERTICAL);
next.setGravity(Gravity.CENTER_VERTICAL);
prev.setCompoundDrawablesWithIntrinsicBounds(R.drawable.navigate_prev, 0, 0, 0);
next.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.navigate_next, 0);
TextViewCompat.setCompoundDrawableTintList(prev, AppCompatResources.getColorStateList(getContext(), R.color.colorAccent));
TextViewCompat.setCompoundDrawableTintList(next, AppCompatResources.getColorStateList(getContext(), R.color.colorAccent));
prev.setVisibility(View.INVISIBLE);
next.setVisibility(View.INVISIBLE);
mPrev = prev;
mNext = next;
addView(mPrev);
addView(mNext);
setCurrentPage(1, false);
}
private void ensureTarget() {
if (mTarget == null) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (!child.equals(mNext) && !child.equals(mPrev)) {
mTarget = child;
break;
}
}
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int width = getMeasuredWidth();
final int height = getMeasuredHeight();
if (getChildCount() == 0)
return;
if (mTarget == null)
ensureTarget();
if (mTarget == null)
return;
mTarget.layout(
getPaddingLeft(),
getPaddingTop(),
width - getPaddingRight(),
height - getPaddingBottom()
);
final int prevWidth = mPrev.getMeasuredWidth();
mPrev.layout(
width / 2 - prevWidth / 2,
getPaddingTop() + (int) adjustedPrevOffset,
width / 2 + prevWidth / 2,
getPaddingTop() + (int) adjustedPrevOffset + mPrev.getMeasuredHeight()
);
final int nextWidth = mNext.getMeasuredWidth();
mNext.layout(
width / 2 - nextWidth / 2,
height - getPaddingBottom() - mNext.getMeasuredHeight(),
width / 2 + nextWidth / 2,
height - getPaddingBottom()
);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mTarget == null)
ensureTarget();
if (mTarget == null)
return;
mTarget.measure(
MeasureSpec.makeMeasureSpec(getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)
);
mPrev.measure(
MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec((int) adjustedPageTurnLayoutSize, MeasureSpec.EXACTLY)
);
mNext.measure(
MeasureSpec.makeMeasureSpec(getMeasuredWidth(), MeasureSpec.AT_MOST),
MeasureSpec.makeMeasureSpec((int) adjustedPageTurnLayoutSize, MeasureSpec.EXACTLY)
);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mCurrentOverScroll == 0)
return;
if (mCurrentOverScroll > 0) {
mRippleBound.set(
getPaddingLeft(),
(int) (getPaddingTop() - adjustedRippleGive),
getMeasuredWidth() - getPaddingRight(),
(int) (getPaddingTop() + adjustedPrevOffset + mPrev.getMeasuredHeight() + adjustedRippleGive)
);
}
if (mCurrentOverScroll < 0) {
final int height = getMeasuredHeight();
mRippleBound.set(
getPaddingLeft(),
(int) (height - getPaddingBottom() - mNext.getMeasuredHeight() - adjustedRippleGive),
getMeasuredWidth() - getPaddingRight(),
height - getPaddingBottom()
);
}
mRipplePaint.reset();
mRipplePaint.setStyle(Paint.Style.FILL);
int currentNightMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
switch (currentNightMode) {
case Configuration.UI_MODE_NIGHT_YES:
mRipplePaint.setColor(ContextCompat.getColor(getContext(), R.color.material_light_blue_700));
break;
case Configuration.UI_MODE_NIGHT_NO:
mRipplePaint.setColor(ContextCompat.getColor(getContext(), R.color.material_light_blue_300));
break;
}
canvas.drawCircle(
(mRippleBound.left + mRippleBound.right) / 2F,
mCurrentOverScroll > 0 ? mRippleBound.bottom : mRippleBound.top,
mRippleSize,
mRipplePaint
);
}
private void onOverscroll(int overscroll) {
if (mTarget == null)
ensureTarget();
if (mTarget == null)
return;
mCurrentOverScroll = overscroll;
if (overscroll > 0) {
mPrev.setVisibility(View.VISIBLE);
mNext.setVisibility(View.INVISIBLE);
} else if (overscroll < 0) {
mPrev.setVisibility(View.INVISIBLE);
mNext.setVisibility(View.VISIBLE);
} else {
mPrev.setVisibility(View.INVISIBLE);
mNext.setVisibility(View.INVISIBLE);
}
if (Math.abs(overscroll) >= adjustedPageTurnLayoutSize) {
if (!mRippleAnimator.isStarted() && mRippleSize != mRippleTargetSize) {
mVibrator.vibrate(10);
mRippleAnimator.setIntValues(mRippleSize, mRippleTargetSize);
mRippleAnimator.start();
}
} else {
if (!mRippleAnimator.isStarted() && mRippleSize != 0) {
mRippleAnimator.setIntValues(mRippleSize, 0);
mRippleAnimator.start();
}
}
float clippedOverScrollTop = (overscroll > 0 ? 1 : -1) * Math.min(Math.abs(overscroll), adjustedPageTurnLayoutSize);
mTarget.setTranslationY(clippedOverScrollTop);
}
private void onOverscrollEnd(int overscroll) {
if (mTarget == null)
ensureTarget();
if (mTarget == null)
return;
mRippleAnimator.cancel();
mRippleAnimator.setIntValues(mRippleSize, 0);
mRippleAnimator.start();
mPrev.setVisibility(View.INVISIBLE);
mNext.setVisibility(View.INVISIBLE);
ViewCompat.animate(mTarget)
.setDuration(PAGE_TURN_ANIM_DURATION)
.setInterpolator(new DecelerateInterpolator())
.translationY(0);
if (Math.abs(overscroll) > adjustedPageTurnLayoutSize && mOnPageTurnListener != null) {
if (overscroll > 0)
mOnPageTurnListener.onPrev(mCurrentPage-1);
if (overscroll < 0)
mOnPageTurnListener.onNext(mCurrentPage+1);
}
}
// NestedScrollingParent
private int mTotalUnconsumed = 0;
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return isEnabled() && (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
@Override
public void onNestedScrollAccepted(View child, View target, int axes) {
mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);
startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL);
mTotalUnconsumed = 0;
}
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
if (mTotalUnconsumed != 0 && dy > 0 == mTotalUnconsumed > 0) {
if (Math.abs(dy) > Math.abs(mTotalUnconsumed)) {
consumed[1] = dy - mTotalUnconsumed;
mTotalUnconsumed = 0;
} else {
mTotalUnconsumed -= dy;
consumed[1] = dy;
}
onOverscroll(mTotalUnconsumed);
}
final int[] parentConsumed = new int[2];
if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
consumed[0] += parentConsumed[0];
consumed[1] += parentConsumed[1];
}
}
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
final int[] mParentOffsetInWindow = new int[2];
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, mParentOffsetInWindow);
final int dy = dyUnconsumed + mParentOffsetInWindow[1];
if (mTotalUnconsumed == 0 && ((dy < 0 && !mShowPrev) || (dy > 0 && !mShowNext)))
return;
if (dy != 0) {
mTotalUnconsumed -= dy;
onOverscroll(mTotalUnconsumed);
}
}
@Override
public void onStopNestedScroll(View child) {
mNestedScrollingParentHelper.onStopNestedScroll(child);
if (Math.abs(mTotalUnconsumed) > 0) {
onOverscrollEnd(mTotalUnconsumed);
mTotalUnconsumed = 0;
}
stopNestedScroll();
}
// NestedScrollingChild
@Override
public void setNestedScrollingEnabled(boolean enabled) {
mNestedScrollingChildHelper.setNestedScrollingEnabled(enabled);
}
@Override
public boolean isNestedScrollingEnabled() {
return mNestedScrollingChildHelper.isNestedScrollingEnabled();
}
@Override
public boolean startNestedScroll(int axes) {
return mNestedScrollingChildHelper.startNestedScroll(axes);
}
@Override
public void stopNestedScroll() {
mNestedScrollingChildHelper.stopNestedScroll();
}
@Override
public boolean hasNestedScrollingParent() {
return mNestedScrollingChildHelper.hasNestedScrollingParent();
}
@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {
return mNestedScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow);
}
@Override
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow) {
return mNestedScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
}
@Override
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
return mNestedScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
}
@Override
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
return mNestedScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY);
}
public interface OnPageTurnListener {
void onPrev(int page);
void onNext(int page);
}
}

View File

@@ -0,0 +1,72 @@
package xyz.quaver.pupil.ui.view
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.ProgressCardViewBinding
class ProgressCard @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, defStyle: Int = R.attr.cardViewStyle) : CardView(context, attr, defStyle) {
enum class Type {
LOADING,
CACHE,
DOWNLOAD
}
var type: Type = Type.LOADING
set(value) {
field = value
when (field) {
Type.LOADING -> R.color.colorAccent
Type.CACHE -> R.color.material_blue_700
Type.DOWNLOAD -> R.color.material_green_a700
}.let {
val color = ContextCompat.getColor(context, it)
DrawableCompat.setTint(binding.progressbar.progressDrawable, color)
}
}
var progress: Int
get() = binding.progressbar.progress
set(value) {
binding.progressbar.progress = value
}
var max: Int
get() = binding.progressbar.max
set(value) {
binding.progressbar.max = value
binding.progressbar.visibility =
if (value == 0)
GONE
else
VISIBLE
}
val binding = ProgressCardViewBinding.inflate(LayoutInflater.from(context), this)
init {
binding.content.setOnClickListener {
performClick()
}
binding.content.setOnLongClickListener {
performLongClick()
}
}
override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
if (childCount == 0)
super.addView(child, index, params)
else
binding.content.addView(child, index, params)
}
}

View File

@@ -23,17 +23,19 @@ import android.content.Context
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.types.Tag import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.translations
import xyz.quaver.pupil.util.wordCapitalize import xyz.quaver.pupil.util.wordCapitalize
@SuppressLint("ViewConstructor") @SuppressLint("ViewConstructor")
class TagChip(context: Context, tag: Tag) : Chip(context) { class TagChip(context: Context, _tag: Tag) : Chip(context) {
val tag: Tag = val tag: Tag =
tag.let { _tag.let {
when { when {
it.area != null -> it it.area != null -> it
else -> Tag("tag", tag.tag) else -> Tag("tag", _tag.tag)
} }
} }
@@ -44,23 +46,52 @@ class TagChip(context: Context, tag: Tag) : Chip(context) {
}.toMap() }.toMap()
init { init {
chipIcon = when(tag.area) { when(tag.area) {
"male" -> { "male" -> {
setChipBackgroundColorResource(R.color.material_blue_700) setChipBackgroundColorResource(R.color.material_blue_700)
setTextColor(ContextCompat.getColor(context, android.R.color.white)) setTextColor(ContextCompat.getColor(context, android.R.color.white))
ContextCompat.getDrawable(context, R.drawable.gender_male_white) setCloseIconTintResource(android.R.color.white)
chipIcon = ContextCompat.getDrawable(context, R.drawable.gender_male_white)
} }
"female" -> { "female" -> {
setChipBackgroundColorResource(R.color.material_pink_600) setChipBackgroundColorResource(R.color.material_pink_600)
setTextColor(ContextCompat.getColor(context, android.R.color.white)) setTextColor(ContextCompat.getColor(context, android.R.color.white))
ContextCompat.getDrawable(context, R.drawable.gender_female_white) setCloseIconTintResource(android.R.color.white)
chipIcon = ContextCompat.getDrawable(context, R.drawable.gender_female_white)
}
}
if (favoriteTags.contains(tag))
setChipBackgroundColorResource(R.color.material_orange_500)
isCloseIconVisible = true
closeIcon = ContextCompat.getDrawable(context,
if (favoriteTags.contains(tag))
R.drawable.ic_star_filled
else
R.drawable.ic_star_empty
)
setOnCloseIconClickListener {
if (favoriteTags.contains(tag)) {
favoriteTags.remove(tag)
closeIcon = ContextCompat.getDrawable(context, R.drawable.ic_star_empty)
when(tag.area) {
"male" -> setChipBackgroundColorResource(R.color.material_blue_700)
"female" -> setChipBackgroundColorResource(R.color.material_pink_600)
else -> chipBackgroundColor = null
}
} else {
favoriteTags.add(tag)
closeIcon = ContextCompat.getDrawable(context, R.drawable.ic_star_filled)
setChipBackgroundColorResource(R.color.material_orange_500)
} }
else -> null
} }
text = when (tag.area) { text = when (tag.area) {
"language" -> languages[tag.tag] "language" -> languages[tag.tag]
else -> tag.tag.wordCapitalize() else -> (translations[tag.tag] ?: tag.tag).wordCapitalize()
} }
setEnsureMinTouchTargetSize(false) setEnsureMinTouchTargetSize(false)

View File

@@ -0,0 +1,100 @@
/*
* 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.ui.view
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.util.Log
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import kotlinx.coroutines.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.types.Tags
class TagChipGroup @JvmOverloads constructor(context: Context, attr: AttributeSet? = null, attrStyle: Int = R.attr.chipGroupStyle, val tags: Tags = Tags()) : ChipGroup(context, attr, attrStyle), MutableSet<Tag> by tags {
object Defaults {
const val maxChipSize = 10
}
var maxChipSize: Int = Defaults.maxChipSize
set(value) {
field = value
refresh()
}
private val moreView = Chip(context).apply {
text = ""
setEnsureMinTouchTargetSize(false)
setOnClickListener {
removeView(this)
for (i in maxChipSize until tags.size) {
val tag = tags.elementAt(i)
addView(TagChip(context, tag).apply {
setOnClickListener {
onClickListener?.invoke(tag)
}
})
}
}
}
var onClickListener: ((Tag) -> Unit)? = null
private fun applyAttributes(attr: TypedArray) {
maxChipSize = attr.getInt(R.styleable.TagChipGroup_maxTag, Defaults.maxChipSize)
}
private var refreshJob: Job? = null
fun refresh() {
refreshJob?.cancel()
this.removeAllViews()
refreshJob = CoroutineScope(Dispatchers.Main).launch {
tags.take(maxChipSize).map {
CoroutineScope(Dispatchers.Default).async {
TagChip(context, it).apply {
setOnClickListener {
onClickListener?.invoke(this.tag)
}
}
}
}.forEach {
addView(it.await())
}
if (maxChipSize > 0 && tags.size > maxChipSize)
addView(moreView)
}
}
init {
applyAttributes(context.obtainStyledAttributes(attr, R.styleable.TagChipGroup))
refresh()
}
}

View File

@@ -18,9 +18,12 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import kotlinx.serialization.* import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
import java.io.File import java.io.File
class SavedSet <T: Any> (private val file: File, private val any: T, private val set: MutableSet<T> = mutableSetOf()) : MutableSet<T> by set { class SavedSet <T: Any> (private val file: File, private val any: T, private val set: MutableSet<T> = mutableSetOf()) : MutableSet<T> by set {
@@ -38,48 +41,50 @@ class SavedSet <T: Any> (private val file: File, private val any: T, private val
load() load()
} }
@Synchronized
fun load() { fun load() {
synchronized(this) {
set.clear() set.clear()
kotlin.runCatching { kotlin.runCatching {
Json.decodeFromString(serializer, file.readText()) Json.decodeFromString(serializer, file.readText())
}.onSuccess { }.onSuccess {
set.addAll(it) set.addAll(it)
} }.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
} }
} }
@Synchronized
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
fun save() { fun save() {
synchronized(this) {
file.writeText(Json.encodeToString(serializer, set.toList())) file.writeText(Json.encodeToString(serializer, set.toList()))
} }
}
@Synchronized
override fun add(element: T): Boolean { override fun add(element: T): Boolean {
load() set.remove(element)
return set.add(element).also { return set.add(element).also {
save() save()
} }
} }
@Synchronized
override fun addAll(elements: Collection<T>): Boolean { override fun addAll(elements: Collection<T>): Boolean {
load() set.removeAll(elements)
return set.addAll(elements).also { return set.addAll(elements).also {
save() save()
} }
} }
@Synchronized
override fun remove(element: T): Boolean { override fun remove(element: T): Boolean {
load()
return set.remove(element).also { return set.remove(element).also {
save() save()
} }
} }
@Synchronized
override fun clear() { override fun clear() {
set.clear() set.clear()
save() save()

View File

@@ -1,37 +0,0 @@
package xyz.quaver.pupil.util
import android.graphics.Paint
import android.text.style.LineHeightSpan
class SetLineOverlap(private val overlap: Boolean) : LineHeightSpan {
companion object {
private var originalBottom = 15
private var originalDescent = 13
private var overlapSaved = false
}
override fun chooseHeight(
text: CharSequence?,
start: Int,
end: Int,
spanstartv: Int,
lineHeight: Int,
fm: Paint.FontMetricsInt?
) {
fm ?: return
if (overlap) {
if (overlapSaved) {
originalBottom = fm.bottom
originalDescent = fm.descent
overlapSaved = true
}
fm.bottom += fm.top
fm.descent += fm.top
} else {
fm.bottom = originalBottom
fm.descent = originalDescent
overlapSaved = false
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* 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/>.
*/
@file:Suppress("DEPRECATION", "Recycle")
package xyz.quaver.pupil.util
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.ImageFormat
import android.graphics.SurfaceTexture
import android.hardware.Camera
import android.view.Surface
import android.view.WindowManager
import com.google.android.gms.tasks.Task
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.Face
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
/** Check if this device has a camera */
private fun Context.checkCameraHardware() =
this.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
private fun openFrontCamera() : Pair<Camera?, Int> {
var camera: Camera? = null
var cameraID: Int = -1
val cameraInfo = Camera.CameraInfo()
for (i in 0 until Camera.getNumberOfCameras()) {
Camera.getCameraInfo(i, cameraInfo)
if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT)
runCatching { Camera.open(i) }.getOrNull()?.let { camera = it; cameraID = i }
if (camera != null) break
}
return Pair(camera, cameraID)
}
val orientations = mapOf(
Surface.ROTATION_0 to 0,
Surface.ROTATION_90 to 90,
Surface.ROTATION_180 to 180,
Surface.ROTATION_270 to 270,
)
private fun getRotation(context: Context, cameraID: Int): Int {
val cameraRotation = Camera.CameraInfo().also { Camera.getCameraInfo(cameraID, it) }.orientation
val rotation = orientations[(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.rotation] ?: error("")
return (cameraRotation + rotation) % 360
}
var camera: Camera? = null
var surfaceTexture: SurfaceTexture? = null
private val detector = FaceDetection.getClient(
FaceDetectorOptions.Builder()
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
.build()
)
private var process: Task<List<Face>>? = null
fun startCamera(context: Context, callback: (List<Face>) -> Unit) {
if (camera != null) closeCamera()
val cameraID = openFrontCamera().let { (cam, cameraID) ->
cam ?: return
camera = cam
cameraID
}
with (camera!!) {
parameters = parameters.apply {
setPreviewSize(640, 480)
previewFormat = ImageFormat.NV21
}
setPreviewTexture(surfaceTexture ?: SurfaceTexture(0).also {
surfaceTexture = it
})
startPreview()
setPreviewCallback { bytes, _ ->
if (process?.isComplete == false)
return@setPreviewCallback
val rotation = getRotation(context, cameraID)
val image = InputImage.fromByteArray(bytes, 640, 480, rotation, InputImage.IMAGE_FORMAT_NV21)
process = detector.process(image)
.addOnSuccessListener(callback)
}
}
}
fun closeCamera() {
camera?.setPreviewCallback(null)
camera?.stopPreview()
surfaceTexture?.release()
surfaceTexture = null
camera?.release()
camera = null
}

View File

@@ -1,297 +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.util.download
import android.content.Context
import android.content.ContextWrapper
import android.util.Base64
import android.util.SparseArray
import androidx.preference.PreferenceManager
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.quaver.Code
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.Reader
import xyz.quaver.pupil.util.getCachedGallery
import xyz.quaver.pupil.util.getDownloadDirectory
import xyz.quaver.pupil.util.isParentOf
import xyz.quaver.readBytes
import java.io.BufferedInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.net.URL
@Suppress("DEPRECATION")
@Deprecated("Use downloader.Cache instead")
class Cache(context: Context) : ContextWrapper(context) {
companion object {
private val moving = mutableListOf<Int>()
private val readers = SparseArray<Reader?>()
}
private val preference = PreferenceManager.getDefaultSharedPreferences(this)
// Search in this order
// Download -> Cache
fun getCachedGallery(galleryID: Int) = getCachedGallery(this, galleryID).also {
if (!it.exists())
it.mkdirs()
}
fun getCachedMetadata(galleryID: Int) : Metadata? {
val file = File(getCachedGallery(galleryID), ".metadata")
if (!file.exists())
return null
return try {
Json.decodeFromString(file.readText())
} catch (e: Exception) {
//File corrupted
file.delete()
null
}
}
fun setCachedMetadata(galleryID: Int, metadata: Metadata) {
if (preference.getBoolean("cache_disable", false))
return
val file = File(getCachedGallery(galleryID), ".metadata").also {
if (!it.exists())
it.createNewFile()
}
file.writeText(Json.encodeToString(metadata))
}
suspend fun getThumbnail(galleryID: Int): String? {
val metadata = Cache(this).getCachedMetadata(galleryID)
@Suppress("BlockingMethodInNonBlockingContext")
val thumbnail = if (metadata?.thumbnail == null)
withContext(Dispatchers.IO) {
val thumbnail = getGalleryBlock(galleryID)?.thumbnails?.firstOrNull() ?: return@withContext null
try {
val data = URL(thumbnail).readBytes().apply {
if (isEmpty()) return@withContext null
}
Base64.encodeToString(data, Base64.DEFAULT)
} catch (e: Exception) {
null
}
}
else
metadata.thumbnail
setCachedMetadata(
galleryID,
Metadata(Cache(this).getCachedMetadata(galleryID), thumbnail = thumbnail)
)
return thumbnail
}
suspend fun getGalleryBlock(galleryID: Int): GalleryBlock? {
val metadata = Cache(this).getCachedMetadata(galleryID)
val sources = listOf(
{ xyz.quaver.hitomi.getGalleryBlock(galleryID) },
{ xyz.quaver.hiyobi.getGalleryBlock(galleryID) }
)
val galleryBlock = if (metadata?.galleryBlock == null) {
withContext(Dispatchers.IO) {
var galleryBlock: GalleryBlock? = null
for (source in sources) {
galleryBlock = try {
source.invoke()
} catch (e: Exception) {
null
}
if (galleryBlock != null)
break
}
galleryBlock
} ?: return null
}
else
metadata.galleryBlock
setCachedMetadata(
galleryID,
Metadata(Cache(this).getCachedMetadata(galleryID), galleryBlock = galleryBlock)
)
return galleryBlock
}
fun getReaderOrNull(galleryID: Int): Reader? {
return readers[galleryID] ?: getCachedMetadata(galleryID)?.reader
}
suspend fun getReader(galleryID: Int): Reader? {
val metadata = getCachedMetadata(galleryID)
val mirrors = preference.getString("mirrors", null)?.split('>') ?: listOf()
val sources = mapOf(
Code.HITOMI to { xyz.quaver.hitomi.getReader(galleryID) },
Code.HIYOBI to { xyz.quaver.hiyobi.getReader(galleryID) }
).let {
if (mirrors.isNotEmpty())
it.toSortedMap{ o1, o2 ->
mirrors.indexOf(o1.name) - mirrors.indexOf(o2.name)
}
else
it
}
val reader =
if (readers[galleryID] != null)
return readers[galleryID]
else if (metadata?.reader == null) {
var retval: Reader? = null
for (source in sources) {
retval = try {
withContext(Dispatchers.IO) {
withTimeoutOrNull(1000) {
source.value.invoke()
}
}
} catch (e: Exception) {
FirebaseCrashlytics.getInstance().recordException(e)
null
}
if (retval != null)
break
}
retval
} else
metadata.reader
readers.put(galleryID, reader)
setCachedMetadata(
galleryID,
Metadata(Cache(this).getCachedMetadata(galleryID), readers = reader)
)
return reader
}
val imageNameRegex = Regex("""^\d+\..+$""")
fun getImages(galleryID: Int): List<File?>? {
val gallery = getCachedGallery(galleryID)
return gallery.list { _, name ->
imageNameRegex.matches(name)
}?.map {
File(gallery, it)
}
}
val imageExtensions = listOf(
"png",
"jpg",
"webp",
"gif"
)
fun getImage(galleryID: Int, index: Int): File? {
val gallery = getCachedGallery(galleryID)
for (ext in imageExtensions) {
File(gallery, "%05d.$ext".format(index)).let {
if (it.exists())
return it
}
}
return null
}
fun putImage(galleryID: Int, index: Int, ext: String, data: InputStream) {
if (preference.getBoolean("cache_disable", false))
return
val cache = File(getCachedGallery(galleryID), "%05d.$ext".format(index)).also {
if (!it.exists())
it.createNewFile()
}
try {
BufferedInputStream(data).use { inputStream ->
FileOutputStream(cache).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
} catch (e: Exception) {
cache.delete()
}
}
fun moveToDownload(galleryID: Int) {
if (preference.getBoolean("cache_disable", false))
return
if (moving.contains(galleryID))
return
CoroutineScope(Dispatchers.IO).launch {
val cache = getCachedGallery(galleryID).also {
if (!it.exists())
return@launch
}
val download = File(getDownloadDirectory(this@Cache), galleryID.toString())
if (download.isParentOf(cache))
return@launch
FirebaseCrashlytics.getInstance().log("MOVING ${cache.canonicalPath} --> ${download.canonicalPath}")
cache.copyRecursively(download, true) { file, err ->
FirebaseCrashlytics.getInstance().log("MOVING ERROR ${file.canonicalPath} ${err.message}")
OnErrorAction.SKIP
}
FirebaseCrashlytics.getInstance().log("MOVED ${cache.canonicalPath}")
FirebaseCrashlytics.getInstance().log("DELETING ${cache.canonicalPath}")
cache.deleteRecursively()
FirebaseCrashlytics.getInstance().log("DELETED ${cache.canonicalPath}")
}
}
fun isDownloading(galleryID: Int) = getCachedMetadata(galleryID)?.isDownloading == true
fun setDownloading(galleryID: Int, isDownloading: Boolean) {
setCachedMetadata(galleryID, Metadata(getCachedMetadata(galleryID), isDownloading = isDownloading))
}
}

View File

@@ -1,389 +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.util.download
import android.app.PendingIntent
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.SharedPreferences
import android.util.Log
import android.util.SparseArray
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.preference.PreferenceManager
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.*
import okhttp3.*
import okio.*
import xyz.quaver.Code
import xyz.quaver.hitomi.Reader
import xyz.quaver.hitomi.getReferer
import xyz.quaver.hitomi.imageUrlFromImage
import xyz.quaver.hiyobi.createImgList
import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.interceptors
import xyz.quaver.pupil.ui.ReaderActivity
import java.io.File
import java.io.IOException
import java.util.concurrent.LinkedBlockingQueue
@Suppress("DEPRECATION")
@Deprecated("Use DownloadService instead")
@OptIn(ExperimentalCoroutinesApi::class)
class DownloadWorker private constructor(context: Context) : ContextWrapper(context) {
private val preferences : SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
//region ProgressListener
@Suppress("UNCHECKED_CAST")
private val progressListener = object: ProgressListener {
override fun update(tag: Any?, bytesRead: Long, contentLength: Long, done: Boolean) {
val (galleryID, index) = (tag as? Pair<Int, Int>) ?: return
if (!done && progress[galleryID]?.get(index)?.isFinite() == true)
progress[galleryID]?.set(index, bytesRead * 100F / contentLength)
}
}
interface ProgressListener {
fun update(tag: Any?, bytesRead : Long, contentLength: Long, done: Boolean)
}
class ProgressResponseBody(
val tag: Any?,
val responseBody: ResponseBody,
val progressListener : ProgressListener
) : ResponseBody() {
private var bufferedSource : BufferedSource? = null
override fun contentLength() = responseBody.contentLength()
override fun contentType() = responseBody.contentType()
override fun source(): BufferedSource {
if (bufferedSource == null)
bufferedSource = Okio.buffer(source(responseBody.source()))
return bufferedSource!!
}
private fun source(source: Source) = object: ForwardingSource(source) {
var totalBytesRead = 0L
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
totalBytesRead += if (bytesRead == -1L) 0L else bytesRead
progressListener.update(tag, totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
init {
interceptors[Pair::class] = { chain ->
val request = chain.request()
var response = chain.proceed(request)
var retry = 5
while (!response.isSuccessful && retry > 0) {
response = chain.proceed(request)
retry--
}
response.newBuilder()
.body(response.body()?.let {
ProgressResponseBody(request.tag(), it, progressListener)
}).build()
}
}
//endregion
//region Singleton
companion object {
@Volatile private var instance: DownloadWorker? = null
fun getInstance(context: Context) =
instance ?: synchronized(this) {
instance ?: DownloadWorker(context).also { instance = it }
}
}
//endregion
val notificationManager = NotificationManagerCompat.from(context)
val queue = LinkedBlockingQueue<Int>()
/*
* KEY
* primary galleryID
* secondary index
* PRIMARY VALUE
* MutableList -> Download in progress
* null -> Loading / Gallery doesn't exist
* SECONDARY VALUE
* 0 <= value < 100 -> Download in progress
* Float.POSITIVE_INFINITY -> Download completed
*/
val progress = SparseArray<MutableList<Float>?>()
val notification = SparseArray<NotificationCompat.Builder?>()
private val loop = loop()
private val worker = SparseArray<Job?>()
fun stop() {
queue.clear()
loop.cancel()
for (i in 0 until worker.size()) {
val galleryID = worker.keyAt(i)
Cache(this@DownloadWorker).setDownloading(galleryID, false)
worker[galleryID]?.cancel()
}
client.dispatcher().queuedCalls().filter {
it.request().tag() is Pair<*, *>
}.forEach {
it.cancel()
}
client.dispatcher().runningCalls().filter {
it.request().tag() is Pair<*, *>
}.forEach {
it.cancel()
}
progress.clear()
notification.clear()
notificationManager.cancelAll()
}
fun cancel(galleryID: Int) {
queue.remove(galleryID)
worker[galleryID]?.cancel()
client.dispatcher().queuedCalls().filter {
((it.request().tag() as Pair<*, *>).first as Int) == galleryID
}.forEach {
it.cancel()
}
client.dispatcher().runningCalls().filter {
((it.request().tag() as Pair<*, *>).first as Int) == galleryID
}.forEach {
it.cancel()
}
progress.remove(galleryID)
notification.remove(galleryID)
notificationManager.cancel(galleryID)
}
fun isCompleted(galleryID: Int) = progress[galleryID]?.all { it.isInfinite() } == true
private fun queueDownload(galleryID: Int, reader: Reader, index: Int, callback: Callback) {
val lowQuality = preferences.getBoolean("low_quality", false)
val request = Request.Builder().apply {
when (reader.code) {
Code.HITOMI -> {
url(
imageUrlFromImage(
galleryID,
reader.galleryInfo.files[index],
!lowQuality
)
)
addHeader("Referer", getReferer(galleryID))
}
Code.HIYOBI -> {
url(createImgList(galleryID, reader, lowQuality)[index].path)
}
else -> {
//shouldn't be called anyway
}
}
tag(galleryID to index)
}.build()
client.newCall(request).enqueue(callback)
}
private fun download(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch {
val reader = Cache(this@DownloadWorker).getReader(galleryID)
//gallery doesn't exist
if (reader == null) {
progress.put(galleryID, null)
Cache(this@DownloadWorker).setDownloading(galleryID, false)
return@launch
}
val cache = Cache(this@DownloadWorker).getImages(galleryID)
progress.put(galleryID, reader.galleryInfo.files.indices.map { index ->
if (cache?.firstOrNull { it?.nameWithoutExtension?.toIntOrNull() == index } != null)
Float.POSITIVE_INFINITY
else
0F
}.toMutableList())
if (notification[galleryID] == null)
initNotification(galleryID)
notification[galleryID]?.setContentTitle(reader.galleryInfo.title)
notify(galleryID)
if (isCompleted(galleryID)) {
with(Cache(this@DownloadWorker)) {
if (isDownloading(galleryID)) {
moveToDownload(galleryID)
setDownloading(galleryID, false)
}
}
return@launch
}
for (i in reader.galleryInfo.files.indices) {
val callback = object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (e.message?.contains("cancel", true) != false)
return
cancel(galleryID)
queue.add(galleryID)
}
override fun onResponse(call: Call, response: Response) {
if (response.code() != 200) {
response.close()
onFailure(call, IOException())
return
}
val ext = call.request().url().encodedPath().split('.').last()
try {
response.body()!!.use {
Cache(this@DownloadWorker).putImage(galleryID, i, ext, it.byteStream())
}
progress[galleryID]?.set(i, Float.POSITIVE_INFINITY)
notify(galleryID)
CoroutineScope(Dispatchers.IO).launch {
if (isCompleted(galleryID)) {
with(Cache(this@DownloadWorker)) {
if (isDownloading(galleryID)) {
moveToDownload(galleryID)
setDownloading(galleryID, false)
}
}
}
}
} catch (e: Exception) {
FirebaseCrashlytics.getInstance().apply {
log("FAIL ON OK ${call.request().tag()} (${e.message})")
setCustomKey("POS", "FAIL ON OK")
recordException(e)
}
File(Cache(this@DownloadWorker).getCachedGallery(galleryID), "%05d.$ext".format(i)).delete()
cancel(galleryID)
queue.add(galleryID)
}
}
}
if (progress[galleryID]?.get(i)?.isFinite() == true)
queueDownload(galleryID, reader, i, callback)
}
}
private fun notify(galleryID: Int) {
val max = progress[galleryID]?.size ?: 0
val progress = progress[galleryID]?.count { it.isInfinite() } ?: 0
if (isCompleted(galleryID)) {
notification[galleryID]
?.setContentText(getString(R.string.reader_notification_complete))
?.setSmallIcon(android.R.drawable.stat_sys_download_done)
?.setProgress(0, 0, false)
?.setOngoing(false)
notificationManager.cancel(galleryID)
} else
notification[galleryID]
?.setProgress(max, progress, false)
?.setContentText("$progress/$max")
if (Cache(this).isDownloading(galleryID) && notification[galleryID] != null)
notification[galleryID]?.let { notificationManager.notify(galleryID, it.build()) }
else
notificationManager.cancel(galleryID)
}
private fun initNotification(galleryID: Int) {
val intent = Intent(this, ReaderActivity::class.java).apply {
putExtra("galleryID", galleryID)
}
val pendingIntent = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(intent)
getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT)
}
notification.put(galleryID, NotificationCompat.Builder(this, "download").apply {
setContentTitle(getString(R.string.reader_loading))
setContentText(getString(R.string.reader_notification_text))
setSmallIcon(android.R.drawable.stat_sys_download) // had to use this because old android doesn't support VectorDrawable on Notification :P
setContentIntent(pendingIntent)
setProgress(0, 0, true)
setOngoing(true)
})
}
private fun loop() = CoroutineScope(Dispatchers.Default).launch {
while (true) {
if (queue.isEmpty())
continue
val galleryID = queue.peek() ?: continue
if (progress.indexOfKey(galleryID) >= 0) // Gallery already downloading!
cancel(galleryID)
if (notification[galleryID] == null)
initNotification(galleryID)
if (Cache(this@DownloadWorker).isDownloading(galleryID))
notification[galleryID]?.let { notificationManager.notify(galleryID, it.build()) }
worker.put(galleryID, download(galleryID))
queue.poll()
}
}
}

View File

@@ -1,46 +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.util.download
import kotlinx.serialization.Serializable
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.Reader
@Suppress("DEPRECATION")
@Deprecated("Use downloader.Cache.Metadata instead")
@Serializable
data class Metadata(
var thumbnail: String? = null,
var galleryBlock: GalleryBlock? = null,
var reader: Reader? = null,
var isDownloading: Boolean? = null
) {
constructor(
metadata: Metadata?,
thumbnail: String? = null,
galleryBlock: GalleryBlock? = null,
readers: Reader? = null,
isDownloading: Boolean? = null
) : this(
thumbnail ?: metadata?.thumbnail,
galleryBlock ?: metadata?.galleryBlock,
readers ?: metadata?.reader,
isDownloading ?: metadata?.isDownloading
)
}

View File

@@ -20,38 +20,98 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.util.SparseArray import android.net.Uri
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Request import okhttp3.Request
import xyz.quaver.Code
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.Reader
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.* import xyz.quaver.io.util.*
import xyz.quaver.pupil.client import xyz.quaver.pupil.client
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.hitomi.*
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
@Serializable
data class OldReader(
val code: String,
val galleryInfo: OldGalleryInfo
)
@Serializable
data class OldGalleryInfo(
val language_localname: String? = null,
val language: String? = null,
val date: String? = null,
val files: List<OldGalleryFiles>,
val id: Int? = null,
val type: String? = null,
val title: String? = null
)
@Serializable
data class OldGalleryFiles(
val width: Int,
val hash: String,
val haswebp: Int = 0,
val name: String,
val height: Int,
val hasavif: Int = 0,
val hasavifsmalltn: Int? = 0
)
@Serializable
data class OldMetadata(
var galleryBlock: GalleryBlock? = null,
var reader: OldReader? = null,
var imageList: MutableList<String?>? = null
) {
fun copy(): OldMetadata = OldMetadata(galleryBlock, reader, imageList?.let { MutableList(it.size) { i -> it[i] } })
}
@Serializable @Serializable
data class Metadata( data class Metadata(
var galleryBlock: GalleryBlock? = null, var galleryBlock: GalleryBlock? = null,
var reader: Reader? = null, var galleryInfo: GalleryInfo? = null,
var imageList: MutableList<String?>? = null var imageList: MutableList<String?>? = null
) { ) {
fun copy(): Metadata = Metadata(galleryBlock, reader, imageList?.let { MutableList(it.size) { i -> it[i] } }) constructor(old: OldMetadata) : this(
old.galleryBlock,
old.reader?.galleryInfo?.let { oldGalleryInfo ->
GalleryInfo(
oldGalleryInfo.id.toString(),
oldGalleryInfo.title ?: "",
null,
oldGalleryInfo.language,
oldGalleryInfo.type ?: "",
oldGalleryInfo.date ?: "",
files = oldGalleryInfo.files.map {
GalleryFiles(
it.width,
it.hash,
it.haswebp,
it.name,
it.height,
it.hasavif,
it.hasavifsmalltn
)
}
)
},
old.imageList
)
fun copy(): Metadata = Metadata(galleryBlock, galleryInfo, imageList?.let { MutableList(it.size) { i -> it[i] } })
} }
class Cache private constructor(context: Context, val galleryID: Int) : ContextWrapper(context) { class Cache private constructor(context: Context, val galleryID: Int) : ContextWrapper(context) {
companion object { companion object {
val instances = SparseArray<Cache>() val instances = ConcurrentHashMap<Int, Cache>()
fun getInstance(context: Context, galleryID: Int) = fun getInstance(context: Context, galleryID: Int) =
instances[galleryID] ?: synchronized(this) { instances[galleryID] ?: synchronized(this) {
@@ -59,9 +119,9 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
} }
@Synchronized @Synchronized
fun delete(galleryID: Int) { fun delete(context: Context, galleryID: Int) {
instances[galleryID]?.cacheFolder?.deleteRecursively() File(context.cacheDir, "imageCache/$galleryID").deleteRecursively()
instances.delete(galleryID) instances.remove(galleryID)
} }
} }
@@ -70,10 +130,14 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
} }
var metadata = kotlin.runCatching { var metadata = kotlin.runCatching {
findFile(".metadata")?.readText()?.let { findFile(".metadata")?.readText()?.let { metadata ->
Json.decodeFromString<Metadata>(it) kotlin.runCatching {
Json.decodeFromString<Metadata>(metadata)
}.getOrElse {
Metadata(json.decodeFromString<OldMetadata>(metadata))
} }
}.getOrNull() ?: Metadata() }
}.onFailure { it.printStackTrace() }.getOrNull() ?: Metadata()
val downloadFolder: FileX? val downloadFolder: FileX?
get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID) get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID)
@@ -106,100 +170,109 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
} }
suspend fun getGalleryBlock(): GalleryBlock? { suspend fun getGalleryBlock(): GalleryBlock? {
val sources = listOf(
{ xyz.quaver.hitomi.getGalleryBlock(galleryID) },
{ xyz.quaver.hiyobi.getGalleryBlock(galleryID) }
)
return metadata.galleryBlock return metadata.galleryBlock
?: withContext(Dispatchers.IO) { ?: withContext(Dispatchers.IO) {
var galleryBlock: GalleryBlock? = null try {
getGalleryBlock(galleryID).also {
for (source in sources) {
galleryBlock = try {
source.invoke()
} catch (e: Exception) { null }
if (galleryBlock != null)
break
}
galleryBlock?.also {
setMetadata { metadata -> metadata.galleryBlock = it } setMetadata { metadata -> metadata.galleryBlock = it }
} }
} catch (e: Exception) { return@withContext null }
} }
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun getThumbnail(): ByteArray? = suspend fun getThumbnail(): Uri =
findFile(".thumbnail")?.readBytes() findFile(".thumbnail")?.uri
?: getGalleryBlock()?.thumbnails?.firstOrNull()?.let { withContext(Dispatchers.IO) { ?: getGalleryBlock()?.thumbnails?.firstOrNull()?.let { withContext(Dispatchers.IO) {
kotlin.runCatching { kotlin.runCatching {
val request = Request.Builder() val request = Request.Builder()
.url(it) .url(it)
.header("Referer", "https://hitomi.la/")
.build() .build()
client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() } client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() }
}.getOrNull()?.also { kotlin.run { }.getOrNull()?.let { thumbnail -> kotlin.runCatching {
cacheFolder.getChild(".thumbnail").writeBytes(it) cacheFolder.getChild(".thumbnail").also {
} } if (!it.exists())
} } it.createNewFile()
suspend fun getReader(): Reader? { it.writeBytes(thumbnail)
val mirrors = Preferences.get<String>("mirrors").let { if (it.isEmpty()) emptyList() else it.split('>') }
val sources = mapOf(
Code.HITOMI to { xyz.quaver.hitomi.getReader(galleryID) },
Code.HIYOBI to { xyz.quaver.hiyobi.getReader(galleryID) }
).let {
if (mirrors.isNotEmpty())
it.toSortedMap{ o1, o2 -> mirrors.indexOf(o1.name) - mirrors.indexOf(o2.name) }
else
it
} }
}.getOrNull()?.uri }
} } ?: Uri.EMPTY
return metadata.reader suspend fun getGalleryInfo(): GalleryInfo? {
return metadata.galleryInfo
?: withContext(Dispatchers.IO) { ?: withContext(Dispatchers.IO) {
var reader: Reader? = null try {
getGalleryInfo(galleryID).also {
for (source in sources) {
reader = try {
source.value.invoke()
} catch (e: Exception) {
null
}
if (reader != null)
break
}
reader?.also {
setMetadata { metadata -> setMetadata { metadata ->
metadata.reader = it metadata.galleryInfo = it
if (metadata.imageList == null) if (metadata.imageList == null)
metadata.imageList = MutableList(reader.galleryInfo.files.size) { null } metadata.imageList = MutableList(it.files.size) { null }
} }
} }
} catch (e: Exception) {
null
}
} }
} }
fun getImage(index: Int): FileX? = fun getImage(index: Int): FileX? =
metadata.imageList?.get(index)?.let { findFile(it) } metadata.imageList?.getOrNull(index)?.let { findFile(it) }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
fun putImage(index: Int, fileName: String, data: ByteArray) { suspend fun putImage(index: Int, fileName: String, data: ByteArray) = coroutineScope {
val file = cacheFolder.getChild(fileName) val file = cacheFolder.getChild(fileName)
if (!file.exists())
file.createNewFile() file.createNewFile()
file.writeBytes(data) file.writeBytes(data)
setMetadata { metadata -> metadata.imageList!![index] = fileName } setMetadata { metadata -> metadata.imageList!![index] = fileName }
} }
private val lock = ConcurrentHashMap<Int, Mutex>()
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
fun moveToDownload() = CoroutineScope(Dispatchers.IO).launch { fun moveToDownload() = CoroutineScope(Dispatchers.IO).launch {
val downloadFolder = downloadFolder ?: return@launch val downloadFolder = downloadFolder ?: return@launch
if (lock[galleryID]?.isLocked == true)
return@launch
(lock[galleryID] ?: Mutex().also { lock[galleryID] = it }).withLock {
val cacheMetadata = cacheFolder.getChild(".metadata")
val downloadMetadata = downloadFolder.getChild(".metadata")
if (!cacheMetadata.exists())
return@launch
if (cacheMetadata.exists()) {
kotlin.runCatching {
if (!downloadMetadata.exists())
downloadMetadata.createNewFile()
downloadMetadata.writeText(Json.encodeToString(metadata))
}
}
val cacheThumbnail = cacheFolder.getChild(".thumbnail")
val downloadThumbnail = downloadFolder.getChild(".thumbnail")
if (cacheThumbnail.exists()) {
kotlin.runCatching {
if (!downloadThumbnail.exists())
downloadThumbnail.createNewFile()
downloadThumbnail.outputStream()?.use { target -> target.channel.truncate(0L); cacheThumbnail.inputStream()?.use { source ->
source.copyTo(target)
} }
cacheThumbnail.delete()
}
}
metadata.imageList?.forEach { imageName -> metadata.imageList?.forEach { imageName ->
imageName ?: return@forEach imageName ?: return@forEach
val target = downloadFolder.getChild(imageName) val target = downloadFolder.getChild(imageName)
@@ -209,22 +282,16 @@ class Cache private constructor(context: Context, val galleryID: Int) : ContextW
return@forEach return@forEach
kotlin.runCatching { kotlin.runCatching {
if (!target.exists())
target.createNewFile() target.createNewFile()
source.readBytes()?.let { target.writeBytes(it) }
target.outputStream()?.use { target -> target.channel.truncate(0L); source.inputStream()?.use { source ->
source.copyTo(target)
} }
} }
} }
val cacheMetadata = cacheFolder.getChild(".metadata") cacheFolder.deleteRecursively()
val downloadMetadata = downloadFolder.getChild(".metadata")
if (cacheMetadata.exists()) {
kotlin.runCatching {
downloadMetadata.createNewFile()
downloadMetadata.writeText(Json.encodeToString(metadata))
cacheMetadata.delete()
} }
} }
cacheFolder.delete()
}
} }

View File

@@ -21,13 +21,18 @@ package xyz.quaver.pupil.util.downloader
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.util.Log import android.util.Log
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Call import okhttp3.Call
import xyz.quaver.io.FileX import xyz.quaver.io.FileX
import xyz.quaver.io.util.* import xyz.quaver.io.util.deleteRecursively
import xyz.quaver.io.util.getChild
import xyz.quaver.io.util.readText
import xyz.quaver.io.util.writeText
import xyz.quaver.pupil.client import xyz.quaver.pupil.client
import xyz.quaver.pupil.services.DownloadService import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.util.Preferences import xyz.quaver.pupil.util.Preferences
@@ -47,14 +52,12 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!) val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
val downloadFolder: FileX val downloadFolder: FileX
get() = { get() = kotlin.runCatching {
kotlin.runCatching {
FileX(this, Preferences.get<String>("download_folder")) FileX(this, Preferences.get<String>("download_folder"))
}.getOrElse { }.getOrElse {
Preferences["download_folder"] = defaultDownloadFolder.uri.toString() Preferences["download_folder"] = defaultDownloadFolder.uri.toString()
defaultDownloadFolder defaultDownloadFolder
} }
}.invoke()
private var prevDownloadFolder: FileX? = null private var prevDownloadFolder: FileX? = null
private var downloadFolderMapInstance: MutableMap<Int, String>? = null private var downloadFolderMapInstance: MutableMap<Int, String>? = null
@@ -63,30 +66,28 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
get() { get() {
if (prevDownloadFolder != downloadFolder) { if (prevDownloadFolder != downloadFolder) {
prevDownloadFolder = downloadFolder prevDownloadFolder = downloadFolder
downloadFolderMapInstance = { downloadFolderMapInstance = run {
val file = downloadFolder.getChild(".download") val file = downloadFolder.getChild(".download")
val data = if (file.exists()) val data = if (file.exists())
kotlin.runCatching { kotlin.runCatching {
file.readText()?.let{ Json.decodeFromString<MutableMap<Int, String>>(it) } file.readText()?.let{ Json.decodeFromString<MutableMap<Int, String>>(it) }
}.onFailure { file.delete() }.getOrNull() }.onFailure { file.delete() }.getOrNull()
else else
null null
data ?: run {
data ?: {
file.createNewFile() file.createNewFile()
mutableMapOf<Int, String>() mutableMapOf()
}.invoke() }
}.invoke() }
} }
return downloadFolderMapInstance!! return downloadFolderMapInstance ?: mutableMapOf()
} }
@Synchronized @Synchronized
fun isDownloading(galleryID: Int): Boolean { fun isDownloading(galleryID: Int): Boolean {
val isThisGallery: (Call) -> Boolean = { (it.request().tag() as? DownloadService.Tag)?.galleryID == galleryID } val isThisGallery: (Call) -> Boolean = { !it.isCanceled && (it.request().tag() as? DownloadService.Tag)?.galleryID == galleryID }
return downloadFolderMap.containsKey(galleryID) return downloadFolderMap.containsKey(galleryID)
&& client.dispatcher().let { it.queuedCalls().any(isThisGallery) || it.runningCalls().any(isThisGallery) } && client.dispatcher().let { it.queuedCalls().any(isThisGallery) || it.runningCalls().any(isThisGallery) }
@@ -96,21 +97,19 @@ class DownloadManager private constructor(context: Context) : ContextWrapper(con
fun getDownloadFolder(galleryID: Int): FileX? = fun getDownloadFolder(galleryID: Int): FileX? =
downloadFolderMap[galleryID]?.let { downloadFolder.getChild(it) } downloadFolderMap[galleryID]?.let { downloadFolder.getChild(it) }
@Synchronized fun addDownloadFolder(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch {
fun addDownloadFolder(galleryID: Int) { val name = Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock()
val name = runBlocking { ?.formatDownloadFolder() ?: return@launch
Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock()
}?.formatDownloadFolder() ?: return
val folder = downloadFolder.getChild(name) val folder = downloadFolder.getChild(name)
if (!folder.exists()) downloadFolderMap[galleryID] = name
folder.mkdir()
downloadFolderMap[galleryID] = folder.name
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() } downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap)) downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
if (folder.exists()) return@launch
folder.mkdir()
} }
@Synchronized @Synchronized

View File

@@ -19,35 +19,49 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.content.Context import android.content.Context
import android.os.storage.StorageManager import kotlinx.coroutines.CoroutineScope
import androidx.core.content.ContextCompat import kotlinx.coroutines.Dispatchers
import androidx.core.net.toUri import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import xyz.quaver.pupil.histories
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.lang.reflect.Array
import java.net.URL
@Suppress("DEPRECATION") val mutex = Mutex()
@Deprecated("Use downloader.Cache instead") fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
fun getCachedGallery(context: Context, galleryID: Int) = if (mutex.isLocked) return@launch
File(getDownloadDirectory(context), galleryID.toString()).let {
if (it.exists()) mutex.withLock {
it val cacheFolder = File(context.cacheDir, "imageCache")
else val downloadManager = DownloadManager.getInstance(context)
File(context.cacheDir, "imageCache/$galleryID")
val limit = (Preferences.get<String>("cache_limit").toLongOrNull() ?: 0L)*1024*1024*1024
if (limit == 0L) return@withLock
val cacheSize = {
var size = 0L
cacheFolder.walk().forEach {
size += it.length()
} }
@Suppress("DEPRECATION") size
@Deprecated("Use downloader.Cache instead")
fun getDownloadDirectory(context: Context) =
Preferences.get<String>("dl_location").let {
if (it.isNotEmpty() && !it.startsWith("content"))
File(it)
else
context.getExternalFilesDir(null)!!
} }
@Suppress("DEPRECATION") if (cacheSize.invoke() > limit)
@Deprecated("Use FileX instead") while (cacheSize.invoke() > limit/2) {
fun File.isParentOf(another: File) = val caches = cacheFolder.list() ?: return@withLock
another.absolutePath.startsWith(this.absolutePath)
synchronized(histories) {
(histories.firstOrNull {
caches.contains(it.toString()) && !downloadManager.isDownloading(it)
} ?: return@withLock).let {
Cache.delete(context, it)
}
}
}
}
}

View File

@@ -18,19 +18,25 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.serialization.json.*
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import xyz.quaver.Code import xyz.quaver.pupil.R
import xyz.quaver.hitomi.GalleryBlock import xyz.quaver.pupil.hitomi.GalleryBlock
import xyz.quaver.hitomi.Reader import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.hitomi.getReferer import xyz.quaver.pupil.hitomi.imageUrlFromImage
import xyz.quaver.hitomi.imageUrlFromImage
import xyz.quaver.hiyobi.createImgList
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
@@ -40,7 +46,7 @@ fun String.wordCapitalize() : String {
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
for (word in this.split(" ")) for (word in this.split(" "))
result.add(word.capitalize(Locale.US)) result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() })
return result.joinToString(" ") return result.joinToString(" ")
} }
@@ -82,7 +88,8 @@ fun OkHttpClient.Builder.proxyInfo(proxyInfo: ProxyInfo) = this.apply {
val formatMap = mapOf<String, GalleryBlock.() -> (String)>( val formatMap = mapOf<String, GalleryBlock.() -> (String)>(
"-id-" to { id.toString() }, "-id-" to { id.toString() },
"-title-" to { title }, "-title-" to { title },
"-artist-" to { artists.joinToString() } "-artist-" to { if (artists.isNotEmpty()) artists.joinToString() else "N/A" },
"-group-" to { if (groups.isNotEmpty()) groups.joinToString() else "N/A" }
// TODO // TODO
) )
/** /**
@@ -93,39 +100,95 @@ fun GalleryBlock.formatDownloadFolder(): String =
formatMap.entries.fold(it) { str, (k, v) -> formatMap.entries.fold(it) { str, (k, v) ->
str.replace(k, v.invoke(this), true) str.replace(k, v.invoke(this), true)
} }
}.replace("/", "") }.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
fun GalleryBlock.formatDownloadFolderTest(format: String): String = fun GalleryBlock.formatDownloadFolderTest(format: String): String =
format.let { format.let {
formatMap.entries.fold(it) { str, (k, v) -> formatMap.entries.fold(it) { str, (k, v) ->
str.replace(k, v.invoke(this), true) str.replace(k, v.invoke(this), true)
} }
}.replace("/", "") }.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
val Reader.requestBuilders: List<Request.Builder> suspend fun GalleryInfo.getRequestBuilders(): List<Request.Builder> {
get() { val galleryID = this.id.toIntOrNull() ?: 0
val galleryID = this.galleryInfo.id ?: 0 return this.files.map {
val lowQuality = Preferences["low_quality", true]
return when(code) {
Code.HITOMI -> {
this.galleryInfo.files.map {
Request.Builder() Request.Builder()
.url(imageUrlFromImage(galleryID, it, !lowQuality)) .url(
.header("Referer", getReferer(galleryID)) runCatching {
} imageUrlFromImage(galleryID, it, false)
}
Code.HIYOBI -> {
createImgList(galleryID, this, lowQuality).map {
Request.Builder()
.url(it.path)
} }
.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
} }
.getOrDefault("https://a/")
)
.header("Referer", "https://hitomi.la/")
} }
} }
fun String.ellipsize(n: Int): String = fun byteCount(codePoint: Int): Int = when (codePoint) {
if (this.length > n) in 0 ..< 0x80 -> 1
this.slice(0 until n) + "" in 0x80 ..< 0x800 -> 2
else in 0x800 ..< 0x10000 -> 3
this in 0x10000 ..< 0x110000 -> 4
else -> 0
}
fun String.ellipsize(n: Int): String = buildString {
var count = 0
var index = 0
val codePointLength = this@ellipsize.codePointCount(0, this@ellipsize.length)
while (index < codePointLength) {
val nextCodePoint = this@ellipsize.codePointAt(index)
val nextByte = byteCount(nextCodePoint)
if (count + nextByte > 124) {
append("")
break
}
appendCodePoint(nextCodePoint)
count += nextByte
index++
}
}
operator fun JsonElement.get(index: Int) =
this.jsonArray[index]
operator fun JsonElement.get(tag: String) =
this.jsonObject[tag]
fun JsonElement.getOrNull(tag: String) = kotlin.runCatching {
this.jsonObject.getOrDefault(tag, null)
}.getOrNull()
val JsonElement.content
get() = this.jsonPrimitive.contentOrNull
fun checkNotificationEnabled(context: Context) =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
fun showNotificationPermissionExplanationDialog(context: Context) {
AlertDialog.Builder(context)
.setTitle(R.string.warning)
.setMessage(R.string.notification_denied)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.show()
}
fun requestNotificationPermission(
activity: Activity,
requestPermissionLauncher: ActivityResultLauncher<String>,
showRationale: Boolean = true,
ifGranted: () -> Unit,
) {
when {
checkNotificationEnabled(activity) -> ifGranted()
showRationale && ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.POST_NOTIFICATIONS) ->
showNotificationPermissionExplanationDialog(activity)
else ->
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Request
import xyz.quaver.pupil.client
import java.io.IOException
import java.util.*
private val filesURL = "https://api.github.com/repos/tom5079/Pupil/git/trees/tags"
private val contentURL = "https://raw.githubusercontent.com/tom5079/Pupil/tags/"
var translations: Map<String, String> = run {
updateTranslations()
emptyMap()
}
private set
@Suppress("BlockingMethodInNonBlockingContext")
fun updateTranslations() = CoroutineScope(Dispatchers.IO).launch {
translations = emptyMap()
kotlin.runCatching {
translations = Json.decodeFromString<Map<String, String>>(client.newCall(
Request.Builder()
.url(contentURL + "${Preferences["tag_translation", ""].let { if (it.isEmpty()) Locale.getDefault().language else it }}.json")
.build()
).execute().also { if (it.code() != 200) return@launch }.body()?.use { it.string() } ?: return@launch).filterValues { it.isNotEmpty() }
}
}
fun getAvailableLanguages(): List<String> {
val languages = Locale.getISOLanguages()
val json = Json.parseToJsonElement(client.newCall(
Request.Builder()
.url(filesURL)
.build()
).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.string() } ?: return emptyList())
return listOf("en") + (json["tree"]?.jsonArray?.mapNotNull {
val name = it["path"]?.jsonPrimitive?.content?.takeWhile { c -> c != '.' }
languages.firstOrNull { code -> code.equals(name, ignoreCase = true) }
} ?: emptyList())
}

View File

@@ -18,48 +18,23 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.annotation.SuppressLint
import android.app.DownloadManager import android.app.DownloadManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri import android.net.Uri
import android.util.Base64
import android.util.Log
import android.webkit.URLUtil import android.webkit.URLUtil
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import ru.noties.markwon.Markwon import ru.noties.markwon.Markwon
import xyz.quaver.hitomi.GalleryBlock import xyz.quaver.pupil.*
import xyz.quaver.hitomi.Reader import xyz.quaver.pupil.types.Tag
import xyz.quaver.hitomi.getGalleryBlock
import xyz.quaver.hitomi.getReader
import xyz.quaver.io.FileX
import xyz.quaver.io.util.getChild
import xyz.quaver.io.util.*
import xyz.quaver.pupil.BuildConfig
import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.services.DownloadService
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.Metadata
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.URL import java.net.URL
@@ -160,6 +135,10 @@ fun checkUpdate(context: Context, force: Boolean = false) {
val msg = extractReleaseNote(update, Locale.getDefault()) val msg = extractReleaseNote(update, Locale.getDefault())
setMessage(Markwon.create(context).toMarkdown(msg)) setMessage(Markwon.create(context).toMarkdown(msg))
setPositiveButton(android.R.string.ok) { _, _ -> setPositiveButton(android.R.string.ok) { _, _ ->
if (!checkNotificationEnabled(context)) {
showNotificationPermissionExplanationDialog(context)
return@setPositiveButton
}
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
@@ -182,10 +161,10 @@ fun checkUpdate(context: Context, force: Boolean = false) {
Preferences["update_download_id"] = it Preferences["update_download_id"] = it
} }
} }
setNegativeButton(if (force) android.R.string.cancel else R.string.ignore_update) { _, _ -> setNegativeButton(if (force) android.R.string.cancel else R.string.ignore) { _, _ ->
if (!force) if (!force)
preferences.edit() preferences.edit()
.putLong("ignore_update_until", System.currentTimeMillis() + 604800000) .putLong("ignore_update_until", System.currentTimeMillis() + 86400000)
.apply() .apply()
} }
} }
@@ -196,7 +175,7 @@ fun checkUpdate(context: Context, force: Boolean = false) {
} }
} }
fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((List<Int>) -> Unit)? = null) { fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((Int) -> Unit)? = null) {
if (!URLUtil.isValidUrl(url)) { if (!URLUtil.isValidUrl(url)) {
onFailure?.invoke(IllegalArgumentException()) onFailure?.invoke(IllegalArgumentException())
return return
@@ -214,123 +193,22 @@ fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
kotlin.runCatching { kotlin.runCatching {
Json.decodeFromString<List<Int>>(response.also { if (it.code() != 200) throw IOException() }.body().use { it?.string() } ?: "[]").let { val data = Json.parseToJsonElement(response.also { if (it.code() != 200) throw IOException() }.body().use { it?.string() } ?: "[]")
favorites.addAll(it)
onSuccess?.invoke(it) when (data) {
is JsonArray -> favorites.addAll(data.map { it.jsonPrimitive.int })
is JsonObject -> {
val newFavorites = data["favorites"]?.let { Json.decodeFromJsonElement<List<Int>>(it) }.orEmpty()
val newFavoriteTags = data["favorite_tags"]?.let { Json.decodeFromJsonElement<List<Tag>>(it) }.orEmpty()
favorites.addAll(newFavorites)
favoriteTags.addAll(newFavoriteTags)
onSuccess?.invoke(favorites.size + favoriteTags.size)
}
else -> error("data is neither JsonArray or JsonObject")
} }
}.onFailure { onFailure?.invoke(it) } }.onFailure { onFailure?.invoke(it) }
} }
}) })
} }
private var job: Job? = null
private val receiver = object: BroadcastReceiver() {
val ACTION_CANCEL = "ACTION_IMPORT_CANCEL"
override fun onReceive(context: Context?, intent: Intent?) {
context ?: return
when (intent?.action) {
ACTION_CANCEL -> {
job?.cancel()
NotificationManagerCompat.from(context).cancel(R.id.notification_id_import)
context.unregisterReceiver(this)
}
}
}
}
@SuppressLint("RestrictedApi")
fun xyz.quaver.pupil.util.downloader.DownloadManager.migrate() {
registerReceiver(receiver, IntentFilter().apply { addAction(receiver.ACTION_CANCEL) })
val notificationManager = NotificationManagerCompat.from(this)
val action = NotificationCompat.Action.Builder(0, getText(android.R.string.cancel),
PendingIntent.getBroadcast(this, R.id.notification_import_cancel_action.normalizeID(), Intent(receiver.ACTION_CANCEL), PendingIntent.FLAG_UPDATE_CURRENT)
).build()
val notification = NotificationCompat.Builder(this, "import")
.setContentTitle(getText(R.string.import_old_galleries_notification))
.setProgress(0, 0, true)
.addAction(action)
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
DownloadService.cancel(this)
job?.cancel()
job = CoroutineScope(Dispatchers.IO).launch {
val downloadFolders = downloadFolder.listFiles { folder ->
folder.isDirectory && !downloadFolderMap.values.contains(folder.name)
}?.map {
if (it !is FileX)
FileX(this@migrate, it)
else
it
}
if (downloadFolders.isNullOrEmpty()) return@launch
downloadFolders.forEachIndexed { index, folder ->
notification
.setContentText(getString(R.string.import_old_galleries_notification_text, index, downloadFolders.size))
.setProgress(index, downloadFolders.size, false)
notificationManager.notify(R.id.notification_id_import, notification.build())
kotlin.runCatching {
val metadata = kotlin.runCatching {
folder.getChild(".metadata").readText()?.let { Json.parseToJsonElement(it).jsonObject }
}.getOrNull()
val galleryID = folder.name.toIntOrNull() ?: return@runCatching
val galleryBlock: GalleryBlock? = kotlin.runCatching {
metadata?.get("galleryBlock")?.let { Json.decodeFromJsonElement<GalleryBlock>(it) }
}.getOrNull() ?: getGalleryBlock(galleryID)
val reader: Reader? = kotlin.runCatching {
metadata?.get("reader")?.let { Json.decodeFromJsonElement<Reader>(it) }
}.getOrNull() ?: getReader(galleryID)
metadata?.get("thumbnail")?.jsonPrimitive?.contentOrNull?.also { thumbnail ->
val file = folder.getChild(".thumbnail").also {
if (it.exists())
it.delete()
it.createNewFile()
}
file.writeBytes(Base64.decode(thumbnail, Base64.DEFAULT))
}
val list: MutableList<String?> =
MutableList(reader!!.galleryInfo.files.size) { null }
folder.listFiles { file ->
file?.nameWithoutExtension?.let {
Regex("""\d{5}""").matches(it) && it.toIntOrNull() != null
} == true
}?.forEach {
list[it.nameWithoutExtension.toInt()] = it.name
}
folder.getChild(".metadata").also { if (it.exists()) it.delete(); it.createNewFile() }.writeText(
Json.encodeToString(Metadata(galleryBlock, reader, list))
)
synchronized(Cache) {
Cache.delete(galleryID)
}
downloadFolderMap[galleryID] = folder.name
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile(); it.writeText(Json.encodeToString(downloadFolderMap)) }
}
}
notification
.setContentText(getText(R.string.import_old_galleries_notification_done))
.setProgress(0, 0, false)
.setOngoing(false)
.mActions.clear()
notificationManager.notify(R.id.notification_id_import, notification.build())
kotlin.runCatching {
unregisterReceiver(receiver)
}
}
}

View File

@@ -1,8 +1,4 @@
<!--drawable/account_group.xml--> <!--drawable/account_group.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="?attr/colorControlNormal" android:pathData="M12 5.5A3.5 3.5 0 0 1 15.5 9a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 9 3.5 3.5 0 0 1 12 5.5M5 8c0.56 0 1.08 0.15 1.53 0.42-0.15 1.43 0.27 2.85 1.13 3.96C7.16 13.34 6.16 14 5 14a3 3 0 0 1-3-3 3 3 0 0 1 3-3m14 0a3 3 0 0 1 3 3 3 3 0 0 1-3 3c-1.16 0-2.16-0.66-2.66-1.62 0.86-1.11 1.28-2.53 1.13-3.96C17.92 8.15 18.44 8 19 8M5.5 18.25c0-2.07 2.91-3.75 6.5-3.75s6.5 1.68 6.5 3.75V20h-13v-1.75M0 20v-1.5c0-1.39 1.89-2.56 4.45-2.9-0.59 0.68-0.95 1.62-0.95 2.65V20H0m24 0h-3.5v-1.75c0-1.03-0.36-1.97-0.95-2.65 2.56 0.34 4.45 1.51 4.45 2.9V20z"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="?attr/colorControlNormal" android:pathData="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" />
</vector> </vector>

View File

@@ -1,8 +1,4 @@
<!--drawable/account_star.xml--> <!--drawable/account_star.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="?attr/colorControlNormal" android:pathData="M15 14c-2.67 0-8 1.33-8 4v2h16v-2c0-2.67-5.33-4-8-4m0-2a4 4 0 0 0 4-4 4 4 0 0 0-4-4 4 4 0 0 0-4 4 4 4 0 0 0 4 4M5 13.28l2.45 1.49-0.65-2.81L9 10.08 6.11 9.83 5 7.19 3.87 9.83 1 10.08l2.18 1.88-0.68 2.81L5 13.28z"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="?attr/colorControlNormal" android:pathData="M15,14C12.33,14 7,15.33 7,18V20H23V18C23,15.33 17.67,14 15,14M15,12A4,4 0 0,0 19,8A4,4 0 0,0 15,4A4,4 0 0,0 11,8A4,4 0 0,0 15,12M5,13.28L7.45,14.77L6.8,11.96L9,10.08L6.11,9.83L5,7.19L3.87,9.83L1,10.08L3.18,11.96L2.5,14.77L5,13.28Z" />
</vector> </vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.5,10.5 L12,3m0,0 l7.5,7.5M12,3v18"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/colorPrimaryDark"
android:strokeLineCap="round"/>
</vector>

View File

@@ -1,8 +1,4 @@
<!--drawable/arrow_right.xml--> <!--drawable/arrow_right.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="#fff" android:pathData="M4 11v2h12l-5.5 5.5 1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5 16 11H4z"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#fff" android:pathData="M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z" />
</vector> </vector>

View File

@@ -1,37 +1,14 @@
<animated-vector <animated-vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" xmlns:tools="http://schemas.android.com/tools" tools:ignore="newApi">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
xmlns:tools="http://schemas.android.com/tools"
tools:ignore="newApi">
<aapt:attr name="android:drawable"> <aapt:attr name="android:drawable">
<vector <vector android:name="vector" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:name="vector" <path android:name="path" android:pathData="M 12 15.39 L 8.24 17.66 L 9.23 13.38 L 5.91 10.5 L 10.29 10.13 L 12 6.09 L 13.71 10.13 L 18.09 10.5 L 14.77 13.38 L 15.76 17.66 M 22 9.24 L 14.81 8.63 L 12 2 L 9.19 8.63 L 2 9.24 L 7.45 13.97 L 5.82 21 L 12 17.27 L 18.18 21 L 16.54 13.97 L 22 9.24 Z" android:fillColor="@color/material_orange_500"/>
android:width="24dp" <clip-path android:name="clip" android:pathData="M 2 21 L 2 21 L 22 21 L 22 21 Z"/>
android:height="24dp" <path android:name="path_1" android:pathData="M 12 17.27 L 18.18 21 L 16.54 13.97 L 22 9.24 L 14.81 8.62 L 12 2 L 9.19 8.62 L 2 9.24 L 7.45 13.97 L 5.82 21 L 12 17.27 Z" android:fillColor="@color/material_orange_500"/>
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:name="path"
android:pathData="M 12 15.39 L 8.24 17.66 L 9.23 13.38 L 5.91 10.5 L 10.29 10.13 L 12 6.09 L 13.71 10.13 L 18.09 10.5 L 14.77 13.38 L 15.76 17.66 M 22 9.24 L 14.81 8.63 L 12 2 L 9.19 8.63 L 2 9.24 L 7.45 13.97 L 5.82 21 L 12 17.27 L 18.18 21 L 16.54 13.97 L 22 9.24 Z"
android:fillColor="@color/material_orange_500"/>
<clip-path
android:name="clip"
android:pathData="M 2 21 L 2 21 L 22 21 L 22 21 Z"/>
<path
android:name="path_1"
android:pathData="M 12 17.27 L 18.18 21 L 16.54 13.97 L 22 9.24 L 14.81 8.62 L 12 2 L 9.19 8.62 L 2 9.24 L 7.45 13.97 L 5.82 21 L 12 17.27 Z"
android:fillColor="@color/material_orange_500"/>
</vector> </vector>
</aapt:attr> </aapt:attr>
<target android:name="clip"> <target android:name="clip">
<aapt:attr name="android:animation"> <aapt:attr name="android:animation">
<objectAnimator <objectAnimator android:propertyName="pathData" android:duration="500" android:valueFrom="M 2 21 L 2 21 L 22 21 L 22 21 Z" android:valueTo="M 2 2 L 2 21 L 22 21 L 22 2 Z" android:valueType="pathType" android:interpolator="@android:interpolator/fast_out_slow_in"/>
android:propertyName="pathData"
android:duration="500"
android:valueFrom="M 2 21 L 2 21 L 22 21 L 22 21 Z"
android:valueTo="M 2 2 L 2 21 L 22 21 L 22 2 Z"
android:valueType="pathType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr> </aapt:attr>
</target> </target>
</animated-vector> </animated-vector>

View File

@@ -1,8 +1,4 @@
<!--drawable/backspace_outline.xml--> <!--drawable/backspace_outline.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="#000" android:pathData="M19 15.59L17.59 17 14 13.41 10.41 17 9 15.59 12.59 12 9 8.41 10.41 7 14 10.59 17.59 7 19 8.41 15.41 12 19 15.59M22 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H7c-0.69 0-1.23-0.36-1.59-0.89L0 12l5.41-8.12C5.77 3.35 6.31 3 7 3h15m0 2H7l-4.72 7L7 19h15V5z"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#000" android:pathData="M19,15.59L17.59,17L14,13.41L10.41,17L9,15.59L12.59,12L9,8.41L10.41,7L14,10.59L17.59,7L19,8.41L15.41,12L19,15.59M22,3A2,2 0 0,1 24,5V19A2,2 0 0,1 22,21H7C6.31,21 5.77,20.64 5.41,20.11L0,12L5.41,3.88C5.77,3.35 6.31,3 7,3H22M22,5H7L2.28,12L7,19H22V5Z" />
</vector> </vector>

View File

@@ -1,8 +1,4 @@
<!--drawable/book_open.xml--> <!--drawable/book_open.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="?attr/colorControlNormal" android:pathData="M13 12h7v1.5h-7m0-4h7V11h-7m0 3.5h7V16h-7m8-12H3a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2m0 15h-9V6h9"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="?attr/colorControlNormal" android:pathData="M13,12H20V13.5H13M13,9.5H20V11H13M13,14.5H20V16H13M21,4H3A2,2 0 0,0 1,6V19A2,2 0 0,0 3,21H21A2,2 0 0,0 23,19V6A2,2 0 0,0 21,4M21,19H12V6H21" />
</vector> </vector>

View File

@@ -1,8 +1,4 @@
<!--drawable/brush.xml--> <!--drawable/brush.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="?attr/colorControlNormal" android:pathData="M20.71 4.63l-1.34-1.34c-0.37-0.39-1.02-0.39-1.41 0L9 12.25 11.75 15l8.96-8.96c0.39-0.39 0.39-1.04 0-1.41M7 14a3 3 0 0 0-3 3c0 1.31-1.16 2-2 2 0.92 1.22 2.5 2 4 2a4 4 0 0 0 4-4 3 3 0 0 0-3-3z"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="?attr/colorControlNormal" android:pathData="M20.71,4.63L19.37,3.29C19,2.9 18.35,2.9 17.96,3.29L9,12.25L11.75,15L20.71,6.04C21.1,5.65 21.1,5 20.71,4.63M7,14A3,3 0 0,0 4,17C4,18.31 2.84,19 2,19C2.92,20.22 4.5,21 6,21A4,4 0 0,0 10,17A3,3 0 0,0 7,14Z" />
</vector> </vector>

View File

@@ -1,8 +1,4 @@
<!--drawable/cancel.xml--> <!--drawable/cancel.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="#fff" android:pathData="M12 2a10 10 0 0 1 10 10 10 10 0 0 1-10 10A10 10 0 0 1 2 12 10 10 0 0 1 12 2m0 2a8 8 0 0 0-8 8c0 1.85 0.63 3.55 1.68 4.91L16.91 5.68C15.55 4.63 13.85 4 12 4m0 16a8 8 0 0 0 8-8c0-1.85-0.63-3.55-1.68-4.91L7.09 18.32C8.45 19.37 10.15 20 12 20z"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#fff" android:pathData="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,13.85 4.63,15.55 5.68,16.91L16.91,5.68C15.55,4.63 13.85,4 12,4M12,20A8,8 0 0,0 20,12C20,10.15 19.37,8.45 18.32,7.09L7.09,18.32C8.45,19.37 10.15,20 12,20Z" />
</vector> </vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,12.75 L11.25,15 15,9.75M21,12a9,9 0,1 1,-18 0,9 9,0 0,1 18,0Z"
android:strokeLineJoin="round"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="@color/colorPrimaryDark"
android:strokeLineCap="round"/>
</vector>

View File

@@ -1,8 +1,4 @@
<!--drawable/clock_end.xml--> <!--drawable/clock_end.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="#fff" android:pathData="M12 1C8.14 1 5 4.14 5 8a7 7 0 0 0 7 7c3.86 0 7-3.13 7-7 0-3.86-3.14-7-7-7m0 2.15c2.67 0 4.85 2.17 4.85 4.85 0 2.68-2.18 4.85-4.85 4.85A4.85 4.85 0 0 1 7.15 8 4.85 4.85 0 0 1 12 3.15M11 5v3.69l3.19 1.84 0.75-1.3-2.44-1.41V5M15 16v3H3v2h12v3l4-4m0 0v4h2v-8h-2"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#fff" android:pathData="M12,1C8.14,1 5,4.14 5,8A7,7 0 0,0 12,15C15.86,15 19,11.87 19,8C19,4.14 15.86,1 12,1M12,3.15C14.67,3.15 16.85,5.32 16.85,8C16.85,10.68 14.67,12.85 12,12.85A4.85,4.85 0 0,1 7.15,8A4.85,4.85 0 0,1 12,3.15M11,5V8.69L14.19,10.53L14.94,9.23L12.5,7.82V5M15,16V19H3V21H15V24L19,20M19,20V24H21V16H19" />
</vector> </vector>

View File

@@ -1,8 +1,4 @@
<!--drawable/clock_start.xml--> <!--drawable/clock_start.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
android:height="24dp" <path android:fillColor="#fff" android:pathData="M12 1C8.14 1 5 4.14 5 8a7 7 0 0 0 7 7c3.86 0 7-3.13 7-7 0-3.86-3.14-7-7-7m0 2.15c2.67 0 4.85 2.17 4.85 4.85 0 2.68-2.18 4.85-4.85 4.85A4.85 4.85 0 0 1 7.15 8 4.85 4.85 0 0 1 12 3.15M11 5v3.69l3.19 1.84 0.75-1.3-2.44-1.41V5M4 16v8h2v-3h12v3l4-4-4-4v3H6v-3"/>
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#fff" android:pathData="M12,1C8.14,1 5,4.14 5,8A7,7 0 0,0 12,15C15.86,15 19,11.87 19,8C19,4.14 15.86,1 12,1M12,3.15C14.67,3.15 16.85,5.32 16.85,8C16.85,10.68 14.67,12.85 12,12.85A4.85,4.85 0 0,1 7.15,8A4.85,4.85 0 0,1 12,3.15M11,5V8.69L14.19,10.53L14.94,9.23L12.5,7.82V5M4,16V24H6V21H18V24L22,20L18,16V19H6V16" />
</vector> </vector>

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