Compare commits

..

235 Commits

Author SHA1 Message Date
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
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
244 changed files with 14205 additions and 4006 deletions

View File

@@ -1,26 +1,29 @@
![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.1.6-hotfix7/Pupil-v5.1.6-hotfix7.apk?color=%234fc3f7&label=DOWNLOAD%20APP&style=for-the-badge)](https://github.com/tom5079/Pupil/releases/download/5.1.6-hotfix7/Pupil-v5.1.6-hotfix7.apk)
[![](https://discordapp.com/api/guilds/610452916612104194/embed.png?style=banner2)](https://discord.gg/Stj4b5v)
[![](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)
# Features
![Main Screen](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/main-screenshot.jpg?raw=true)
# Installation
Go [Releases page](https://github.com/tom5079/Pupil/releases) and get latest version or
Visit [github page](https://tom5079.github.io/Pupil/) (only available in Korean)
or Build app yourself
or Build app yourself
# 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
Any kind of contribution is appriciated. Feel free to leave PR!
Any kind of contribution is appreciated. Feel free to leave PR!
## Tag Translation
Head over to [tags branch](https://github.com/tom5079/Pupil/tree/tags)

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
@@ -23,23 +23,15 @@
-dontobfuscate
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations
# kotlinx-serialization-json specific. Add this if you have java.lang.NoClassDefFoundError kotlinx.serialization.json.JsonObjectSerializer
-keepclassmembers class kotlinx.serialization.json.** {
-dontnote kotlinx.serialization.SerializationKt
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; } # <-- change package name to your app's
-keepclassmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; }
-keepclassmembers class xyz.quaver.** {
*** Companion;
}
-keepclasseswithmembers class xyz.quaver.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment
-keep class xyz.quaver.pupil.** { *; }
-keep class app.cash.zipline.** { *; }

Binary file not shown.

Binary file not shown.

View File

@@ -4,17 +4,34 @@
"type": "APK",
"kind": "Directory"
},
"applicationId": "xyz.quaver.pupil.beta",
"applicationId": "xyz.quaver.pupil",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 600,
"versionName": "6.0.0-alpha02",
"versionCode": 70,
"versionName": "5.3.19",
"outputFile": "app-release.apk"
}
],
"elementType": "File"
"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

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

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
<?xml version="1.0" encoding="utf-8"?><!--
~ Pupil, Hitomi.la viewer for Android
~ Copyright (C) 2020 tom5079
~
@@ -17,6 +16,4 @@
~ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" translatable="false" tools:override="true">Pupil-Debug</string>
</resources>
<resources></resources>

View File

@@ -5,11 +5,22 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="21"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.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
android:name=".Pupil"
@@ -21,12 +32,15 @@
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config"
android:windowSoftInputMode="adjustResize"
tools:replace="android:theme"
tools:ignore="UnusedAttribute">
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="face" />
<provider
android:authorities="${applicationId}.fileprovider"
android:authorities="${applicationId}.provider"
android:name="androidx.core.content.FileProvider"
android:exported="false"
android:grantUriPermissions="true">
@@ -34,24 +48,151 @@
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service android:name=".services.DownloadService"
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
android:name=".receiver.UpdateBroadcastReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter>
</receiver>
<activity android:name=".ui.LockActivity" />
<activity
android:name=".ui.ReaderActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:parentActivityName=".ui.MainActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<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" />
<data android:scheme="https" />
<data android:host="*.hasha.in"/>
<data android:pathPrefix="/reader"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<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" />
<data android:scheme="https" />
<data android:host="hitomi.la"/>
<data android:pathPrefix="/galleries"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<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" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/manga" />
</intent-filter>
<intent-filter android:autoVerify="true">
<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" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/doujinshi" />
</intent-filter>
<intent-filter android:autoVerify="true">
<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" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/cg" />
</intent-filter>
<intent-filter android:autoVerify="true">
<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" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/imageset" />
</intent-filter>
<intent-filter android:autoVerify="true">
<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" />
<data android:scheme="https" />
<data android:host="hitomi.la" />
<data android:pathPrefix="/reader" />
</intent-filter>
<intent-filter android:autoVerify="true">
<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" />
<data android:host="e-hentai.org" />
<data android:pathPrefix="/g" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="e-hentai.org" />
<data android:pathPrefix="/g" />
</intent-filter>
</activity>
<activity
android:name=".ui.SettingsActivity"
android:label="@string/settings_title">
</activity>
<activity
android:name=".ui.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize"
android:theme="@style/NoActionBarAppTheme"
android:exported="true"
android:windowSoftInputMode="adjustResize">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
</intent-filter>
</activity>
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
<activity android:name=".ui.TransferActivity" />
</application>
</manifest>

View File

@@ -23,34 +23,171 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
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.GooglePlayServicesRepairableException
import com.google.android.gms.security.ProviderInstaller
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import org.kodein.di.*
import org.kodein.di.android.x.androidXModule
import xyz.quaver.pupil.sources.core.NetworkCache
import xyz.quaver.pupil.sources.core.settingsDataStore
import xyz.quaver.pupil.util.PupilHttpClient
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.*
import okhttp3.Dispatcher
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
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.util.*
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.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import kotlin.reflect.KClass
class Pupil : Application(), DIAware {
typealias PupilInterceptor = (Interceptor.Chain) -> Response
override val di: DI by DI.lazy {
import(androidXModule(this@Pupil))
lateinit var histories: SavedSet<Int>
private set
lateinit var favorites: SavedSet<Int>
private set
lateinit var favoriteTags: SavedSet<Tag>
private set
lateinit var searchHistory: SavedSet<String>
private set
bind { singleton { NetworkCache(this@Pupil) } }
val interceptors = mutableMapOf<KClass<out Any>, PupilInterceptor>()
bindSingleton { settingsDataStore }
lateinit var clientBuilder: OkHttpClient.Builder
bind { singleton { PupilHttpClient(OkHttp.create()) } }
var clientHolder: OkHttpClient? = null
val client: OkHttpClient
get() = clientHolder ?: clientBuilder.build().also {
clientHolder = 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() {
companion object {
lateinit var instance: Pupil
private set
}
override fun onCreate() {
super.onCreate()
instance = this
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
preferences = PreferenceManager.getDefaultSharedPreferences(this)
val userID = Preferences["user_id", ""].let { userID ->
if (userID.isEmpty()) UUID.randomUUID().toString().also { Preferences["user_id"] = it }
else userID
}
FirebaseApp.initializeApp(this)
FirebaseCrashlytics.getInstance().setUserId(userID)
val proxyInfo = getProxyInfo()
clientBuilder = OkHttpClient.Builder()
// .connectTimeout(0, TimeUnit.SECONDS)
.sslSocketFactory(getSSLContext(this).socketFactory)
.readTimeout(0, TimeUnit.SECONDS)
.proxyInfo(proxyInfo)
.addInterceptor { chain ->
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)
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 {
Preferences.get<String>("download_folder").also {
if (it.startsWith("content://"))
contentResolver.takePersistableUriPermission(
Uri.parse(it),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
if (!FileX(this, it).canWrite())
throw Exception()
}
} catch (e: Exception) {
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)
favorites = SavedSet(File(ContextCompat.getDataDir(this), "favorites.json"), 0)
favoriteTags = SavedSet(File(ContextCompat.getDataDir(this), "favorites_tags.json"), Tag.parse(""))
searchHistory = SavedSet(File(ContextCompat.getDataDir(this), "search_histories.json"), "")
favoriteTags.filter { it.tag.contains('_') }.forEach {
favoriteTags.remove(it)
}
/*
if (BuildConfig.DEBUG)
FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)*/
try {
ProviderInstaller.installIfNeeded(this)
@@ -60,6 +197,15 @@ class Pupil : Application(), DIAware {
e.printStackTrace()
}
BigImageViewer.initialize(
FrescoImageLoader.with(
this,
OkHttpImagePipelineConfigFactory
.newBuilder(this, client)
.build()
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -83,6 +229,28 @@ class Pupil : Application(), DIAware {
enableVibration(true)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.channel_update_description)
enableLights(false)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
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")) {
true -> AppCompatDelegate.MODE_NIGHT_YES
false -> AppCompatDelegate.MODE_NIGHT_NO
})
super.onCreate()
}
}

View File

@@ -0,0 +1,323 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.adapters
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.daimajia.swipe.SwipeLayout
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
import com.daimajia.swipe.interfaces.SwipeAdapterInterface
import com.github.piasy.biv.loader.ImageLoader
import kotlinx.coroutines.*
import xyz.quaver.io.util.getChild
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.hitomi.getGallery
import xyz.quaver.pupil.hitomi.getGalleryInfo
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.view.ProgressCard
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.wordCapitalize
import java.io.File
class GalleryBlockAdapter(private val galleries: List<Int>) : RecyclerSwipeAdapter<RecyclerView.ViewHolder>(), SwipeAdapterInterface {
var updateAll = true
var thin: Boolean = Preferences["thin"]
inner class GalleryViewHolder(val binding: GalleryblockItemBinding) : RecyclerView.ViewHolder(binding.root) {
private var galleryID: Int = 0
init {
CoroutineScope(Dispatchers.Main).launch {
while (updateAll) {
updateProgress(itemView.context)
delay(1000)
}
}
}
private fun updateProgress(context: Context) = CoroutineScope(Dispatchers.Main).launch {
with(binding.galleryblockCard) {
val imageList = Cache.getInstance(context, galleryID).metadata.imageList
if (imageList == null) {
max = 0
return@with
}
progress = imageList.count { it != null }
max = imageList.size
this@GalleryViewHolder.binding.galleryblockId.setOnClickListener {
(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()
}
type = if (!imageList.contains(null)) {
val downloadManager = DownloadManager.getInstance(context)
if (downloadManager.getDownloadFolder(galleryID) == null)
ProgressCard.Type.CACHE
else
ProgressCard.Type.DOWNLOAD
} else
ProgressCard.Type.LOADING
}
}
fun bind(galleryID: Int) {
this.galleryID = galleryID
updateProgress(itemView.context)
val cache = Cache.getInstance(itemView.context, galleryID)
CoroutineScope(Dispatchers.IO).launch {
val galleryBlock = cache.getGalleryBlock() ?: return@launch
launch(Dispatchers.Main) {
val resources = itemView.context.resources
val languages = resources.getStringArray(R.array.languages).map {
it.split("|").let { split ->
Pair(split[0], split[1])
}
}.toMap()
val artists = galleryBlock.artists
val series = galleryBlock.series
binding.galleryblockThumbnail.apply {
setOnClickListener {
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)
}
override fun onCacheHit(imageType: Int, image: File?) {}
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 {
cache.getThumbnail().let { launch(Dispatchers.Main) {
showImage(it)
} }
}
}
binding.galleryblockTitle.text = galleryBlock.title
with(binding.galleryblockArtist) {
text = artists.joinToString { it.wordCapitalize() }
visibility = when {
artists.isNotEmpty() -> View.VISIBLE
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(binding.galleryblockSeries) {
text =
resources.getString(
R.string.galleryblock_series,
series.joinToString(", ") { it.wordCapitalize() })
visibility = when {
series.isNotEmpty() -> View.VISIBLE
else -> View.GONE
}
}
binding.galleryblockType.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
with(binding.galleryblockLanguage) {
text =
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
visibility = when {
!galleryBlock.language.isNullOrEmpty() -> View.VISIBLE
else -> View.GONE
}
}
with(binding.galleryblockTagGroup) {
onClickListener = {
onChipClickedHandler.forEach { callback ->
callback.invoke(it)
}
}
tags.clear()
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 {
val pageCount = kotlin.runCatching {
getGalleryInfo(galleryBlock.id).files.size
}.getOrNull() ?: return@launch
withContext(Dispatchers.Main) {
binding.galleryblockPagecount.text = itemView.context.getString(R.string.galleryblock_pagecount, pageCount)
}
}
with(binding.galleryblockFavorite) {
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
setOnClickListener {
when {
favorites.contains(galleryBlock.id) -> {
favorites.remove(galleryBlock.id)
setImageResource(R.drawable.ic_star_empty)
}
else -> {
favorites.add(galleryBlock.id)
setImageDrawable(AnimatedVectorDrawableCompat.create(context, R.drawable.avd_star).apply {
this ?: return@apply
registerAnimationCallback(object: Animatable2Compat.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable?) {
setImageResource(R.drawable.ic_star_filled)
}
})
start()
})
}
}
}
}
}
}
// Make some views invisible to make it thinner
if (thin) {
binding.galleryblockTagGroup.visibility = View.GONE
}
}
}
val onChipClickedHandler = ArrayList<((Tag) -> Unit)>()
var onDownloadClickedHandler: ((Int) -> Unit)? = null
var onDeleteClickedHandler: ((Int) -> Unit)? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return GalleryViewHolder(GalleryblockItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is GalleryViewHolder) {
val galleryID = galleries[position]
holder.bind(galleryID)
holder.binding.galleryblockCard.binding.download.setOnClickListener {
onDownloadClickedHandler?.invoke(position)
}
holder.binding.galleryblockCard.binding.delete.setOnClickListener {
onDeleteClickedHandler?.invoke(position)
}
mItemManger.bindView(holder.binding.root, position)
holder.binding.galleryblockCard.binding.swipeLayout.addSwipeListener(object: SwipeLayout.SwipeListener {
override fun onStartOpen(layout: SwipeLayout?) {
mItemManger.closeAllExcept(layout)
holder.binding.galleryblockCard.binding.download.text =
if (DownloadManager.getInstance(holder.binding.root.context).isDownloading(galleryID))
holder.binding.root.context.getString(android.R.string.cancel)
else
holder.binding.root.context.getString(R.string.main_download)
}
override fun onClose(layout: SwipeLayout?) {}
override fun onHandRelease(layout: SwipeLayout?, xvel: Float, yvel: Float) {}
override fun onOpen(layout: SwipeLayout?) {}
override fun onStartClose(layout: SwipeLayout?) {}
override fun onUpdate(layout: SwipeLayout?, leftOffset: Int, topOffset: Int) {}
})
}
}
override fun getItemCount() = galleries.size
override fun getSwipeLayoutResourceId(position: Int) = R.id.swipe_layout
}

View File

@@ -0,0 +1,250 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.adapters
import android.content.Context
import android.graphics.drawable.Animatable
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.facebook.drawee.backends.pipeline.Fresco
import com.facebook.drawee.controller.BaseControllerListener
import com.facebook.drawee.drawable.ScalingUtils
import com.facebook.drawee.interfaces.DraweeController
import com.facebook.drawee.view.SimpleDraweeView
import com.facebook.imagepipeline.image.ImageInfo
import com.github.piasy.biv.view.BigImageView
import com.github.piasy.biv.view.ImageShownCallback
import com.github.piasy.biv.view.ImageViewFactory
import kotlinx.coroutines.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.ReaderItemBinding
import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.util.downloader.Cache
import java.io.File
import kotlin.math.roundToInt
class ReaderAdapter(
private val activity: ReaderActivity,
private val galleryID: Int
) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
var galleryInfo: GalleryInfo? = null
var isFullScreen = false
var onItemClickListener : (() -> (Unit))? = null
inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
init {
with (binding.image) {
setImageViewFactory(FrescoImageViewFactory().apply {
updateView = { imageInfo ->
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
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
}
}
}
override fun onThumbnailShown() {}
})
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)
cache = Cache.getInstance(itemView.context, galleryID)
if (!isFullScreen) {
binding.root.setBackgroundResource(R.drawable.reader_item_boundary)
binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = 0
dimensionRatio =
"${galleryInfo!!.files[position].width}:${galleryInfo!!.files[position].height}"
}
} 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 progress = activity.downloader?.progress?.get(galleryID)?.get(position)
if (progress?.isInfinite() == true && image != null) {
binding.progressGroup.visibility = View.INVISIBLE
binding.image.showImage(image.uri)
} else {
binding.progressGroup.visibility = View.VISIBLE
binding.readerItemProgressbar.progress =
if (progress?.isInfinite() == true)
100
else
progress?.roundToInt() ?: 0
clear()
CoroutineScope(Dispatchers.Main).launch {
delay(1000)
notifyItemChanged(position)
}
}
}
fun clear() {
binding.image.mainView.let {
when (it) {
is SubsamplingScaleImageView ->
it.recycle()
is SimpleDraweeView ->
it.controller = null
}
}
}
}
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

@@ -0,0 +1,52 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.adapters
import android.net.Uri
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.github.piasy.biv.view.BigImageView
import xyz.quaver.pupil.R
class ThumbnailAdapter(var thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailAdapter.ViewHolder>() {
class ViewHolder(val view: BigImageView) : RecyclerView.ViewHolder(view) {
fun clear() {
view.ssiv?.recycle()
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(BigImageView(parent.context).apply {
setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
})
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.view.showImage(Uri.parse(thumbnails[position]))
}
override fun getItemCount() = thumbnails.size
override fun onViewRecycled(holder: ViewHolder) {
holder.clear()
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.view.ViewGroup
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.min
class ThumbnailPageAdapter(private val thumbnails: List<String>) : RecyclerView.Adapter<ThumbnailPageAdapter.ViewHolder>() {
class ViewHolder(val view: RecyclerView) : RecyclerView.ViewHolder(view)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(RecyclerView(parent.context).apply {
val layoutManager = GridLayoutManager(parent.context, 3)
val adapter = ThumbnailAdapter(listOf())
this.layoutManager = layoutManager
this.adapter = adapter
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
})
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
(holder.view.adapter as ThumbnailAdapter).apply {
thumbnails = this@ThumbnailPageAdapter.thumbnails.slice(9*position until min(9*position+9, this@ThumbnailPageAdapter.thumbnails.size))
notifyDataSetChanged()
(holder.view.layoutManager as GridLayoutManager).scrollToPosition(8)
}
}
override fun getItemCount() = if (thumbnails.isEmpty()) 0 else thumbnails.size/9 + if (thumbnails.size%9 != 0) 1 else 0
}

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,292 @@
/*
* 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): String {
var retval = "b"
if (!base.isNullOrBlank())
retval = base
val b = 16
val r = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""")
val m = r.find(url) ?: return "a"
val g = m.groupValues.let { it[2] + it[1] }.toIntOrNull(b)
if (g != null) {
retval = (97 + gg.m(g)).toChar().toString() + retval
}
return retval
}
suspend fun urlFromUrl(url: String, base: String? = null): String {
return url.replace(
Regex("""//..?\.(?:gold-usergeneratedcontent\.net|hitomi\.la)/"""),
"//${subdomainFromURL(url, base)}.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 != '.' }
val dir = dir ?: "images"
return "https://a.gold-usergeneratedcontent.net/$dir/${fullPathFromHash(image.hash)}.$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)
suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean): String {
return urlFromUrlFromHash(galleryID, image, "webp", null, "a")
// 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

@@ -0,0 +1,96 @@
/*
* 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.receiver
import android.app.DownloadManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.Preferences
import java.io.File
class UpdateBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
context ?: return
when (intent?.action) {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
// Validate download
val downloadID: Long = Preferences["update_download_id"]
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2) != downloadID)
return
// Get target uri
val query = DownloadManager.Query()
.setFilterById(downloadID)
val uri = downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) {
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))?.let {
val uri = Uri.parse(it)
when (uri.scheme) {
"file" ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!))
else
uri
"content" -> uri
else -> null
}
}
} else
null
} ?: return
// Build Notification
val notificationManager = NotificationManagerCompat.from(context)
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
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
}, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else 0)
val notification = NotificationCompat.Builder(context, "update")
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(context.getText(R.string.update_download_completed))
.setContentText(context.getText(R.string.update_download_completed_description))
.setContentIntent(pendingIntent)
.build()
notificationManager.notify(R.id.notification_id_update, notification)
}
}
}
}

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

@@ -0,0 +1,444 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.services
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import okhttp3.ResponseBody
import okio.*
import xyz.quaver.pupil.*
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
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
class DownloadService : Service() {
data class Tag(val galleryID: Int, val index: Int, val startId: Int? = null)
//region Notification
private val notificationManager by lazy {
NotificationManagerCompat.from(this)
}
private val serviceNotification by lazy {
NotificationCompat.Builder(this, "downloader")
.setContentTitle(getString(R.string.downloader_running))
.setProgress(0, 0, false)
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
}
private val notification = ConcurrentHashMap<Int, NotificationCompat.Builder?>()
private fun initNotification(galleryID: Int) {
val intent = Intent(this, ReaderActivity::class.java)
.putExtra("galleryID", galleryID)
val pendingIntent = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(intent)
getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0)
}
val action =
NotificationCompat.Action.Builder(0, getText(android.R.string.cancel),
PendingIntent.getService(
this,
R.id.notification_download_cancel_action.normalizeID(),
Intent(this, DownloadService::class.java)
.putExtra(KEY_COMMAND, COMMAND_CANCEL)
.putExtra(KEY_ID, galleryID),
PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0),
).build()
notification[galleryID] = NotificationCompat.Builder(this, "download").apply {
setContentTitle(getString(R.string.reader_loading))
setContentText(getString(R.string.reader_notification_text))
setSmallIcon(R.drawable.ic_notification)
setContentIntent(pendingIntent)
addAction(action)
setProgress(0, 0, true)
setOngoing(true)
}
notify(galleryID)
}
@SuppressLint("RestrictedApi", "MissingPermission")
private fun notify(galleryID: Int) {
val max = progress[galleryID]?.size ?: 0
val progress = progress[galleryID]?.count { it == Float.POSITIVE_INFINITY } ?: 0
val notification = notification[galleryID] ?: return
if (!checkNotificationEnabled(this)) return
if (isCompleted(galleryID)) {
notification
.setContentText(getString(R.string.reader_notification_complete))
.setProgress(0, 0, false)
.setOngoing(false)
.mActions.clear()
notificationManager.cancel(galleryID)
} else
notification
.setProgress(max, progress, false)
.setContentText("$progress/$max")
if (DownloadManager.getInstance(this).getDownloadFolder(galleryID) != null || galleryID == priority)
notification.let { notificationManager.notify(galleryID, it.build()) }
else
notificationManager.cancel(galleryID)
}
//endregion
//region ProgressListener
@Suppress("UNCHECKED_CAST")
private val progressListener: ProgressListener = { (galleryID, index), bytesRead, contentLength, done ->
if (!done && progress[galleryID]?.get(index)?.isFinite() == true)
progress[galleryID]?.set(index, bytesRead * 100F / contentLength)
}
private 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.invoke(tag as Tag, totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
private val interceptor: PupilInterceptor = { chain ->
val request = chain.request()
var response = kotlin.runCatching {
chain.proceed(request)
}.getOrNull()
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()
}
if (response == null)
response = chain.proceed(request)
response!!.newBuilder()
.body(response.body()?.let {
ProgressResponseBody(request.tag(), it, progressListener)
}).build()
}
//endregion
//region Downloader
/**
* 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 = ConcurrentHashMap<Int, MutableList<Float>>()
var priority = 0
fun isCompleted(galleryID: Int) = progress[galleryID]?.toList()?.all { it == Float.POSITIVE_INFINITY } == true
private val callback = object: Callback {
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) {
val galleryID = (call.request().tag() as Tag).galleryID
}
}
override fun onResponse(call: Call, response: Response) {
val (galleryID, index, startId) = call.request().tag() as Tag
val ext = call.request().url().encodedPath().split('.').last()
CoroutineScope(Dispatchers.IO).launch {
runCatching {
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")
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)
notify(galleryID)
if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService)
.getDownloadFolder(galleryID) != null
)
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
startId?.let { stopSelf(it) }
}
}.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
}
}
}
}
fun cancel(startId: Int? = null) {
client.dispatcher().queuedCalls().filter {
it.request().tag() is Tag
}.forEach {
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
it.cancel()
}
client.dispatcher().runningCalls().filter {
it.request().tag() is Tag
}.forEach {
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
it.cancel()
}
progress.clear()
notification.clear()
notificationManager.cancelAll()
startId?.let { stopSelf(it) }
}
fun cancel(galleryID: Int, startId: Int? = null) {
client.dispatcher().queuedCalls().filter {
(it.request().tag() as? Tag)?.galleryID == galleryID
}.forEach {
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
it.cancel()
}
client.dispatcher().runningCalls().filter {
(it.request().tag() as? Tag)?.galleryID == galleryID
}.forEach {
(it.request().tag() as? Tag)?.startId?.let { stopSelf(it) }
it.cancel()
}
progress.remove(galleryID)
notification.remove(galleryID)
notificationManager.cancel(galleryID)
startId?.let { stopSelf(it) }
}
fun delete(galleryID: Int, startId: Int? = null) = CoroutineScope(Dispatchers.IO).launch {
cancel(galleryID)
DownloadManager.getInstance(this@DownloadService).deleteDownloadFolder(galleryID)
Cache.delete(this@DownloadService, galleryID)
startId?.let { stopSelf(it) }
}
fun download(galleryID: Int, priority: Boolean = false, startId: Int? = null): Job = CoroutineScope(Dispatchers.IO).launch {
if (DownloadManager.getInstance(this@DownloadService).isDownloading(galleryID))
return@launch
cleanCache(this@DownloadService)
val cache = Cache.getInstance(this@DownloadService, galleryID)
initNotification(galleryID)
val galleryInfo = cache.getGalleryInfo()
// Gallery doesn't exist
if (galleryInfo == null) {
delete(galleryID)
progress[galleryID] = mutableListOf()
return@launch
}
histories.add(galleryID)
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)
}
}
if (isCompleted(galleryID)) {
if (DownloadManager.getInstance(this@DownloadService).getDownloadFolder(galleryID) != null)
Cache.getInstance(this@DownloadService, galleryID).moveToDownload()
notificationManager.cancel(galleryID)
startId?.let { stopSelf(it) }
return@launch
}
notification[galleryID]?.setContentTitle(galleryInfo.title.ellipsize(32))
notify(galleryID)
val queued = mutableSetOf<Int>()
if (priority) {
client.dispatcher().queuedCalls().forEach {
val queuedID = (it.request().tag() as? Tag)?.galleryID ?: return@forEach
if (queued.add(queuedID))
cancel(queuedID)
}
}
galleryInfo.getRequestBuilders().forEachIndexed { index, it ->
if (progress[galleryID]?.get(index)?.isInfinite() == false) {
val request = it.tag(Tag(galleryID, index, startId)).build()
client.newCall(request).enqueue(callback)
}
}
queued.forEach { download(it) }
}
//endregion
companion object {
const val KEY_COMMAND = "COMMAND" // String
const val KEY_ID = "ID" // Int
const val KEY_PRIORITY = "PRIORITY" // Boolean
const val COMMAND_DOWNLOAD = "DOWNLOAD"
const val COMMAND_CANCEL = "CANCEL"
const val COMMAND_DELETE = "DELETE"
private fun command(context: Context, extras: Intent.() -> Unit) {
ContextCompat.startForegroundService(context, Intent(context, DownloadService::class.java).apply(extras))
}
fun download(context: Context, galleryID: Int, priority: Boolean = false) {
command(context) {
putExtra(KEY_COMMAND, COMMAND_DOWNLOAD)
putExtra(KEY_PRIORITY, priority)
putExtra(KEY_ID, galleryID)
}
}
fun cancel(context: Context, galleryID: Int? = null) {
command(context) {
putExtra(KEY_COMMAND, COMMAND_CANCEL)
galleryID?.let { putExtra(KEY_ID, it) }
}
}
fun delete(context: Context, galleryID: Int) {
command(context) {
putExtra(KEY_COMMAND, COMMAND_DELETE)
putExtra(KEY_ID, galleryID)
}
}
}
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())
} else {
startForeground(R.id.downloader_notification_id, serviceNotification.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
when (intent?.getStringExtra(KEY_COMMAND)) {
COMMAND_DOWNLOAD -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0)
download(it, intent.getBooleanExtra(KEY_PRIORITY, false), startId)
}
COMMAND_CANCEL -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0) cancel(it, startId) else cancel(startId = startId) }
COMMAND_DELETE -> intent.getIntExtra(KEY_ID, -1).let { if (it > 0) delete(it, startId) }
}
return START_NOT_STICKY
}
inner class Binder : android.os.Binder() {
val service = this@DownloadService
}
private val binder = Binder()
override fun onBind(p0: Intent?) = binder
override fun onCreate() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.TIRAMISU) {
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
}
override fun onDestroy() {
interceptors.remove(Tag::class)
}
}

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.types
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.pupil.hitomi.Suggestion
import xyz.quaver.pupil.util.translations
@Parcelize
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)
@IgnoredOnParcel
override val body =
if (translations[s] != null)
"${translations[s]} ($s)"
else
s
}
@Parcelize
class Suggestion(override val body: String) : SearchSuggestion
@Parcelize
class NoResultSuggestion(override val body: String) : SearchSuggestion
@Parcelize
class LoadingSuggestion(override val body: String) : SearchSuggestion
@Parcelize
@Suppress("PARCELABLE_PRIMARY_CONSTRUCTOR_IS_EMPTY")
class FavoriteHistorySwitch(override val body: String) : SearchSuggestion

View File

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

View File

@@ -0,0 +1,67 @@
/*
* 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
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.os.PersistableBundle
import android.view.WindowManager
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.LockManager
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.normalizeID
open class BaseActivity : AppCompatActivity() {
private var locked: Boolean = true
private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK)
locked = false
else
finish()
}
@CallSuper
override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
super.onCreate(savedInstanceState, persistentState)
locked = !LockManager(this).locks.isNullOrEmpty()
}
@CallSuper
override fun onResume() {
super.onResume()
if (Preferences["security_mode"])
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE)
else
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (locked)
lockLauncher.launch(Intent(this, LockActivity::class.java))
}
}

View File

@@ -0,0 +1,280 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui
import android.app.Activity
import android.app.AlertDialog
import android.os.Bundle
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import com.andrognito.patternlockview.PatternLockView
import com.google.android.material.snackbar.Snackbar
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.LockActivityBinding
import xyz.quaver.pupil.ui.fragment.PINLockFragment
import xyz.quaver.pupil.ui.fragment.PatternLockFragment
import xyz.quaver.pupil.util.Lock
import xyz.quaver.pupil.util.LockManager
import xyz.quaver.pupil.util.Preferences
private var lastUnlocked = 0L
class LockActivity : AppCompatActivity() {
private lateinit var lockManager: LockManager
private var mode: String? = null
private lateinit var binding: LockActivityBinding
private val patternLockFragment = PatternLockFragment().apply {
var lastPass = ""
onPatternDrawn = {
when(mode) {
null -> {
val result = lockManager.check(it)
if (result == true) {
lastUnlocked = System.currentTimeMillis()
setResult(Activity.RESULT_OK)
finish()
} else
binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
}
"add_lock" -> {
if (lastPass.isEmpty()) {
lastPass = it
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
} else {
if (lastPass == it) {
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
finish()
} else {
binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
lastPass = ""
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
}
}
}
}
}
}
private val pinLockFragment = PINLockFragment().apply {
var lastPass = ""
onPINEntered = {
when(mode) {
null -> {
val result = lockManager.check(it)
if (result == true) {
lastUnlocked = System.currentTimeMillis()
setResult(Activity.RESULT_OK)
finish()
} else {
binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
setAnimationListener(object: Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) {
binding.pinLockView.resetPinLockView()
binding.pinLockView.isEnabled = true
}
override fun onAnimationStart(animation: Animation?) {
binding.pinLockView.isEnabled = false
}
override fun onAnimationRepeat(animation: Animation?) {
// Do Nothing
}
})
})
}
}
"add_lock" -> {
if (lastPass.isEmpty()) {
lastPass = it
binding.pinLockView.resetPinLockView()
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
} else {
if (lastPass == it) {
LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it))
finish()
} else {
binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
setAnimationListener(object: Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) {
binding.pinLockView.resetPinLockView()
binding.pinLockView.isEnabled = true
}
override fun onAnimationStart(animation: Animation?) {
binding.pinLockView.isEnabled = false
}
override fun onAnimationRepeat(animation: Animation?) {
// Do Nothing
}
})
})
lastPass = ""
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
}
}
}
}
}
}
private fun showBiometricPrompt() {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(getText(R.string.settings_lock_fingerprint_prompt))
.setSubtitle(getText(R.string.settings_lock_fingerprint_prompt_subtitle))
.setNegativeButtonText(getText(android.R.string.cancel))
.setConfirmationRequired(false)
.build()
val biometricPrompt = BiometricPrompt(this, ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
lastUnlocked = System.currentTimeMillis()
setResult(RESULT_OK)
finish()
return
}
})
// Displays the "log in" prompt.
biometricPrompt.authenticate(promptInfo)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = LockActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
lockManager = try {
LockManager(this)
} catch (e: Exception) {
AlertDialog.Builder(this).apply {
setTitle(R.string.warning)
setMessage(R.string.lock_corrupted)
setPositiveButton(android.R.string.ok) { _, _ ->
finish()
}
}.show()
return
}
mode = intent.getStringExtra("mode")
val force = intent.getBooleanExtra("force", false)
when(mode) {
null -> {
if (lockManager.isEmpty()) {
setResult(RESULT_OK)
finish()
return
}
if (System.currentTimeMillis() - lastUnlocked < 5*60*1000 && !force) {
lastUnlocked = System.currentTimeMillis()
setResult(RESULT_OK)
finish()
return
}
if (
Preferences["lock_fingerprint"]
&& BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
) {
binding.fingerprintBtn.apply {
isEnabled = true
setOnClickListener {
showBiometricPrompt()
}
}
showBiometricPrompt()
}
binding.patternBtn.apply {
isEnabled = lockManager.contains(Lock.Type.PATTERN)
setOnClickListener {
supportFragmentManager.beginTransaction().replace(
R.id.lock_content, patternLockFragment
).commit()
}
}
binding.pinBtn.apply {
isEnabled = lockManager.contains(Lock.Type.PIN)
setOnClickListener {
supportFragmentManager.beginTransaction().replace(
R.id.lock_content, pinLockFragment
).commit()
}
}
binding.passwordBtn.isEnabled = false
when (lockManager.locks!!.first().type) {
Lock.Type.PIN -> {
supportFragmentManager.beginTransaction().add(
R.id.lock_content, pinLockFragment
).commit()
}
Lock.Type.PATTERN -> {
supportFragmentManager.beginTransaction().add(
R.id.lock_content, patternLockFragment
).commit()
}
else -> return
}
}
"add_lock" -> {
binding.patternBtn.isEnabled = false
binding.pinBtn.isEnabled = false
binding.fingerprintBtn.isEnabled = false
binding.passwordBtn.isEnabled = false
when(intent.getStringExtra("type")!!) {
"pattern" -> {
binding.patternBtn.isEnabled = true
supportFragmentManager.beginTransaction().add(
R.id.lock_content, patternLockFragment
).commit()
}
"pin" -> {
binding.pinBtn.isEnabled = true
supportFragmentManager.beginTransaction().add(
R.id.lock_content, pinLockFragment
).commit()
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,619 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.*
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.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.google.android.material.snackbar.Snackbar
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.mlkit.vision.face.Face
import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
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.services.DownloadService
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.DownloadManager
import xyz.quaver.pupil.util.requestNotificationPermission
import xyz.quaver.pupil.util.showNotificationPermissionExplanationDialog
import xyz.quaver.pupil.util.startCamera
class ReaderActivity : BaseActivity() {
private var galleryID = 0
private var currentPage = 0
private var isScroll = true
private var isFullscreen = false
set(value) {
field = value
(binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value
}
private lateinit var cache: Cache
var downloader: DownloadService? = null
private val conn = object: ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
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?) {
downloader = null
}
}
private val snapHelper = PagerSnapHelper()
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?) {
super.onCreate(savedInstanceState)
binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
title = getString(R.string.reader_loading)
supportActionBar?.setDisplayHomeAsUpEnabled(false)
handleIntent(intent)
cache = Cache.getInstance(this, galleryID)
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID)
if (galleryID == 0) {
onBackPressed()
return
}
initDownloadListener()
initView()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
if (intent.action == Intent.ACTION_VIEW) {
val uri = intent.data
val lastPathSegment = uri?.lastPathSegment
if (uri != null && lastPathSegment != null) {
galleryID = if (uri.host?.endsWith("hasha.in") == true) {
lastPathSegment?.toInt() ?: 0
} else when (uri.host) {
"hitomi.la" ->
Regex("([0-9]+).html").find(lastPathSegment)?.groupValues?.get(1)?.toIntOrNull() ?: 0
"e-hentai.org" -> uri.pathSegments[1].toInt()
else -> 0
}
}
} else {
galleryID = intent.getIntExtra("galleryID", 0)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.reader, menu)
with(menu.findItem(R.id.reader_menu_favorite)) {
if (favorites.contains(galleryID))
(icon as Animatable).start()
}
this.menu = menu
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when(item.itemId) {
R.id.reader_menu_page_indicator -> {
// TODO: Switch to DialogFragment
val binding = NumberpickerDialogBinding.inflate(layoutInflater, binding.root, false)
with(binding.numberPicker) {
minValue = 1
maxValue = cache.metadata.galleryInfo?.files?.size ?: 0
value = currentPage
}
val dialog = AlertDialog.Builder(this).apply {
setView(binding.root)
}.create()
binding.okButton.setOnClickListener {
(this@ReaderActivity.binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(binding.numberPicker.value-1, 0)
dialog.dismiss()
}
dialog.show()
}
R.id.reader_menu_favorite -> {
val id = galleryID
val favorite = menu?.findItem(R.id.reader_menu_favorite) ?: return true
if (favorites.contains(id)) {
favorites.remove(id)
favorite.icon = AnimatedVectorDrawableCompat.create(this, R.drawable.avd_star)
} else {
favorites.add(id)
(favorite.icon as Animatable).start()
}
}
}
return true
}
override fun onResume() {
super.onResume()
bindService(Intent(this, DownloadService::class.java), conn, BIND_AUTO_CREATE)
if (cameraEnabled)
startCamera(this, cameraCallback)
}
override fun onPause() {
super.onPause()
closeCamera()
if (downloader != null)
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() {
if (isScroll and !isFullscreen)
super.onBackPressed()
if (isFullscreen) {
isFullscreen = false
fullscreen(false)
}
if (!isScroll) {
isScroll = true
scrollMode(true)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
//currentPage is 1-based
return when(keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-2, 0)
true
}
KeyEvent.KEYCODE_VOLUME_DOWN -> {
(binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0)
true
}
else -> super.onKeyDown(keyCode, event)
}
}
private var update = true
private fun initDownloadListener() {
CoroutineScope(Dispatchers.Main).launch {
while (update) {
delay(1000)
val downloader = downloader ?: continue
if (!downloader.progress.containsKey(galleryID)) //loading
continue
if (downloader.progress[galleryID]?.isEmpty() == true) { //Gallery not found
update = false
Snackbar
.make(binding.root, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
.show()
return@launch
}
binding.downloadProgressbar.max = binding.recyclerview.adapter?.itemCount ?: 0
binding.downloadProgressbar.progress =
downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
if (title == getString(R.string.reader_loading)) {
val galleryInfo = cache.metadata.galleryInfo
if (galleryInfo != null) {
with(binding.recyclerview.adapter as ReaderAdapter) {
this.galleryInfo = galleryInfo
notifyDataSetChanged()
}
title = galleryInfo.title
menu?.findItem(R.id.reader_menu_page_indicator)?.title =
"$currentPage/${galleryInfo.files.size}"
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.hitomi)
}
}
if (downloader.isCompleted(galleryID)) { //Download finished
binding.downloadProgressbar.visibility = View.GONE
animateDownloadFAB(false)
}
}
}
}
private fun initView() {
with(binding.recyclerview) {
adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
onItemClickListener = {
if (isScroll) {
isScroll = false
isFullscreen = true
scrollMode(false)
fullscreen(true)
} else {
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing
}
}
}
addOnScrollListener(object: RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
if (dy < 0)
binding.fab.showMenuButton(true)
else if (dy > 0)
binding.fab.hideMenuButton(true)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
if (layoutManager.findFirstVisibleItemPosition() == -1)
return
currentPage = layoutManager.findFirstVisibleItemPosition()+1
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}"
}
})
}
with(binding.downloadFab) {
animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button
setOnClickListener {
requestNotificationPermission(
this@ReaderActivity,
requestNotificationPermssionLauncher
) {
val downloadManager = DownloadManager.getInstance(this@ReaderActivity)
if (downloadManager.isDownloading(galleryID)) {
downloadManager.deleteDownloadFolder(galleryID)
animateDownloadFAB(false)
} else {
downloadManager.addDownloadFolder(galleryID)
DownloadService.download(context, galleryID, true)
animateDownloadFAB(true)
}
}
}
}
with(binding.retryFab) {
setImageResource(R.drawable.refresh)
setOnClickListener {
DownloadService.download(context, galleryID)
}
}
with(binding.autoFab) {
setImageResource(R.drawable.eye_white)
setOnClickListener {
when {
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
toggleCamera()
}
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 ->
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}
with(binding.fullscreenFab) {
setImageResource(R.drawable.ic_fullscreen)
setOnClickListener {
isFullscreen = true
fullscreen(isFullscreen)
binding.fab.close(true)
}
}
}
private fun fullscreen(isFullscreen: Boolean) {
with(window.attributes) {
if (isFullscreen) {
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
supportActionBar?.hide()
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 {
flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
supportActionBar?.show()
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
}
binding.recyclerview.adapter = binding.recyclerview.adapter // Force to redraw
}
private fun scrollMode(isScroll: Boolean) {
if (isScroll) {
snapHelper.attachToRecyclerView(null)
binding.recyclerview.layoutManager = LinearLayoutManager(this)
} else {
snapHelper.attachToRecyclerView(binding.recyclerview)
binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) {
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
extraLayoutSpace.fill(600)
}
}
}
(binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
}
private fun animateDownloadFAB(animate: Boolean) {
with(binding.downloadFab) {
if (animate) {
val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading)
icon?.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable?) {
if (downloader?.isCompleted(galleryID) == true) // If download is finished, stop animating
post {
setImageResource(R.drawable.ic_download)
labelText = getString(R.string.reader_fab_download_cancel)
}
else // Or continue animate
post {
icon.start()
labelText = getString(R.string.reader_fab_download_cancel)
}
}
})
setImageDrawable(icon)
icon?.start()
} else {
setImageResource(R.drawable.ic_download)
labelText = getString(R.string.reader_fab_download)
}
}
}
val cameraCallback: (List<Face>) -> Unit = callback@{ faces ->
binding.eyeCard.dot.let {
it.visibility = View.VISIBLE
CoroutineScope(Dispatchers.Main).launch {
delay(50)
it.visibility = View.INVISIBLE
}
}
if (faces.size != 1)
ContextCompat.getDrawable(this, R.drawable.eye_off).let {
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

@@ -0,0 +1,45 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui
import android.os.Bundle
import android.view.MenuItem
import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.fragment.SettingsFragment
class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.settings_activity)
supportFragmentManager
.beginTransaction()
.replace(R.id.settings, SettingsFragment())
.commit()
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> onBackPressed()
}
return true
}
}

View File

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

View File

@@ -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

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

View File

@@ -0,0 +1,171 @@
/*
* 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.app.Dialog
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.DefaultQueryDialogBinding
import xyz.quaver.pupil.types.Tags
import xyz.quaver.pupil.util.Preferences
class DefaultQueryDialog : DialogFragment() {
private val languages: Map<String, String> by lazy {
requireContext().resources.getStringArray(R.array.languages).map {
it.split("|").let { split ->
Pair(split[0], split[1])
}
}.toMap()
}
private val reverseLanguages: Map<String, String> by lazy {
languages.entries.associate { (k, v) -> v to k }
}
private val excludeBL = "-male:yaoi"
private val excludeGuro = listOf("-female:guro", "-male:guro")
private val excludeLoli = listOf("-female:loli", "-male:shota")
var onPositiveButtonClickListener : ((Tags) -> (Unit))? = null
private var _binding: DefaultQueryDialogBinding? = null
private val binding get() = _binding!!
private fun initView() {
val tags = Tags.parse(
Preferences["default_query"]
)
with(binding.languageSelector) {
adapter =
ArrayAdapter(
context,
android.R.layout.simple_spinner_dropdown_item,
arrayListOf(
context.getString(R.string.default_query_dialog_language_selector_none)
).apply {
addAll(languages.values)
}
)
if (tags.any { it.area == "language" && !it.isNegative }) {
val tag = languages[tags.first { it.area == "language" }.tag]
if (tag != null) {
setSelection(
@Suppress("UNCHECKED_CAST")
(adapter as ArrayAdapter<String>).getPosition(tag)
)
tags.removeByArea("language", false)
}
}
}
with(binding.BLCheckbox) {
isChecked = tags.contains(excludeBL)
if (tags.contains(excludeBL))
tags.remove(excludeBL)
}
with(binding.guroCheckbox) {
isChecked = excludeGuro.all { tags.contains(it) }
if (excludeGuro.all { tags.contains(it) })
excludeGuro.forEach {
tags.remove(it)
}
}
with(binding.loliCheckbox) {
isChecked = excludeLoli.all { tags.contains(it) }
if (excludeLoli.all { tags.contains(it) })
excludeLoli.forEach {
tags.remove(it)
}
}
with(binding.edittext) {
setText(tags.toString(), android.widget.TextView.BufferType.EDITABLE)
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int
) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
s ?: return
if (s.any { it.isUpperCase() })
s.replace(
0,
s.length,
s.toString().lowercase()
)
}
})
}
}
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

@@ -0,0 +1,85 @@
/*
* 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.app.Dialog
import android.os.Bundle
import android.view.ViewGroup
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.DialogFragment
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.DownloadFolderNameDialogBinding
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.formatDownloadFolder
import xyz.quaver.pupil.util.formatDownloadFolderTest
import xyz.quaver.pupil.util.formatMap
class DownloadFolderNameDialogFragment : DialogFragment() {
private var _binding: DownloadFolderNameDialogBinding? = null
private val binding get() = _binding!!
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = DownloadFolderNameDialogBinding.inflate(layoutInflater)
initView()
return Dialog(requireContext()).apply {
setContentView(binding.root)
window?.attributes?.width = ViewGroup.LayoutParams.MATCH_PARENT
}
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
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) {
Snackbar.make(binding.root, R.string.settings_invalid_download_folder_name, Snackbar.LENGTH_SHORT).show()
return@setOnClickListener
}
Preferences["download_folder_name"] = binding.edittext.text.toString()
dismiss()
}
}
}

View File

@@ -0,0 +1,156 @@
/*
* 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.app.Activity
import android.app.Dialog
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.fragment.app.DialogFragment
import com.google.android.material.snackbar.Snackbar
import xyz.quaver.io.FileX
import xyz.quaver.io.util.toFile
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.byteToString
import xyz.quaver.pupil.util.downloader.DownloadManager
import java.io.File
class DownloadLocationDialogFragment : DialogFragment() {
private var _binding: DownloadLocationDialogBinding? = null
private val binding get() = _binding!!
private val entries = mutableMapOf<File?, DownloadLocationItemBinding>()
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 ->
dir ?: return@forEachIndexed
DownloadLocationItemBinding.inflate(layoutInflater, binding.root, true).apply {
locationType.text = requireContext().getString(when (index) {
0 -> R.string.settings_download_folder_internal
else -> R.string.settings_download_folder_removable
})
locationAvailable.text = requireContext().getString(
R.string.settings_download_folder_available,
byteToString(dir.freeSpace)
)
root.setOnClickListener {
entries.values.forEach { entry ->
entry.button.isChecked = false
}
button.performClick()
Preferences["download_folder"] = dir.toUri().toString()
}
entries[dir] = this
}
}
DownloadLocationItemBinding.inflate(layoutInflater, binding.root, true).apply {
locationType.text = requireContext().getString(R.string.settings_download_folder_custom)
root.setOnClickListener {
entries.values.forEach { entry ->
entry.button.isChecked = false
}
button.performClick()
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra("android.content.extra.SHOW_ADVANCED", true)
}
requestDownloadFolderLauncher.launch(intent)
}
entries[null] = this
}
val downloadFolder = DownloadManager.getInstance(requireContext()).downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = DownloadLocationDialogBinding.inflate(layoutInflater)
initView()
return AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.settings_download_folder)
setView(binding.root)
setPositiveButton(requireContext().getText(android.R.string.ok)) { _, _ ->
if (Preferences["download_folder", ""].isEmpty())
Preferences["download_folder"] = context.getExternalFilesDir(null)?.toUri()?.toString() ?: ""
}
isCancelable = false
}.create()
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
}

View File

@@ -0,0 +1,255 @@
/*
* 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.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout.LayoutParams
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.quaver.pupil.hitomi.Gallery
import xyz.quaver.pupil.hitomi.getGallery
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.GalleryBlockAdapter
import xyz.quaver.pupil.adapters.ThumbnailPageAdapter
import xyz.quaver.pupil.databinding.*
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.ui.view.TagChip
import xyz.quaver.pupil.util.ItemClickSupport
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.wordCapitalize
import java.util.*
import kotlin.collections.ArrayList
class GalleryDialog(context: Context, private val galleryID: Int) : AlertDialog(context) {
val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>()
private lateinit var binding: GalleryDialogBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = GalleryDialogBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.attributes.apply {
this ?: return@apply
width = LayoutParams.MATCH_PARENT
height = LayoutParams.MATCH_PARENT
}
with(binding.fab) {
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.arrow_right))
setOnClickListener {
context.startActivity(Intent(context, ReaderActivity::class.java).apply {
putExtra("galleryID", galleryID)
})
}
}
CoroutineScope(Dispatchers.IO).launch {
try {
val gallery = getGallery(galleryID)
launch (Dispatchers.Main) {
binding.progressbar.visibility = View.GONE
binding.title.text = gallery.title
binding.artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() }
with(binding.type) {
text = gallery.type.wordCapitalize()
setOnClickListener {
gallery.type.let {
when (it) {
"artist CG" -> "artistcg"
"game CG" -> "gamecg"
else -> it
}
}.let {
onChipClickedHandler.forEach { handler ->
handler.invoke(Tag("type", it))
}
}
}
}
binding.cover.showImage(Uri.parse(gallery.cover))
addDetails(gallery)
addThumbnails(gallery)
addRelated(gallery)
}
} catch (e: Exception) {
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) {
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
type.setText(R.string.gallery_details)
listOf(
R.string.gallery_artists,
R.string.gallery_groups,
R.string.gallery_language,
R.string.gallery_series,
R.string.gallery_characters,
R.string.gallery_tags
).zip(
listOf(
gallery.artists.map { Tag("artist", it) },
gallery.groups.map { Tag("group", it) },
listOf(gallery.language).map { Tag("language", it) },
gallery.series.map { Tag("series", it) },
gallery.characters.map { Tag("character", it) },
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 ->
when {
tag.area != null -> tag
else -> Tag("tag", it)
}
}
}
)
).filter {
(_, content) -> content.isNotEmpty()
}.forEach { (title, content) ->
GalleryDialogTagsBinding.inflate(layoutInflater, contents, true).apply {
type.setText(title)
content.forEach { tag ->
tags.addView(
TagChip(context, tag).apply {
setOnClickListener {
onChipClickedHandler.forEach { handler ->
handler.invoke(tag)
}
}
}
)
}
}
}
}
}
private fun addThumbnails(gallery: Gallery) {
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
type.setText(R.string.gallery_thumbnails)
val pager = ViewPager2(context).apply {
adapter = ThumbnailPageAdapter(gallery.thumbnails)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
contents.addView(
pager,
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
)
// TODO: Change to direct allocation
GalleryDialogDotindicatorBinding.inflate(layoutInflater, contents, true).apply {
dotindicator.setViewPager2(pager)
}
}
}
private fun addRelated(gallery: Gallery) {
val galleries = mutableListOf<Int>()
val adapter = GalleryBlockAdapter(galleries).apply {
onChipClickedHandler.add { tag ->
this@GalleryDialog.onChipClickedHandler.forEach { handler ->
handler.invoke(tag)
}
}
}
GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
type.setText(R.string.gallery_related)
contents.addView(RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context)
this.adapter = adapter
ItemClickSupport.addTo(this).apply {
onItemClickListener = { _, position, _ ->
context.startActivity(Intent(context, ReaderActivity::class.java).apply {
putExtra("galleryID", galleries[position])
})
}
onItemLongClickListener = { _, position, _ ->
GalleryDialog(context, galleries[position]).apply {
onChipClickedHandler.add { tag ->
this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) }
}
}.show()
true
}
}
})
CoroutineScope(Dispatchers.IO).launch {
gallery.related.forEach { galleryID ->
Cache.getInstance(context, galleryID).getGalleryBlock()?.let {
galleries.add(galleryID)
}
}
withContext(Dispatchers.Main) {
adapter.notifyDataSetChanged()
}
}
}
}
}

View File

@@ -0,0 +1,133 @@
/*
* 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.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.clientBuilder
import xyz.quaver.pupil.clientHolder
import xyz.quaver.pupil.databinding.ProxyDialogBinding
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.ProxyInfo
import xyz.quaver.pupil.util.getProxyInfo
import xyz.quaver.pupil.util.proxyInfo
import java.net.Proxy
class ProxyDialogFragment : DialogFragment() {
private var _binding: ProxyDialogBinding? = null
private val binding get() = _binding!!
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = ProxyDialogBinding.inflate(layoutInflater)
initView()
return AlertDialog.Builder(requireContext()).apply {
setView(binding.root)
}.create()
}
private fun initView() {
val proxyInfo = getProxyInfo()
val enabler = { enable: Boolean ->
binding.addr.isEnabled = enable
binding.port.isEnabled = enable
binding.username.isEnabled = enable
binding.password.isEnabled = enable
if (!enable) {
binding.addr.text = null
binding.port.text = null
binding.username.text = null
binding.password.text = null
}
}
with(binding.typeSelector) {
adapter = ArrayAdapter(
context,
android.R.layout.simple_spinner_dropdown_item,
context.resources.getStringArray(R.array.proxy_type)
)
setSelection(proxyInfo.type.ordinal)
onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
enabler.invoke(position != 0)
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
binding.addr.setText(proxyInfo.host)
binding.port.setText(proxyInfo.port?.toString())
binding.username.setText(proxyInfo.username)
binding.password.setText(proxyInfo.password)
enabler.invoke(proxyInfo.type != Proxy.Type.DIRECT)
binding.cancelButton.setOnClickListener {
dismiss()
}
binding.okButton.setOnClickListener {
val type = Proxy.Type.values()[binding.typeSelector.selectedItemPosition]
val addr = binding.addr.text?.toString()
val port = binding.port.text?.toString()?.toIntOrNull()
val username = binding.username.text?.toString()
val password = binding.password.text?.toString()
if (type != Proxy.Type.DIRECT) {
if (addr == null || addr.isEmpty())
binding.addr.error = requireContext().getText(R.string.proxy_dialog_error)
if (port == null)
binding.port.error = requireContext().getText(R.string.proxy_dialog_error)
if (addr == null || addr.isEmpty() || port == null)
return@setOnClickListener
}
ProxyInfo(type, addr, port, username, password).let {
Preferences["proxy"] = Json.encodeToString(it)
clientBuilder
.proxyInfo(it)
clientHolder = null
client
}
dismiss()
}
}
}

View File

@@ -0,0 +1,146 @@
/*
* 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.fragment
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreferenceCompat
import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.LockActivity
import xyz.quaver.pupil.util.Lock
import xyz.quaver.pupil.util.LockManager
import xyz.quaver.pupil.util.Preferences
class LockSettingsFragment : PreferenceFragmentCompat() {
override fun onResume() {
super.onResume()
val lockManager = LockManager(requireContext())
findPreference<Preference>("lock_pattern")?.summary =
if (lockManager.contains(Lock.Type.PATTERN))
getString(R.string.settings_lock_enabled)
else
""
findPreference<Preference>("lock_pin")?.summary =
if (lockManager.contains(Lock.Type.PIN))
getString(R.string.settings_lock_enabled)
else
""
if (lockManager.isEmpty()) {
(findPreference<Preference>("lock_fingerprint") as SwitchPreferenceCompat).isChecked = false
Preferences["lock_fingerprint"] = false
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.lock_preferences, rootKey)
with(findPreference<Preference>("lock_pattern")) {
this!!
if (LockManager(requireContext()).contains(Lock.Type.PATTERN))
summary = getString(R.string.settings_lock_enabled)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
val lockManager = LockManager(requireContext())
if (lockManager.contains(Lock.Type.PATTERN)) {
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_lock_remove_message)
setPositiveButton(android.R.string.ok) { _, _ ->
lockManager.remove(Lock.Type.PATTERN)
onResume()
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
} else {
val intent = Intent(requireContext(), LockActivity::class.java).apply {
putExtra("mode", "add_lock")
putExtra("type", "pattern")
}
startActivity(intent)
}
true
}
}
with(findPreference<Preference>("lock_pin")) {
this!!
if (LockManager(requireContext()).contains(Lock.Type.PIN))
summary = getString(R.string.settings_lock_enabled)
onPreferenceClickListener = Preference.OnPreferenceClickListener {
val lockManager = LockManager(requireContext())
if (lockManager.contains(Lock.Type.PIN)) {
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_lock_remove_message)
setPositiveButton(android.R.string.ok) { _, _ ->
lockManager.remove(Lock.Type.PIN)
onResume()
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
} else {
val intent = Intent(requireContext(), LockActivity::class.java).apply {
putExtra("mode", "add_lock")
putExtra("type", "pin")
}
startActivity(intent)
}
true
}
}
with(findPreference<Preference>("lock_fingerprint")) {
this!!
setOnPreferenceChangeListener { _, newValue ->
this as SwitchPreferenceCompat
if (newValue == true && LockManager(requireContext()).isEmpty()) {
isChecked = false
Toast.makeText(requireContext(), R.string.settings_lock_fingerprint_without_lock, Toast.LENGTH_SHORT).show()
} else
isChecked = newValue as Boolean
false
}
}
}
}

View File

@@ -0,0 +1,140 @@
/*
* 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.fragment
import android.app.Activity
import android.content.Intent
import android.content.res.Resources
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
import android.util.Log
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.DrawableCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
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 xyz.quaver.io.FileX
import xyz.quaver.io.util.readText
import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.get
import xyz.quaver.pupil.util.restore
import java.io.File
import java.io.IOException
import kotlin.math.roundToInt
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?) {
setPreferencesFromResource(R.xml.manage_favorites_preferences, rootKey)
initPreferences()
}
private fun initPreferences() {
val context = context ?: return
findPreference<Preference>("backup")?.setOnPreferenceClickListener {
val favorites = runCatching {
Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites.json").readText())
}.getOrNull()
val favoriteTags = kotlin.runCatching {
Json.parseToJsonElement(File(ContextCompat.getDataDir(context), "favorites_tags.json").readText())
}.getOrNull()
val favoriteJson = buildJsonObject {
favorites?.let {
put("favorites", it)
}
favoriteTags?.let {
put("favorite_tags", it)
}
}
val backupFile = File(context.filesDir, "pupil-backup.json").also {
it.writeText(favoriteJson.toString())
}
Intent(Intent.ACTION_SEND).apply {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.provider", backupFile)
setDataAndType(uri, "application/json")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
putExtra(Intent.EXTRA_STREAM, uri)
}.let {
context.startActivity(Intent.createChooser(it, getString(R.string.settings_backup_share)))
}
true
}
findPreference<Preference>("restore")?.setOnPreferenceClickListener {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
requestBackupFileLauncher.launch(intent)
true
}
}
}

View File

@@ -0,0 +1,263 @@
/*
* 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.fragment
import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
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.SAFileX
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.histories
import xyz.quaver.pupil.hitomi.json
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.Metadata
import java.io.File
import kotlin.math.roundToInt
class ManageStorageFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClickListener {
private var job: Job? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.manage_storage_preferences, rootKey)
initPreferences()
}
override fun onPreferenceClick(preference: Preference): Boolean {
val context = context ?: return false
with(preference) {
when (key) {
"delete_cache" -> {
val dir = File(context.cacheDir, "imageCache")
AlertDialog.Builder(context).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_clear_cache_alert_message)
setPositiveButton(android.R.string.ok) { _, _ ->
if (dir.exists())
dir.deleteRecursively()
Cache.instances.clear()
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
CoroutineScope(Dispatchers.IO).launch {
var size = 0L
dir.walk().forEach {
size += it.length()
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
}
}
}
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.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" -> {
val dir = DownloadManager.getInstance(context).downloadFolder
AlertDialog.Builder(context).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_clear_downloads_alert_message)
setPositiveButton(android.R.string.ok) { _, _ ->
CoroutineScope(Dispatchers.IO).launch {
job?.cancel()
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage_loading)
}
if (dir.exists())
dir.listFiles()?.forEach {
when (it) {
is FileX -> it.deleteRecursively()
else -> it.deleteRecursively()
}
}
job = launch {
var size = 0L
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
}
dir.walk().forEach {
size += it.length()
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
}
}
}
}
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
}
"clear_history" -> {
AlertDialog.Builder(context).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_clear_history_alert_message)
setPositiveButton(android.R.string.ok) { _, _ ->
histories.clear()
summary = context.getString(R.string.settings_clear_history_summary, histories.size)
}
setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show()
}
else -> return false
}
}
return true
}
private fun initPreferences() {
val context = context ?: return
with(findPreference<Preference>("delete_cache")) {
this ?: return@with
val dir = File(context.cacheDir, "imageCache")
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
CoroutineScope(Dispatchers.IO).launch {
var size = 0L
dir.walk().forEach {
size += it.length()
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
}
}
}
onPreferenceClickListener = this@ManageStorageFragment
}
with(findPreference<Preference>("delete_downloads")) {
this ?: return@with
val dir = DownloadManager.getInstance(context).downloadFolder
summary = context.getString(R.string.settings_storage_usage, byteToString(0))
job?.cancel()
job = CoroutineScope(Dispatchers.IO).launch {
var size = 0L
dir.walk().forEach {
launch(Dispatchers.Main) {
summary = context.getString(R.string.settings_storage_usage, byteToString(size))
}
size += it.length()
}
}
onPreferenceClickListener = this@ManageStorageFragment
}
with(findPreference<Preference>("clear_history")) {
this ?: return@with
summary = context.getString(R.string.settings_clear_history_summary, histories.size)
onPreferenceClickListener = this@ManageStorageFragment
}
with(findPreference<Preference>("recover_downloads")) {
this ?: return@with
onPreferenceClickListener = this@ManageStorageFragment
}
}
override fun onDestroy() {
job?.cancel()
super.onDestroy()
}
}

View File

@@ -0,0 +1,57 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.andrognito.pinlockview.PinLockListener
import xyz.quaver.pupil.databinding.PinLockFragmentBinding
class PINLockFragment : Fragment() {
private var _binding: PinLockFragmentBinding? = null
val binding get() = _binding!!
var onPINEntered: ((String) -> Unit)? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = PinLockFragmentBinding.inflate(inflater, container, false)
binding.pinLockView.attachIndicatorDots(binding.indicatorDots)
binding.pinLockView.setPinLockListener(object: PinLockListener {
override fun onComplete(p0: String?) {
onPINEntered?.invoke(p0 ?: "")
}
override fun onEmpty() {}
override fun onPinChange(p0: Int, p1: String?) {}
})
return binding.root
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
}

View File

@@ -0,0 +1,61 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.andrognito.patternlockview.PatternLockView
import com.andrognito.patternlockview.listener.PatternLockViewListener
import com.andrognito.patternlockview.utils.PatternLockUtils
import xyz.quaver.pupil.databinding.PatternLockFragmentBinding
class PatternLockFragment : Fragment() {
private var _binding: PatternLockFragmentBinding? = null
val binding get() = _binding!!
var onPatternDrawn: ((String) -> Unit)? = null
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = PatternLockFragmentBinding.inflate(inflater, container, false)
binding.patternLockView.addPatternLockListener(object: PatternLockViewListener {
override fun onComplete(pattern: MutableList<PatternLockView.Dot>?) {
val password = PatternLockUtils.patternToMD5(binding.patternLockView, pattern)
onPatternDrawn?.invoke(password)
}
override fun onCleared() {}
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) {}
override fun onStarted() {}
})
return binding.root
}
override fun onDestroy() {
super.onDestroy()
_binding = null
}
}

View File

@@ -0,0 +1,318 @@
/*
* 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.fragment
import android.app.Activity
import android.content.*
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.preference.*
import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.crashlytics.internal.model.CrashlyticsReport
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.Dispatcher
import xyz.quaver.io.FileX
import xyz.quaver.io.util.getChild
import xyz.quaver.pupil.R
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.SettingsActivity
import xyz.quaver.pupil.ui.TransferActivity
import xyz.quaver.pupil.ui.dialog.*
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.DownloadManager
import java.util.*
import java.util.concurrent.Executors
class SettingsFragment :
PreferenceFragmentCompat(),
Preference.OnPreferenceClickListener,
Preference.OnPreferenceChangeListener,
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() {
super.onResume()
val lockManager = LockManager(requireContext())
findPreference<Preference>("app_lock")?.summary = if (lockManager.locks.isNullOrEmpty()) {
getString(R.string.settings_lock_none)
} else {
lockManager.locks?.joinToString(", ") {
when(it.type) {
Lock.Type.PATTERN -> getString(R.string.settings_lock_pattern)
Lock.Type.PIN -> getString(R.string.settings_lock_pin)
Lock.Type.PASSWORD -> getString(R.string.settings_lock_password)
}
}
}
}
override fun onPreferenceClick(preference: Preference): Boolean {
with (preference) {
when (key) {
"app_version" -> {
checkUpdate(activity as SettingsActivity, true)
}
"download_folder" -> {
DownloadLocationDialogFragment().show(parentFragmentManager, "Download Location Dialog")
}
"default_query" -> {
DefaultQueryDialog().apply {
onPositiveButtonClickListener = { newTags ->
Preferences["default_query"] = newTags.toString()
summary = newTags.toString()
}
}.show(parentFragmentManager, "Default Query Dialog")
}
"app_lock" -> {
val intent = Intent(requireContext(), LockActivity::class.java).apply {
putExtra("force", true)
}
lockLauncher.launch(intent)
}
"proxy" -> {
ProxyDialogFragment().show(parentFragmentManager, "Proxy Dialog")
}
"user_id" -> {
FirebaseCrashlytics.getInstance().recordException(SendLogException())
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText("user_id", Preferences.get<String>("user_id"))
)
Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
"transfer_data" -> {
activity?.startActivity(Intent(activity, TransferActivity::class.java))
}
else -> return false
}
}
return true
}
override fun onPreferenceChange(preference: Preference, newValue: Any?): Boolean {
with (preference) {
when (key) {
"tag_translation" -> {
updateTranslations()
}
"nomedia" -> {
val create = (newValue as? Boolean) ?: return false
return kotlin.runCatching {
val nomedia = DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia")
if (create)
nomedia.createNewFile()
else
nomedia.delete()
}.getOrDefault(false)
}
"dark_mode" -> {
AppCompatDelegate.setDefaultNightMode(when (newValue as Boolean) {
true -> AppCompatDelegate.MODE_NIGHT_YES
false -> AppCompatDelegate.MODE_NIGHT_NO
})
}
else -> return false
}
}
return true
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
key ?: return
with(findPreference<Preference>(key)) {
this ?: return
when (key) {
"proxy" -> {
summary = context.let { getProxyInfo().type.name }
}
"download_folder" -> {
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
}
"download_folder_name" -> {
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
}
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey)
Preferences.registerOnSharedPreferenceChangeListener(this)
initPreferences()
}
override fun onDestroy() {
Preferences.unregisterOnSharedPreferenceChangeListener(this)
super.onDestroy()
}
private fun initPreferences() {
for (i in 0 until preferenceScreen.preferenceCount) {
preferenceScreen.getPreference(i).run {
if (this is PreferenceCategory)
(0 until preferenceCount).map { getPreference(it) }
else
listOf(this)
}.forEach { preference ->
with (preference) with@{
when (key) {
"app_version" -> {
val manager = requireContext().packageManager
val info = manager.getPackageInfo(requireContext().packageName, 0)
summary = requireContext().getString(R.string.settings_app_version_description, info.versionName)
onPreferenceClickListener = this@SettingsFragment
}
"download_folder_name" -> {
summary = Preferences["download_folder_name", "[-id-] -title-"]
setOnPreferenceClickListener {
DownloadFolderNameDialogFragment().show(requireActivity().supportFragmentManager, "Download Location Dialog")
true
}
}
"download_folder" -> {
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
onPreferenceClickListener = this@SettingsFragment
}
"nomedia" -> {
(this as SwitchPreferenceCompat).isChecked = kotlin.runCatching {
DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia").exists()
}.getOrDefault(false)
onPreferenceChangeListener = this@SettingsFragment
}
"default_query" -> {
summary = Preferences.get<String>("default_query")
onPreferenceClickListener = this@SettingsFragment
}
"app_lock" -> {
val lockManager = LockManager(requireContext())
summary =
if (lockManager.locks.isNullOrEmpty()) {
getString(R.string.settings_lock_none)
} else {
lockManager.locks?.joinToString(", ") {
when (it.type) {
Lock.Type.PATTERN -> getString(R.string.settings_lock_pattern)
Lock.Type.PIN -> getString(R.string.settings_lock_pin)
Lock.Type.PASSWORD -> getString(R.string.settings_lock_password)
}
}
}
onPreferenceClickListener = this@SettingsFragment
}
"proxy" -> {
summary = getProxyInfo().type.name
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" -> {
onPreferenceChangeListener = this@SettingsFragment
}
"old_import_galleries" -> {
onPreferenceClickListener = this@SettingsFragment
}
"user_id" -> {
summary = Preferences.get<String>("user_id")
onPreferenceClickListener = this@SettingsFragment
}
"oss" -> {
setOnPreferenceClickListener {
context.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
true
}
}
"transfer_data" -> {
onPreferenceClickListener = this@SettingsFragment
}
}
}
}
}
}
}

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

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

View File

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

View File

@@ -1,57 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2021 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.contentColorFor
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorPalette = darkColors(
primary = LightBlue300,
primaryVariant = LightBlue700,
secondary = Pink600,
onPrimary = Color.White,
onSecondary = Color.White
)
private val LightColorPalette = lightColors(
primary = LightBlue300,
primaryVariant = LightBlue700,
secondary = Pink600,
onPrimary = Color.White,
onSecondary = Color.White
)
@Composable
fun PupilTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) DarkColorPalette else LightColorPalette
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}

View File

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

View File

@@ -0,0 +1,216 @@
/*
* 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.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Animatable
import android.text.Editable
import android.text.TextWatcher
import android.util.AttributeSet
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import xyz.quaver.floatingsearchview.FloatingSearchView
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.floatingsearchview.util.view.SearchInputView
import xyz.quaver.pupil.R
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.types.*
import java.util.*
class FloatingSearchView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
FloatingSearchView(context, attrs),
FloatingSearchView.OnSearchListener,
TextWatcher
{
private val searchInputView = findViewById<SearchInputView>(R.id.search_bar_text)
var onHistoryDeleteClickedListener: ((String) -> Unit)? = null
var onFavoriteHistorySwitchClickListener: (() -> Unit)? = null
init {
searchInputView.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI or searchInputView.imeOptions
searchInputView.addTextChangedListener(this)
onSearchListener = 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 onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
override fun afterTextChanged(s: Editable?) {
s ?: return
if (s.any { it.isUpperCase() })
s.replace(0, s.length, s.toString().lowercase())
}
override fun onSuggestionClicked(searchSuggestion: SearchSuggestion?) {
when (searchSuggestion) {
is TagSuggestion -> {
val tag = "${searchSuggestion.n}:${searchSuggestion.s.replace(Regex("\\s"), "_")}"
with(searchInputView.text!!) {
delete(if (lastIndexOf(' ') == -1) 0 else lastIndexOf(' ') + 1, length)
if (!this.contains(tag))
append("$tag ")
}
}
is Suggestion -> {
with(searchInputView.text!!) {
clear()
append(searchSuggestion.body)
}
}
is FavoriteHistorySwitch -> onFavoriteHistorySwitchClickListener?.invoke()
}
}
override fun onSearchAction(currentQuery: String?) {}
fun onBindSuggestion(
suggestionView: View?,
leftIcon: ImageView?,
textView: TextView?,
item: SearchSuggestion?,
itemPosition: Int
) {
when(item) {
is TagSuggestion -> {
val tag = "${item.n}:${item.s}"
leftIcon?.setImageDrawable(
ResourcesCompat.getDrawable(
resources,
when(item.n) {
"female" -> R.drawable.gender_female
"male" -> R.drawable.gender_male
"language" -> R.drawable.translate
"group" -> R.drawable.account_group
"character" -> R.drawable.account_star
"series" -> R.drawable.book_open
"artist" -> R.drawable.brush
else -> R.drawable.tag
},
context.theme)
)
with(suggestionView?.findViewById<ImageView>(R.id.right_icon)) {
this ?: return@with
if (favoriteTags.contains(Tag.parse(tag)))
setImageResource(R.drawable.ic_star_filled)
else
setImageResource(R.drawable.ic_star_empty)
visibility = View.VISIBLE
rotation = 0f
isEnabled = true
isClickable = true
setOnClickListener {
val tag = Tag.parse(tag)
if (favoriteTags.contains(tag)) {
setImageResource(R.drawable.ic_star_empty)
favoriteTags.remove(tag)
}
else {
setImageDrawable(
AnimatedVectorDrawableCompat.create(context,
R.drawable.avd_star
))
(drawable as Animatable).start()
favoriteTags.add(tag)
}
}
}
if (item.t > 0) {
(suggestionView as? LinearLayout)?.let {
val count = it.findViewById<TextView>(R.id.count)
if (count == null)
it.addView(
LayoutInflater.from(context).inflate(R.layout.suggestion_count, suggestionView, false)
.apply {
this as TextView
text = item.t.toString()
}, 2
)
else
count.text = item.t.toString()
}
}
}
is FavoriteHistorySwitch -> {
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.swap_horizontal, context.theme))
}
is Suggestion -> {
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.history, context.theme))
with(suggestionView?.findViewById<ImageView>(R.id.right_icon)) {
this ?: return@with
setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.delete, context.theme))
visibility = View.VISIBLE
rotation = 0f
isEnabled = true
isClickable = true
setOnClickListener {
onHistoryDeleteClickedListener?.invoke(item.body)
}
}
}
is LoadingSuggestion -> {
leftIcon?.setImageDrawable(CircularProgressDrawable(context).also {
it.setStyle(CircularProgressDrawable.DEFAULT)
it.colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, R.color.colorAccent), PorterDuff.Mode.SRC_IN)
it.start()
})
}
is NoResultSuggestion -> {
leftIcon?.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.close, context.theme))
}
}
}
}

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

@@ -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.annotation.SuppressLint
import android.content.Context
import androidx.core.content.ContextCompat
import com.google.android.material.chip.Chip
import xyz.quaver.pupil.R
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.translations
import xyz.quaver.pupil.util.wordCapitalize
@SuppressLint("ViewConstructor")
class TagChip(context: Context, _tag: Tag) : Chip(context) {
val tag: Tag =
_tag.let {
when {
it.area != null -> it
else -> Tag("tag", _tag.tag)
}
}
private val languages = context.resources.getStringArray(R.array.languages).map {
it.split("|").let { split ->
Pair(split[0], split[1])
}
}.toMap()
init {
when(tag.area) {
"male" -> {
setChipBackgroundColorResource(R.color.material_blue_700)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
setCloseIconTintResource(android.R.color.white)
chipIcon = ContextCompat.getDrawable(context, R.drawable.gender_male_white)
}
"female" -> {
setChipBackgroundColorResource(R.color.material_pink_600)
setTextColor(ContextCompat.getColor(context, android.R.color.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)
}
}
text = when (tag.area) {
"language" -> languages[tag.tag]
else -> (translations[tag.tag] ?: tag.tag).wordCapitalize()
}
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

@@ -0,0 +1,69 @@
/*
* 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 android.view.View
import androidx.recyclerview.widget.RecyclerView
import xyz.quaver.pupil.R
class ItemClickSupport(private val recyclerView: RecyclerView) {
var onItemClickListener: ((RecyclerView, Int, View) -> Unit)? = null
var onItemLongClickListener: ((RecyclerView, Int, View) -> Boolean)? = null
init {
recyclerView.apply {
setTag(R.id.item_click_support, this)
addOnChildAttachStateChangeListener(object: RecyclerView.OnChildAttachStateChangeListener {
override fun onChildViewAttachedToWindow(view: View) {
onItemClickListener?.let { listener ->
view.setOnClickListener {
recyclerView.getChildViewHolder(view).let { holder ->
listener.invoke(recyclerView, holder.adapterPosition, view)
}
}
}
onItemLongClickListener?.let { listener ->
view.setOnLongClickListener {
recyclerView.getChildViewHolder(view).let { holder ->
listener.invoke(recyclerView, holder.adapterPosition, view)
}
}
}
}
override fun onChildViewDetachedFromWindow(view: View) {
// Do Nothing
}
})
}
}
fun detach() {
recyclerView.apply {
clearOnChildAttachStateChangeListeners()
setTag(R.id.item_click_support, null)
}
}
companion object {
fun addTo(view: RecyclerView) = view.let { removeFrom(it); ItemClickSupport(it) }
fun removeFrom(view: RecyclerView) = (view.tag as? ItemClickSupport)?.detach()
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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 android.content.SharedPreferences
import kotlin.reflect.KClass
lateinit var preferences: SharedPreferences
object Preferences: SharedPreferences by preferences {
val defMap = mapOf(
String::class to "",
Int::class to -1,
Long::class to -1L,
Boolean::class to false,
Set::class to emptySet<Any>()
)
operator fun set(key: String, value: String) = edit().putString(key, value).apply()
operator fun set(key: String, value: Int) = edit().putInt(key, value).apply()
operator fun set(key: String, value: Long) = edit().putLong(key, value).apply()
operator fun set(key: String, value: Boolean) = edit().putBoolean(key, value).apply()
operator fun set(key: String, value: Set<String>) = edit().putStringSet(key, value).apply()
@Suppress("UNCHECKED_CAST")
inline operator fun <reified T: Any> get(key: String, defaultVal: T = defMap[T::class] as T): T = (all[key] as? T) ?: defaultVal
fun remove(key: String) {
edit().remove(key).apply()
}
}

View File

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

View File

@@ -0,0 +1,93 @@
/*
* 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 com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.serializer
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 {
@Suppress("UNCHECKED_CAST")
@OptIn(ExperimentalSerializationApi::class)
val serializer: KSerializer<List<T>>
get() = ListSerializer(serializer(any::class.java) as KSerializer<T>)
init {
if (!file.exists()) {
file.parentFile?.mkdirs()
save()
}
load()
}
@Synchronized
fun load() {
set.clear()
kotlin.runCatching {
Json.decodeFromString(serializer, file.readText())
}.onSuccess {
set.addAll(it)
}.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
}
}
@Synchronized
@OptIn(ExperimentalSerializationApi::class)
fun save() {
file.writeText(Json.encodeToString(serializer, set.toList()))
}
@Synchronized
override fun add(element: T): Boolean {
set.remove(element)
return set.add(element).also {
save()
}
}
@Synchronized
override fun addAll(elements: Collection<T>): Boolean {
set.removeAll(elements)
return set.addAll(elements).also {
save()
}
}
@Synchronized
override fun remove(element: T): Boolean {
return set.remove(element).also {
save()
}
}
@Synchronized
override fun clear() {
set.clear()
save()
}
}

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

@@ -0,0 +1,297 @@
/*
* 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.downloader
import android.content.Context
import android.content.ContextWrapper
import android.net.Uri
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Request
import xyz.quaver.io.FileX
import xyz.quaver.io.util.*
import xyz.quaver.pupil.client
import xyz.quaver.pupil.hitomi.*
import java.io.File
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
data class Metadata(
var galleryBlock: GalleryBlock? = null,
var galleryInfo: GalleryInfo? = null,
var imageList: MutableList<String?>? = null
) {
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) {
companion object {
val instances = ConcurrentHashMap<Int, Cache>()
fun getInstance(context: Context, galleryID: Int) =
instances[galleryID] ?: synchronized(this) {
instances[galleryID] ?: Cache(context, galleryID).also { instances.put(galleryID, it) }
}
@Synchronized
fun delete(context: Context, galleryID: Int) {
File(context.cacheDir, "imageCache/$galleryID").deleteRecursively()
instances.remove(galleryID)
}
}
init {
cacheFolder.mkdirs()
}
var metadata = kotlin.runCatching {
findFile(".metadata")?.readText()?.let { metadata ->
kotlin.runCatching {
Json.decodeFromString<Metadata>(metadata)
}.getOrElse {
Metadata(json.decodeFromString<OldMetadata>(metadata))
}
}
}.onFailure { it.printStackTrace() }.getOrNull() ?: Metadata()
val downloadFolder: FileX?
get() = DownloadManager.getInstance(this).getDownloadFolder(galleryID)
val cacheFolder: FileX
get() = FileX(this, cacheDir, "imageCache/$galleryID").also {
if (!it.exists())
it.mkdirs()
}
fun findFile(fileName: String): FileX? =
downloadFolder?.let { downloadFolder -> downloadFolder.getChild(fileName).let {
if (it.exists()) it else null
} } ?: cacheFolder.getChild(fileName).let {
if (it.exists()) it else null
}
@Suppress("BlockingMethodInNonBlockingContext")
fun setMetadata(change: (Metadata) -> Unit) {
change.invoke(metadata)
val file = cacheFolder.getChild(".metadata")
kotlin.runCatching {
if (!file.exists()) {
file.createNewFile()
}
file.writeText(Json.encodeToString(metadata))
}
}
suspend fun getGalleryBlock(): GalleryBlock? {
return metadata.galleryBlock
?: withContext(Dispatchers.IO) {
try {
getGalleryBlock(galleryID).also {
setMetadata { metadata -> metadata.galleryBlock = it }
}
} catch (e: Exception) { return@withContext null }
}
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun getThumbnail(): Uri =
findFile(".thumbnail")?.uri
?: getGalleryBlock()?.thumbnails?.firstOrNull()?.let { withContext(Dispatchers.IO) {
kotlin.runCatching {
val request = Request.Builder()
.url(it)
.header("Referer", "https://hitomi.la/")
.build()
client.newCall(request).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() }
}.getOrNull()?.let { thumbnail -> kotlin.runCatching {
cacheFolder.getChild(".thumbnail").also {
if (!it.exists())
it.createNewFile()
it.writeBytes(thumbnail)
}
}.getOrNull()?.uri }
} } ?: Uri.EMPTY
suspend fun getGalleryInfo(): GalleryInfo? {
return metadata.galleryInfo
?: withContext(Dispatchers.IO) {
try {
getGalleryInfo(galleryID).also {
setMetadata { metadata ->
metadata.galleryInfo = it
if (metadata.imageList == null)
metadata.imageList = MutableList(it.files.size) { null }
}
}
} catch (e: Exception) {
null
}
}
}
fun getImage(index: Int): FileX? =
metadata.imageList?.getOrNull(index)?.let { findFile(it) }
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun putImage(index: Int, fileName: String, data: ByteArray) = coroutineScope {
val file = cacheFolder.getChild(fileName)
if (!file.exists())
file.createNewFile()
file.writeBytes(data)
setMetadata { metadata -> metadata.imageList!![index] = fileName }
}
private val lock = ConcurrentHashMap<Int, Mutex>()
@Suppress("BlockingMethodInNonBlockingContext")
fun moveToDownload() = CoroutineScope(Dispatchers.IO).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 ->
imageName ?: return@forEach
val target = downloadFolder.getChild(imageName)
val source = cacheFolder.getChild(imageName)
if (!source.exists())
return@forEach
kotlin.runCatching {
if (!target.exists())
target.createNewFile()
target.outputStream()?.use { target -> target.channel.truncate(0L); source.inputStream()?.use { source ->
source.copyTo(target)
} }
}
}
cacheFolder.deleteRecursively()
}
}
}

View File

@@ -0,0 +1,127 @@
/*
* 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.downloader
import android.content.Context
import android.content.ContextWrapper
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Call
import xyz.quaver.io.FileX
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.services.DownloadService
import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.formatDownloadFolder
class DownloadManager private constructor(context: Context) : ContextWrapper(context) {
companion object {
@Volatile private var instance: DownloadManager? = null
fun getInstance(context: Context) =
instance ?: synchronized(this) {
instance ?: DownloadManager(context).also { instance = it }
}
}
val defaultDownloadFolder = FileX(this, getExternalFilesDir(null)!!)
val downloadFolder: FileX
get() = kotlin.runCatching {
FileX(this, Preferences.get<String>("download_folder"))
}.getOrElse {
Preferences["download_folder"] = defaultDownloadFolder.uri.toString()
defaultDownloadFolder
}
private var prevDownloadFolder: FileX? = null
private var downloadFolderMapInstance: MutableMap<Int, String>? = null
val downloadFolderMap: MutableMap<Int, String>
@Synchronized
get() {
if (prevDownloadFolder != downloadFolder) {
prevDownloadFolder = downloadFolder
downloadFolderMapInstance = run {
val file = downloadFolder.getChild(".download")
val data = if (file.exists())
kotlin.runCatching {
file.readText()?.let{ Json.decodeFromString<MutableMap<Int, String>>(it) }
}.onFailure { file.delete() }.getOrNull()
else
null
data ?: run {
file.createNewFile()
mutableMapOf()
}
}
}
return downloadFolderMapInstance ?: mutableMapOf()
}
@Synchronized
fun isDownloading(galleryID: Int): Boolean {
val isThisGallery: (Call) -> Boolean = { !it.isCanceled && (it.request().tag() as? DownloadService.Tag)?.galleryID == galleryID }
return downloadFolderMap.containsKey(galleryID)
&& client.dispatcher().let { it.queuedCalls().any(isThisGallery) || it.runningCalls().any(isThisGallery) }
}
@Synchronized
fun getDownloadFolder(galleryID: Int): FileX? =
downloadFolderMap[galleryID]?.let { downloadFolder.getChild(it) }
fun addDownloadFolder(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch {
val name = Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock()
?.formatDownloadFolder() ?: return@launch
val folder = downloadFolder.getChild(name)
downloadFolderMap[galleryID] = name
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
if (folder.exists()) return@launch
folder.mkdir()
}
@Synchronized
fun deleteDownloadFolder(galleryID: Int) {
downloadFolderMap[galleryID]?.let {
kotlin.runCatching {
downloadFolder.getChild(it).deleteRecursively()
downloadFolderMap.remove(galleryID)
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
}
}
}
}

View File

@@ -0,0 +1,67 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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
val mutex = Mutex()
fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
if (mutex.isLocked) return@launch
mutex.withLock {
val cacheFolder = File(context.cacheDir, "imageCache")
val downloadManager = DownloadManager.getInstance(context)
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()
}
size
}
if (cacheSize.invoke() > limit)
while (cacheSize.invoke() > limit/2) {
val caches = cacheFolder.list() ?: return@withLock
synchronized(histories) {
(histories.firstOrNull {
caches.contains(it.toString()) && !downloadManager.isDownloading(it)
} ?: return@withLock).let {
Cache.delete(context, it)
}
}
}
}
}

View File

@@ -0,0 +1,124 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.content.Context
import android.content.ContextWrapper
import androidx.core.content.ContextCompat
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
fun hash(password: String): String {
val bytes = password.toByteArray()
val md = MessageDigest.getInstance("SHA-256")
return md.digest(bytes).fold("") { str, it -> str + "%02x".format(it) }
}
// Ret1: SHA-256 Hash
// Ret2: Hash salt
fun hashWithSalt(password: String): Pair<String, String> {
val salt = (0 until 12).map { source.random() }.joinToString()
return Pair(hash(password+salt), salt)
}
const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@Serializable
data class Lock(val type: Type, val hash: String, val salt: String) {
enum class Type {
PATTERN,
PIN,
PASSWORD
}
companion object {
fun generate(type: Type, password: String): Lock {
val (hash, salt) = hashWithSalt(password)
return Lock(type, hash, salt)
}
}
fun match(password: String): Boolean {
return hash(password+salt) == hash
}
}
class LockManager(base: Context): ContextWrapper(base) {
var locks: ArrayList<Lock>? = null
init {
load()
}
private fun load() {
val lock = File(ContextCompat.getDataDir(this), "lock.json")
if (!lock.exists()) {
lock.createNewFile()
lock.writeText("[]")
}
locks = Json.decodeFromString(lock.readText())
}
private fun save() {
val lock = File(ContextCompat.getDataDir(this), "lock.json")
if (!lock.exists())
lock.createNewFile()
lock.writeText(Json.encodeToString(locks?.toList() ?: listOf()))
}
fun add(lock: Lock) {
remove(lock.type)
locks?.add(lock)
save()
}
fun remove(type: Lock.Type) {
locks?.removeAll { it.type == type }
save()
}
fun check(password: String): Boolean? {
return locks?.any {
it.match(password)
}
}
fun isEmpty(): Boolean {
return locks.isNullOrEmpty()
}
fun isNotEmpty(): Boolean = !isEmpty()
fun contains(type: Lock.Type): Boolean {
return locks?.any { it.type == type } ?: false
}
}

View File

@@ -0,0 +1,194 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
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 com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.serialization.json.*
import okhttp3.OkHttpClient
import okhttp3.Request
import xyz.quaver.pupil.R
import xyz.quaver.pupil.hitomi.GalleryBlock
import xyz.quaver.pupil.hitomi.GalleryInfo
import xyz.quaver.pupil.hitomi.imageUrlFromImage
import java.util.*
import kotlin.collections.ArrayList
@OptIn(ExperimentalStdlibApi::class)
fun String.wordCapitalize() : String {
val result = ArrayList<String>()
@SuppressLint("DefaultLocale")
for (word in this.split(" "))
result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() })
return result.joinToString(" ")
}
private val suffix = listOf(
"B",
"kB",
"MB",
"GB",
"TB" //really?
)
fun byteToString(byte: Long, precision : Int = 1) : String {
var size = byte.toDouble(); var suffixIndex = 0
while (size >= 1024) {
size /= 1024
suffixIndex++
}
return "%.${precision}f ${suffix[suffixIndex]}".format(size)
}
/**
* Convert android generated ID to requestCode
* to prevent java.lang.IllegalArgumentException: Can only use lower 16 bits for requestCode
*
* https://stackoverflow.com/questions/38072322/generate-16-bit-unique-ids-in-android-for-startactivityforresult
*/
fun Int.normalizeID() = this.and(0xFFFF)
fun OkHttpClient.Builder.proxyInfo(proxyInfo: ProxyInfo) = this.apply {
proxy(proxyInfo.proxy())
proxyInfo.authenticator()?.let {
proxyAuthenticator(it)
}
}
val formatMap = mapOf<String, GalleryBlock.() -> (String)>(
"-id-" to { id.toString() },
"-title-" to { title },
"-artist-" to { if (artists.isNotEmpty()) artists.joinToString() else "N/A" },
"-group-" to { if (groups.isNotEmpty()) groups.joinToString() else "N/A" }
// TODO
)
/**
* Formats download folder name with given Metadata
*/
fun GalleryBlock.formatDownloadFolder(): String =
Preferences["download_folder_name", "[-id-] -title-"].let {
formatMap.entries.fold(it) { str, (k, v) ->
str.replace(k, v.invoke(this), true)
}
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
fun GalleryBlock.formatDownloadFolderTest(format: String): String =
format.let {
formatMap.entries.fold(it) { str, (k, v) ->
str.replace(k, v.invoke(this), true)
}
}.replace(Regex("""[*\\|"?><:/]"""), "").ellipsize(127)
suspend fun GalleryInfo.getRequestBuilders(): List<Request.Builder> {
val galleryID = this.id.toIntOrNull() ?: 0
return this.files.map {
Request.Builder()
.url(
runCatching {
imageUrlFromImage(galleryID, it, false)
}
.onFailure {
FirebaseCrashlytics.getInstance().recordException(it)
}
.getOrDefault("https://a/")
)
.header("Referer", "https://hitomi.la/")
}
}
fun byteCount(codePoint: Int): Int = when (codePoint) {
in 0 ..< 0x80 -> 1
in 0x80 ..< 0x800 -> 2
in 0x800 ..< 0x10000 -> 3
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,58 @@
/*
* 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 android.content.Context
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Credentials
import java.net.InetSocketAddress
import java.net.Proxy
@Serializable
data class ProxyInfo(
val type: Proxy.Type,
val host: String? = null,
val port: Int? = null,
val username: String? = null,
val password: String? = null
) {
fun proxy() : Proxy {
return if (host.isNullOrBlank() || port == null)
return Proxy.NO_PROXY
else
Proxy(type, InetSocketAddress.createUnresolved(host, port))
}
fun authenticator(): Authenticator? = if (username.isNullOrBlank() || password.isNullOrBlank()) null else
Authenticator { _, response ->
val credential = Credentials.basic(username, password)
response.request().newBuilder()
.header("Proxy-Authorization", credential)
.build()
}
}
fun getProxyInfo(): ProxyInfo =
Json.decodeFromString(Preferences["proxy", Json.encodeToString(ProxyInfo(Proxy.Type.DIRECT))])

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

@@ -0,0 +1,214 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2019 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import android.app.DownloadManager
import android.content.Context
import android.net.Uri
import android.webkit.URLUtil
import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.json.*
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import ru.noties.markwon.Markwon
import xyz.quaver.pupil.*
import xyz.quaver.pupil.types.Tag
import java.io.File
import java.io.IOException
import java.net.URL
import java.util.*
fun getReleases(url: String) : JsonArray {
return try {
URL(url).readText().let {
Json.parseToJsonElement(it).jsonArray
}
} catch (e: Exception) {
JsonArray(emptyList())
}
}
fun checkUpdate(url: String) : JsonObject? {
val releases = getReleases(url)
if (releases.isEmpty())
return null
return releases.firstOrNull {
Preferences["beta"] || it.jsonObject["prerelease"]?.jsonPrimitive?.booleanOrNull == false
}?.let {
if (it.jsonObject["tag_name"]?.jsonPrimitive?.contentOrNull == BuildConfig.VERSION_NAME)
null
else
it.jsonObject
}
}
fun getApkUrl(releases: JsonObject) : String? {
return releases["assets"]?.jsonArray?.firstOrNull {
Regex("Pupil-v.+\\.apk").matches(it.jsonObject["name"]?.jsonPrimitive?.contentOrNull ?: "")
}.let {
it?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.contentOrNull
}
}
fun checkUpdate(context: Context, force: Boolean = false) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
val ignoreUpdateUntil = preferences.getLong("ignore_update_until", 0)
if (!force && ignoreUpdateUntil > System.currentTimeMillis())
return
fun extractReleaseNote(update: JsonObject, locale: Locale) : String {
val markdown = update["body"]!!.jsonPrimitive.content
val target = when(locale.language) {
"ko" -> "한국어"
"ja" -> "日本語"
else -> "English"
}
val releaseNote = Regex("^# Release Note.+$")
val language = Regex("^## $target$")
val end = Regex("^#.+$")
var releaseNoteFlag = false
var languageFlag = false
val result = StringBuilder()
for(line in markdown.lines()) {
if (releaseNote.matches(line)) {
releaseNoteFlag = true
continue
}
if (releaseNoteFlag) {
if (language.matches(line)) {
languageFlag = true
continue
}
}
if (languageFlag) {
if (end.matches(line))
break
result.append(line+"\n")
}
}
return context.getString(R.string.update_release_note, update["tag_name"]?.jsonPrimitive?.contentOrNull, result.toString())
}
CoroutineScope(Dispatchers.Default).launch {
val update =
checkUpdate(context.getString(R.string.release_url)) ?: return@launch
val url = getApkUrl(update) ?: return@launch
val dialog = AlertDialog.Builder(context).apply {
setTitle(R.string.update_title)
val msg = extractReleaseNote(update, Locale.getDefault())
setMessage(Markwon.create(context).toMarkdown(msg))
setPositiveButton(android.R.string.ok) { _, _ ->
if (!checkNotificationEnabled(context)) {
showNotificationPermissionExplanationDialog(context)
return@setPositiveButton
}
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
//Cancel any download queued before
val id: Long = Preferences["update_download_id"]
if (id != -1L)
downloadManager.remove(id)
val target = File(context.getExternalFilesDir(null), "Pupil.apk").also {
it.delete()
}
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(context.getText(R.string.update_notification_description))
.setDestinationUri(Uri.fromFile(target))
downloadManager.enqueue(request).also {
Preferences["update_download_id"] = it
}
}
setNegativeButton(if (force) android.R.string.cancel else R.string.ignore) { _, _ ->
if (!force)
preferences.edit()
.putLong("ignore_update_until", System.currentTimeMillis() + 86400000)
.apply()
}
}
launch(Dispatchers.Main) {
dialog.show()
}
}
}
fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((Int) -> Unit)? = null) {
if (!URLUtil.isValidUrl(url)) {
onFailure?.invoke(IllegalArgumentException())
return
}
val request = Request.Builder()
.url(url)
.get()
.build()
client.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
onFailure?.invoke(e)
}
override fun onResponse(call: Call, response: Response) {
kotlin.runCatching {
val data = Json.parseToJsonElement(response.also { if (it.code() != 200) throw IOException() }.body().use { it?.string() } ?: "[]")
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) }
}
})
}

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

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