Compare commits

...

228 Commits

Author SHA1 Message Date
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
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
65ea09854e Fixed Bug occuring on Android 11 2021-01-12 12:40:21 +09:00
tom5079
9f9a4c81b3 migrated to ViewBinding 2020-11-29 14:01:56 +09:00
tom5079
d567b30f4b Fixed typo & Built apk 2020-11-29 14:01:56 +09:00
tom5079
6d7c4ce0ab Fixed show extra tags button not showing up & version up 2020-11-29 14:01:51 +09:00
tom5079
e062b8f9e9 Implemented proper Page Turn without relying on RecyclerView 2020-11-29 14:01:46 +09:00
tom5079
08403b7a4e fixed gallery import 2020-11-29 14:01:41 +09:00
tom5079
c6ed5d35e7 ProgressCard 2020-11-29 14:01:25 +09:00
tom5079
dba3460b60 Fixes unable to recursively delete when the download folder is not not SAF based 2020-11-29 14:01:09 +09:00
tom5079
f07f624fcf search bug fix 2020-10-25 00:15:21 +09:00
tom5079
48ff2f328f search bug fix 2020-10-24 23:55:50 +09:00
tom5079
9ae2423a40 search bug fix 2020-10-24 23:05:11 +09:00
tom5079
2bc3c78c75 search bug fix 2020-10-24 23:04:49 +09:00
tom5079
18e9fe75fb hiyobi.me fix 2020-10-24 11:48:14 +09:00
tom5079
880a741a44 hiyobi.me fix 2020-10-24 11:25:16 +09:00
tom5079
2c6ddcc64b hitomi.la image not loading fix 2020-10-21 14:42:07 +09:00
tom5079
8f2e757b77 Update README.md 2020-10-15 15:53:30 +09:00
tom5079
ff177955b3 Update README.md 2020-10-15 15:49:07 +09:00
tom5079
8bb8066a98 Apk built 2020-10-15 14:37:13 +09:00
tom5079
2747ddbf65 Adjust gallery_id margin 2020-10-15 14:32:36 +09:00
tom5079
b939e9424d Translate tag by default 2020-10-15 14:29:22 +09:00
tom5079
fb9dea5d1e Copy galleryID by clicking galleryblock_id 2020-10-15 12:52:38 +09:00
tom5079
da4d5d711b Prefetch
Resolves #109
2020-10-15 10:20:36 +09:00
tom5079
331cbec5f1 Bug fix 2020-10-14 18:36:36 +09:00
tom5079
7f02284285 Update README.md 2020-10-14 00:26:06 +09:00
tom5079
ac2c3a6d97 Merge remote-tracking branch 'origin/master' into master 2020-10-14 00:25:44 +09:00
tom5079
c3bc80fec6 Bug fix 2020-10-14 00:24:38 +09:00
tom5079
09779a0710 Update README.md 2020-10-13 23:47:56 +09:00
tom5079
e82c6ef866 App built
Possible build time optimization
2020-10-13 23:40:53 +09:00
tom5079
861ae9be64 Merge remote-tracking branch 'origin/dev' into dev 2020-10-13 23:34:28 +09:00
tom5079
96108bc1ec Improves Scroll Jitter 2020-10-13 23:34:16 +09:00
tom5079
016f217db0 Merge pull request #108 from klx7007/patch-1
Fix FloatingSearchView imeOptions to only affect keyboard visibility
2020-10-13 23:05:34 +09:00
tom5079
0688294f18 Dependency update
Support non external storage document Uris

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

TODO: Add group name to GalleryBlock
2020-10-01 21:24:32 +09:00
tom5079
60e8b18702 Update README.md 2020-09-29 16:53:15 +09:00
tom5079
a8317824a9 Merge remote-tracking branch 'origin/master' into master 2020-09-27 21:40:32 +09:00
tom5079
0c3c78cc72 Fixed app crashing when loading thumbnail 2020-09-27 21:40:22 +09:00
tom5079
cfd4a8faac Update README.md 2020-09-27 21:39:05 +09:00
tom5079
7f3fb0db0d Update README.md 2020-09-27 20:30:47 +09:00
tom5079
385d3f0d1b Update README.md 2020-09-27 20:29:50 +09:00
tom5079
8fa6bd12a2 Update README.md 2020-09-27 20:27:29 +09:00
tom5079
57c2004e46 Update README.md 2020-09-27 20:21:45 +09:00
tom5079
c6b069bbfb Update README.md 2020-09-27 20:19:53 +09:00
tom5079
c18bffd08f Fixed app crashing when thumbnail is null 2020-09-27 20:18:04 +09:00
tom5079
47ec181439 Fixed app crashing when thumbnail is null 2020-09-27 20:15:43 +09:00
tom5079
90ad40b1b7 Update README.md 2020-09-27 19:32:18 +09:00
tom5079
4d3f20cf98 Update README.md 2020-09-27 19:31:30 +09:00
tom5079
86df9d52bc Update README.md 2020-09-27 19:15:46 +09:00
tom5079
1bd025e070 Fixed ProxyDialog not showing up 2020-09-27 15:09:19 +09:00
tom5079
86ee239c71 App built 2020-09-27 14:39:47 +09:00
tom5079
27d0c01e1f Don't refresh onResume 2020-09-27 14:37:16 +09:00
tom5079
7a9507be01 Somewhat working 2020-09-27 14:29:02 +09:00
tom5079
1490035893 Does not work 2020-09-27 10:04:26 +09:00
tom5079
a6afcb0ed0 Consistent usage of quotation marks 2020-09-26 22:41:51 +09:00
tom5079
ea7e8584cb Consistent usage of quotation marks 2020-09-26 22:36:48 +09:00
tom5079
608c6e6a1d App built 2020-09-26 21:01:36 +09:00
tom5079
bb2c91145f Dependency update 2020-09-26 20:58:46 +09:00
tom5079
db074df0f7 Fixed Download Concurrency issue
Fixed image not showing up after reader is paused and resumed
2020-09-26 11:07:35 +09:00
tom5079
f7c45df9a6 Tag favorite bug fix 2020-09-26 09:36:20 +09:00
tom5079
44e3d16cd6 Merge branch 'dev' into master 2020-09-26 09:08:29 +09:00
tom5079
a973cdfe0b Download Bug fix
Added favorite to TagChip
Improved eyeblink recognition
2020-09-26 09:07:52 +09:00
tom5079
fca42c79a8 Updated startActivityForResult to launchers 2020-09-25 15:39:07 +09:00
tom5079
f236775599 Bug fix
Remember thin mode preference
TagChip favorites
2020-09-25 15:17:05 +09:00
tom5079
360decd37c FloatingSearchView migration 2020-09-16 14:31:45 +09:00
tom5079
998433479b Merge branch 'dev' into master 2020-09-15 23:20:01 +09:00
tom5079
c7e75aacf0 Layout fix
History fix
2020-09-15 23:19:26 +09:00
tom5079
690338273a Merge branch 'dev' into master 2020-09-15 02:42:33 +09:00
tom5079
4207ea494d Bug fix 2020-09-15 02:42:18 +09:00
tom5079
265473a15a Merge branch 'dev' into master
# Conflicts:
#	app/release/app-release.apk
#	app/release/output-metadata.json
2020-09-15 02:13:53 +09:00
tom5079
b907d36770 Bug fix 2020-09-15 02:13:25 +09:00
tom5079
fee280341a Blink Recognition 2020-09-15 01:12:29 +09:00
tom5079
0f1ef70752 Bug fix 2020-09-14 22:34:51 +09:00
tom5079
0f8c68b22e Fixed to work on old Androids 2020-09-13 21:42:02 +09:00
tom5079
701017d2ca Merge branch 'face-recog' into dev 2020-09-13 21:10:29 +09:00
tom5079
be6903ca12 App built 2020-09-13 16:24:23 +09:00
tom5079
1521bc1223 Downloader Bug fix
UI Optimized
Scroller autohide, track disable
2020-09-13 16:19:32 +09:00
tom5079
7ed66b827f Implemented eye recognition
TODO: Move pages according to eye blinking
2020-09-12 20:25:55 +09:00
tom5079
df3a478ef3 Bug fix 2020-09-12 20:22:34 +09:00
tom5079
974ddf69d5 Bug fix 2020-09-12 19:33:13 +09:00
tom5079
56a91268de Fixed fastscroll 2020-09-12 19:10:15 +09:00
tom5079
3dda2f9a1c Dependency update 2020-09-12 12:35:10 +09:00
tom5079
ed20456f9f VD Optimization 2020-09-12 12:17:02 +09:00
tom5079
281d4a0023 Adjusted Fastscroll UI
VD Optimized
2020-09-12 11:23:38 +09:00
tom5079
2170403662 Fixed Image UI 2020-09-12 10:32:23 +09:00
tom5079
b1c1e96135 Smol fix 2020-09-11 20:45:31 +09:00
tom5079
a8de1429c1 Bug fix 2020-09-11 19:53:49 +09:00
tom5079
3ba6cb81ae Bug fix 2020-09-11 19:40:56 +09:00
tom5079
acc85da80f Bug fix 2020-09-10 22:47:57 +09:00
tom5079
b53de8624d Bug fix 2020-09-10 22:45:27 +09:00
tom5079
6e2eeb29cc Bug fix 2020-09-10 21:41:57 +09:00
tom5079
62eb28ac01 Bug fix 2020-09-10 19:44:08 +09:00
tom5079
fd298529bf Memory usage optimization 2020-09-10 19:16:42 +09:00
tom5079
297ce506b1 Bug fix 2020-09-10 17:03:17 +09:00
tom5079
18c6954be3 Improved Suggestions
resolves #100
2020-09-10 16:50:26 +09:00
tom5079
cea3fb1e65 Bug fix 2020-09-09 16:58:01 +09:00
tom5079
7f274fd238 Added OSS Notice 2020-09-09 13:06:32 +09:00
tom5079
439a8e93ec App built 2020-09-09 11:25:53 +09:00
tom5079
83801feee9 Bug fix 2020-09-09 11:23:22 +09:00
tom5079
8a6860c96e Import cleanup 2020-09-09 09:29:33 +09:00
tom5079
5c959f2987 Bug fix 2020-09-09 09:29:33 +09:00
tom5079
4e4397287a Implemented fast scroll 2020-09-09 09:22:17 +09:00
tom5079
fe02abc9e8 Bug fix 2020-09-08 20:04:12 +09:00
tom5079
59347ab317 Bug fix 2020-09-08 19:16:15 +09:00
tom5079
f408a91176 Bug fix 2020-09-07 10:00:10 +09:00
tom5079
6f6956ce27 Fixed DownloadLocationDialogFragment keep showing up when any button is clicked 2020-09-06 17:19:18 +09:00
tom5079
4ecad8eccc Fixed migration 2020-09-05 18:31:53 +09:00
tom5079
486fbe46a0 Fixed migration 2020-09-05 18:11:20 +09:00
tom5079
1ddb636dd0 Fixed migration 2020-09-05 18:00:15 +09:00
tom5079
081c890b4e Bug fix 2020-09-05 12:51:41 +09:00
tom5079
86d528ba13 v5.0-beta1 2020-09-05 09:13:03 +09:00
tom5079
6bda3cb75a Performance improvement 2020-09-05 08:57:10 +09:00
tom5079
12d8949c9e Fixed context not attached error 2020-09-05 08:31:30 +09:00
tom5079
ffc7c2aa67 Bug fix 2020-09-04 11:05:29 +09:00
tom5079
5ec67488eb Bug fix 2020-09-04 07:51:55 +09:00
tom5079
be64703d3c Minor bug fix 2020-09-03 18:28:40 +09:00
tom5079
705925a050 Migration bug fix 2020-09-03 15:49:04 +09:00
tom5079
29665be34d Added Migration 2020-09-03 10:54:07 +09:00
tom5079
1edf986acf Added Download Folder Dialog 2020-09-02 21:59:17 +09:00
tom5079
37be8ccf7f Bug fix 2020-09-02 20:15:26 +09:00
tom5079
ead68b5201 Moved storage managing settings to another fragment 2020-09-02 13:28:05 +09:00
tom5079
4409664698 nomedia 2020-09-02 13:05:36 +09:00
tom5079
fc6bc7965c Added download folder name change 2020-09-02 12:58:31 +09:00
tom5079
f70eccb1da Created TagChip.kt 2020-09-02 12:29:34 +09:00
tom5079
861994e804 Fixed crash when download folder is changed 2020-09-02 12:15:37 +09:00
tom5079
2b8facfb97 End service when completed
Auto redownload
2020-09-02 11:03:46 +09:00
tom5079
9583897ada working 2020-09-02 00:13:25 +09:00
tom5079
7704c96955 what i got so far 2020-09-01 18:07:16 +09:00
tom5079
c96d609803 Created new cache/downloader 2020-08-31 11:41:12 +09:00
tom5079
aa0e5000ab Deleted redundant code 2020-08-30 20:12:06 +09:00
tom5079
7ca4418a50 Added global preferences object 2020-08-30 19:52:51 +09:00
tom5079
fdd9b02388 (Almost) Full OkHTTP implementation 2020-08-28 22:43:47 +09:00
tom5079
ece127e982 Fixed downloader not fully cancelled after DownloadWorker.cancel() is called 2020-08-28 21:51:44 +09:00
tom5079
5488e14f32 Moved history adding code to ReaderActivity.kt 2020-08-26 23:47:07 +09:00
tom5079
3558d826fb Implemented new favorite backup/restore feature 2020-08-26 23:40:36 +09:00
tom5079
68c94d1d8b Removed old features 2020-08-26 20:00:30 +09:00
tom5079
1a4ae5dfc6 Dependency update 2020-08-26 19:19:26 +09:00
tom5079
1a95afe266 Implemented Right-to-Left pageturn 2020-08-26 18:47:27 +09:00
tom5079
6579db3cc8 Added Search all galleries feature 2020-08-26 18:36:59 +09:00
tom5079
ceac01533a Updated History.kt to use kotlin's delegate feature 2020-08-26 18:01:12 +09:00
tom5079
216914882c Separated libpupil to standalone repository
Migrated to Kotlin 1.4
2020-08-23 20:26:23 +09:00
tom5079
735dbab695 Build optimization 2020-08-10 01:53:11 +09:00
tom5079
dbaab152ef Build optimization 2020-08-10 01:32:56 +09:00
tom5079
9da1b30984 i'm dumb 2020-08-08 16:29:04 +09:00
tom5079
9415ab4ef9 Fixed some images crashing
Added auto pageturn timer
2020-08-08 15:55:17 +09:00
tom5079
647294daf2 what were i thinking?! 2020-08-04 17:50:07 +09:00
tom5079
6ebc386474 Fixed app crashing when deleting cache/download 2020-08-04 12:14:14 +09:00
tom5079
3e657bdc09 Merge branch 'dev' 2020-08-03 21:54:28 +09:00
tom5079
0b0adb76a1 Merge branch 'master' into dev
# Conflicts:
#	app/build.gradle
#	app/release/app-release.apk
#	app/release/output-metadata.json
2020-08-03 21:54:14 +09:00
tom5079
17b3e010aa fuck git 2020-08-03 21:49:04 +09:00
tom5079
20003acd73 App built
resolves #98
2020-08-03 21:34:24 +09:00
tom5079
2ab7672092 Search Backtracking Added 2020-08-03 21:31:39 +09:00
tom5079
c317abe64b Open Gallery Info dialog instead of opening up the gallery when opening a random gallery 2020-08-03 21:19:08 +09:00
tom5079
bc33ce1ebc Fix Settings opening up too late if the download folder is too big 2020-08-03 21:14:47 +09:00
Pupil
684c5cf38b Merge pull request #105 from tom5079/dev
4.19-hotfix2
2020-08-03 02:10:17 +09:00
tom5079
c34e15f0a1 Fixed image corruption when not using cache 2020-08-03 02:06:37 +09:00
Pupil
bad004f892 Merge pull request #104 from tom5079/dev
Version 4.19-hotfix1
2020-08-02 17:48:32 +09:00
tom5079
828d3de020 Fixed app crashing between android version 5.0 and 5.1 2020-08-02 17:45:24 +09:00
tom5079
132b3b9be1 Unable to fetch thumbnails fixed 2020-07-29 10:53:50 +09:00
Pupil
388bc6fda5 Merge pull request #103 from tom5079/Pupil-99
resolves #99
2020-07-29 10:28:01 +09:00
tom5079
a93edc184d resolves #99 2020-07-23 22:42:38 +09:00
tom5079
08672d10ac oops 2020-07-23 20:48:24 +09:00
tom5079
b563dae3a8 App rebuilt 2020-07-23 20:37:38 +09:00
tom5079
917f9672dd bug fix - unable to run on older devices
Version 4.18.4
2020-07-23 20:32:45 +09:00
Pupil
9ddb19530b Merge pull request #101 from tom5079/dev
Version 4.18.3
2020-07-12 20:09:12 +09:00
tom5079
431e56a9f1 Version 4.18.3 2020-07-12 20:07:59 +09:00
Pupil
71093aac4c Update README.md 2020-07-12 19:53:28 +09:00
tom5079
47c9e8127e Remove logic for resetting low quality setting 2020-07-07 10:21:30 +09:00
tom5079
24b801b346 resolves #52 2020-07-05 19:45:55 +09:00
tom5079
70608c3ed9 resolves #52 2020-07-05 19:40:14 +09:00
tom5079
f185196e85 Version Update 2020-07-05 19:33:56 +09:00
tom5079
a8766a8bbe Drawer logo fixed 2020-06-23 20:37:20 +09:00
Pupil
27a8c93cfe Merge pull request #97 from tom5079/Pupil-95
Pupil 95
2020-06-23 18:49:57 +09:00
tom5079
a3cd29fda9 Merge remote-tracking branch 'origin/Pupil-95' into Pupil-95 2020-06-23 18:48:24 +09:00
tom5079
adda8ab640 Pupil-95 Search tag icon not visible in light theme 2020-06-23 18:48:13 +09:00
tom5079
1538ea5fc8 Merge remote-tracking branch 'origin/dev' into dev 2020-06-23 18:47:58 +09:00
tom5079
2367a97a54 App built 2020-06-23 18:47:48 +09:00
tom5079
090ec0e4af Auto-Retry
Bug fix
2020-06-23 18:47:48 +09:00
tom5079
de7f552e5c Pupil-95 Search tag icon not visible in light theme 2020-06-23 18:40:43 +09:00
tom5079
d763f5dca0 App built 2020-06-22 09:52:25 +09:00
tom5079
9f41116241 Auto-Retry
Bug fix
2020-06-22 09:48:04 +09:00
Pupil
57faada201 Merge pull request #93 from tom5079/dev
Version 4.18.1
2020-06-21 23:03:59 +09:00
tom5079
1edb95f0c5 Prevent OOM when cache is disabled 2020-06-21 23:03:10 +09:00
tom5079
9f363d8900 idk wtf i'm doin' 2020-06-21 22:43:47 +09:00
217 changed files with 6579 additions and 12333 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@
#Private files #Private files
**/google-services.json **/google-services.json
**/credentials.json

View File

@@ -2,6 +2,22 @@
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<option name="RIGHT_MARGIN" value="120" /> <option name="RIGHT_MARGIN" value="120" />
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="java.util" alias="false" withSubpackages="false" />
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="java" alias="false" withSubpackages="true" />
<package name="javax" alias="false" withSubpackages="true" />
<package name="kotlin" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value=" Copyright &amp;#36;today.year tom5079&#10;&#10; Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10; you may not use this file except in compliance with the License.&#10; You may obtain a copy of the License at&#10;&#10; http://www.apache.org/licenses/LICENSE-2.0&#10;&#10; Unless required by applicable law or agreed to in writing, software&#10; distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10; See the License for the specific language governing permissions and&#10; limitations under the License." />
<option name="myName" value="Apache" />
</copyright>
</component>

View File

@@ -1,8 +1,7 @@
<component name="CopyrightManager"> <component name="CopyrightManager">
<settings> <settings>
<module2copyright> <module2copyright>
<element module="Pupil" copyright="GPL" /> <element module="Project Files" copyright="GPL" />
<element module="libpupil" copyright="Apache" />
</module2copyright> </module2copyright>
</settings> </settings>
</component> </component>

7
.idea/dictionaries/tom50.xml generated Normal file
View File

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

2
.idea/gradle.xml generated
View File

@@ -11,10 +11,10 @@
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/libpupil" />
</set> </set>
</option> </option>
<option name="resolveModulePerSourceSet" value="false" /> <option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AndroidLintNewerVersionAvailable" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

75
.idea/jarRepositories.xml generated Normal file
View File

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

2
.idea/misc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

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

View File

@@ -1,19 +1,12 @@
# Pupil
![Banner](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/pupil-banner.png?raw=true) ![Banner](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/pupil-banner.png?raw=true)
*Pupil, Hitomi.la viewer for Android* *Pupil, Hitomi.la viewer for Android*
![](https://img.shields.io/github/downloads/tom5079/Pupil/total)
[![](https://img.shields.io/github/downloads/tom5079/Pupil/5.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://discordapp.com/api/guilds/610452916612104194/embed.png?style=banner2)](https://discord.gg/Stj4b5v)
I can speak English, Japanese and Korean. If you have any questions, head over to my discord server or DM me!
# Screenshot # Features
![Main Screen](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/main-screenshot.png?raw=true) ![Main Screen](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/main-screenshot.jpg?raw=true)
*Main Screen*
![Reader Screen](https://github.com/tom5079/Pupil/blob/gh-pages/assets/images/reader-screenshot.png?raw=true)
*Reader Screen*
Images are censored to be SFW
# Installation # Installation
@@ -28,3 +21,6 @@ or Build app yourself
# Contribution # Contribution
Any kind of contribution is appriciated. Feel free to leave PR! Any kind of contribution is appriciated. Feel free to leave PR!
## Tag Translation
Head over to [tags branch](https://github.com/tom5079/Pupil/tree/tags)

View File

@@ -1,95 +1,137 @@
apply plugin: 'com.android.application' apply plugin: "com.android.application"
apply plugin: 'kotlin-android' apply plugin: "kotlin-android"
apply plugin: 'kotlin-kapt' apply plugin: "kotlin-kapt"
apply plugin: 'kotlin-android-extensions' apply plugin: "kotlin-parcelize"
apply plugin: 'kotlinx-serialization' apply plugin: "kotlinx-serialization"
apply plugin: "com.google.android.gms.oss-licenses-plugin"
if (file("google-services.json").exists() && file("src/debug/google-services.json").exists()) { if (file("google-services.json").exists()) {
logger.lifecycle("Firebase Enabled") logger.lifecycle("Firebase Enabled")
apply plugin: 'com.google.gms.google-services' apply plugin: "com.google.gms.google-services"
apply plugin: 'com.google.firebase.crashlytics' apply plugin: "com.google.firebase.crashlytics"
apply plugin: 'com.google.firebase.firebase-perf' apply plugin: "com.google.firebase.firebase-perf"
} else { } else {
logger.lifecycle("Firebase Disabled") logger.lifecycle("Firebase Disabled")
} }
ext {
okhttp_version = "3.12.12"
}
configurations {
all {
resolutionStrategy {
eachDependency { DependencyResolveDetails details ->
if (details.requested.group == "com.squareup.okhttp3" && details.requested.name == "okhttp") {
// OkHttp drops support before 5.0 since 3.13.0
details.useVersion okhttp_version
}
}
}
}
}
android { android {
compileSdkVersion 29 compileSdkVersion 30
defaultConfig { defaultConfig {
applicationId "xyz.quaver.pupil" applicationId "xyz.quaver.pupil"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 29 targetSdkVersion 30
versionCode 53 versionCode 64
versionName "4.18" versionName "5.1.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
} }
buildTypes { buildTypes {
debug { debug {
minifyEnabled true
shrinkResources true
debuggable true debuggable true
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
versionNameSuffix "-DEBUG" versionNameSuffix "-DEBUG"
buildConfigField('Boolean', 'CENSOR', 'false') proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
ext.enableCrashlytics = false
ext.alwaysUpdateBuildId = false
} }
release { release {
buildConfigField('Boolean', 'CENSOR', 'false') minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' shrinkResources true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
} }
} }
buildFeatures {
viewBinding true
}
kotlinOptions { kotlinOptions {
freeCompilerArgs += '-Xuse-experimental=kotlin.Experimental' jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs += "-Xuse-experimental=kotlin.Experimental"
} }
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
} }
buildToolsVersion = '29.0.3'
} }
dependencies { dependencies {
def markwonVersion = '3.1.0' implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0"
implementation "androidx.appcompat:appcompat:1.3.0"
implementation "androidx.activity:activity-ktx:1.3.0-beta01"
implementation "androidx.fragment:fragment-ktx:1.3.4"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation "androidx.gridlayout:gridlayout:1.0.0"
implementation "androidx.biometric:biometric:1.1.0"
implementation "androidx.work:work-runtime-ktx:2.6.0-beta01"
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.20.0"
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'androidx.preference:preference:1.1.1'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation "androidx.biometric:biometric:1.0.1"
implementation 'androidx.multidex:multidex:2.0.1'
implementation "com.daimajia.swipelayout:library:1.2.0@aar" implementation "com.daimajia.swipelayout:library:1.2.0@aar"
implementation 'com.google.android.material:material:1.3.0-alpha01'
implementation 'com.google.firebase:firebase-core:17.4.3'
implementation 'com.google.firebase:firebase-analytics:17.4.3'
implementation 'com.google.firebase:firebase-crashlytics:17.1.0'
implementation 'com.google.firebase:firebase-perf:19.0.7'
implementation 'com.github.arimorty:floatingsearchview:2.1.1'
implementation 'com.github.clans:fab:1.6.4'
implementation 'com.github.bumptech.glide:glide:4.11.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'
kapt 'com.github.bumptech.glide:compiler:4.11.0'
implementation ("com.github.bumptech.glide:recyclerview-integration:4.11.0") {
transitive = false
}
implementation 'net.rdrei.android.dirchooser:library:3.2@aar'
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
implementation 'com.andrognito.patternlockview:patternlockview:1.0.0'
//implementation 'com.andrognito.pinlockview:pinlockview:2.1.0'
implementation "ru.noties.markwon:core:${markwonVersion}"
testImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation project(path: ':libpupil')
}
androidExtensions { implementation "com.google.android.material:material:1.3.0"
experimental = true
implementation platform('com.google.firebase:firebase-bom:26.5.0')
implementation "com.google.firebase:firebase-analytics-ktx"
implementation "com.google.firebase:firebase-crashlytics-ktx"
implementation "com.google.firebase:firebase-perf-ktx"
implementation "com.google.android.gms:play-services-oss-licenses:17.0.0"
implementation "com.google.android.gms:play-services-mlkit-face-detection:16.1.7"
implementation "com.github.clans:fab:1.6.4"
//implementation "com.quiph.ui:recyclerviewfastscroller:0.2.1"
implementation 'com.github.piasy:BigImageViewer:1.8.0'
implementation 'com.github.piasy:FrescoImageLoader:1.8.0'
implementation 'com.github.piasy:FrescoImageViewFactory:1.8.0'
//noinspection GradleDependency
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "com.tbuonomo.andrui:viewpagerdotsindicator:4.1.2"
implementation "net.rdrei.android.dirchooser:library:3.2@aar"
implementation "com.gu:option:1.3"
implementation "com.andrognito.patternlockview:patternlockview:1.0.0"
//implementation "com.andrognito.pinlockview:pinlockview:2.1.0"
implementation "ru.noties.markwon:core:3.1.0"
implementation "xyz.quaver:libpupil:2.1.0"
implementation "xyz.quaver:documentfilex:0.6.1"
implementation "xyz.quaver:floatingsearchview:1.1.7"
testImplementation "junit:junit:4.13.1"
androidTestImplementation "androidx.test.ext:junit:1.1.2"
androidTestImplementation "androidx.test:rules:1.3.0"
androidTestImplementation "androidx.test:runner:1.3.0"
androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
} }

Binary file not shown.

View File

@@ -22,14 +22,14 @@
-dontobfuscate -dontobfuscate
-keep public class * implements com.bumptech.glide.module.GlideModule -keepattributes *Annotation*, InnerClasses
-keep class * extends com.bumptech.glide.module.AppGlideModule { -dontnote kotlinx.serialization.SerializationKt
<init>(...); -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;
} }
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { -keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
**[] $VALUES; kotlinx.serialization.KSerializer serializer(...);
public *;
}
-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder {
*** rewind();
} }
-keep class xyz.quaver.pupil.ui.fragment.ManageFavoritesFragment
-keep class xyz.quaver.pupil.ui.fragment.ManageStorageFragment

View File

@@ -1,5 +1,5 @@
{ {
"version": 1, "version": 2,
"artifactType": { "artifactType": {
"type": "APK", "type": "APK",
"kind": "Directory" "kind": "Directory"
@@ -10,10 +10,8 @@
{ {
"type": "SINGLE", "type": "SINGLE",
"filters": [], "filters": [],
"properties": [], "versionCode": 64,
"versionCode": 53, "versionName": "5.1.9",
"versionName": "53",
"enabled": true,
"outputFile": "app-release.apk" "outputFile": "app-release.apk"
} }
] ]

View File

@@ -20,24 +20,10 @@
package xyz.quaver.pupil package xyz.quaver.pupil
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import kotlinx.coroutines.runBlocking
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import xyz.quaver.hitomi.getGalleryIDsFromNozomi
import xyz.quaver.hiyobi.cookie
import xyz.quaver.hiyobi.createImgList
import xyz.quaver.hiyobi.getReader
import xyz.quaver.hiyobi.user_agent
import xyz.quaver.pupil.ui.LockActivity
import xyz.quaver.pupil.util.download.Cache
import xyz.quaver.pupil.util.download.DownloadWorker
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
@@ -52,70 +38,4 @@ class ExampleInstrumentedTest {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
} }
@Test
fun checkCacheDir() {
val activityTestRule = ActivityTestRule(LockActivity::class.java)
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
ContextCompat.getExternalFilesDirs(appContext, null).forEachIndexed { index, file ->
Log.i("PUPILD", "$index: ${file?.absolutePath}")
}
}
@Test
fun test_nozomi() {
val nozomi = getGalleryIDsFromNozomi(null, "index", "all")
Log.i("PUPILD", nozomi.size.toString())
}
@Test
fun test_doSearch() {
val reader = getReader( 1426382)
val data: ByteArray
with(URL(createImgList(1426382, reader)[0].path).openConnection() as HttpsURLConnection) {
setRequestProperty("User-Agent", user_agent)
setRequestProperty("Cookie", cookie)
data = inputStream.readBytes()
}
Log.d("Pupil", data.size.toString())
}
@Test
fun test_downloadWorker() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val galleryID = 515515
val worker = DownloadWorker.getInstance(context)
worker.queue.add(galleryID)
while(worker.progress.indexOfKey(galleryID) < 0 || worker.progress[galleryID] != null) {
Log.i("PUPILD", worker.progress[galleryID]?.joinToString(" ") ?: "null")
if (worker.progress[galleryID]?.all { !it.isFinite() } == true)
break
}
Log.i("PUPILD", "DONE!!")
}
@Test
fun test_getReaderOrNull() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val galleryID = 1561552
runBlocking {
Log.i("PUPILD", Cache(context).getReader(galleryID)?.galleryInfo?.title ?: "null")
}
Log.i("PUPILD", Cache(context).getReaderOrNull(galleryID)?.galleryInfo?.title ?: "null")
}
} }

View File

@@ -6,10 +6,14 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission-sdk-23 android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission-sdk-23 android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application <application
android:name=".Pupil" android:name=".Pupil"
@@ -20,10 +24,14 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:networkSecurityConfig="@xml/network_security_config"
tools:replace="android:theme" tools:replace="android:theme"
android:requestLegacyExternalStorage="true"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="face" />
<provider <provider
android:authorities="${applicationId}.provider" android:authorities="${applicationId}.provider"
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -36,9 +44,14 @@
</provider> </provider>
<receiver android:name=".BroadcastReciever" android:exported="true"> <service android:name=".services.DownloadService"
android:exported="false"/>
<receiver
android:name=".receiver.UpdateBroadcastReceiver"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE"/> <action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter> </intent-filter>
</receiver> </receiver>
@@ -204,7 +217,9 @@
</activity> </activity>
<activity <activity
android:name=".ui.SettingsActivity" android:name=".ui.SettingsActivity"
android:label="@string/settings_title" /> android:label="@string/settings_title">
<tools:validation testUrl="http://ix.io/eer" />
</activity>
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize" android:configChanges="keyboardHidden|orientation|screenSize"
@@ -215,6 +230,17 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="http"
android:host="ix.io"
android:pathPattern="/..*" />
</intent-filter>
</activity> </activity>
<activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" /> <activity android:name="net.rdrei.android.dirchooser.DirectoryChooserActivity" />
</application> </application>

View File

@@ -1,35 +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 com.arlib.floatingsearchview
import android.content.Context
import android.os.Parcelable
import android.util.AttributeSet
class FloatingSearchViewDayNight @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null)
: FloatingSearchView(context, attrs) {
// hack to remove color attributes which should not be reused
override fun onSaveInstanceState(): Parcelable? {
super.onSaveInstanceState()
return null
}
}

View File

@@ -18,71 +18,125 @@
package xyz.quaver.pupil package xyz.quaver.pupil
import android.app.Application
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.multidex.MultiDexApplication
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.fresco.FrescoImageLoader
import com.google.android.gms.common.GooglePlayServicesNotAvailableException import com.google.android.gms.common.GooglePlayServicesNotAvailableException
import com.google.android.gms.common.GooglePlayServicesRepairableException import com.google.android.gms.common.GooglePlayServicesRepairableException
import com.google.android.gms.security.ProviderInstaller import com.google.android.gms.security.ProviderInstaller
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import xyz.quaver.proxy import okhttp3.Dispatcher
import xyz.quaver.pupil.util.Histories import okhttp3.Interceptor
import xyz.quaver.pupil.util.getProxy import okhttp3.OkHttpClient
import okhttp3.Response
import xyz.quaver.io.FileX
import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.*
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.setClient
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.reflect.KClass
class Pupil : MultiDexApplication() { typealias PupilInterceptor = (Interceptor.Chain) -> Response
lateinit var histories: Histories lateinit var histories: SavedSet<Int>
lateinit var favorites: Histories private set
lateinit var favorites: SavedSet<Int>
private set
lateinit var favoriteTags: SavedSet<Tag>
private set
lateinit var searchHistory: SavedSet<String>
private set
init { val interceptors = mutableMapOf<KClass<out Any>, PupilInterceptor>()
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
lateinit var clientBuilder: OkHttpClient.Builder
var clientHolder: OkHttpClient? = null
val client: OkHttpClient
get() = clientHolder ?: clientBuilder.build().also {
clientHolder = it
setClient(it)
} }
class Pupil : Application() {
override fun onCreate() { override fun onCreate() {
val preference = PreferenceManager.getDefaultSharedPreferences(this) AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
val userID = preferences = PreferenceManager.getDefaultSharedPreferences(this)
if (preference.getString("user_id", "").isNullOrEmpty()) {
UUID.randomUUID().toString().also { val userID = Preferences["user_id", ""].let { userID ->
preference.edit().putString("user_id", it).apply() if (userID.isEmpty()) UUID.randomUUID().toString().also { Preferences["user_id"] = it }
else userID
} }
} else
preference.getString("user_id", "") ?: ""
FirebaseCrashlytics.getInstance().setUserId(userID) FirebaseCrashlytics.getInstance().setUserId(userID)
proxy = getProxy(this) val proxyInfo = getProxyInfo()
clientBuilder = OkHttpClient.Builder()
.connectTimeout(0, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.proxyInfo(proxyInfo)
.addInterceptor { chain ->
val request = chain.request()
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 { try {
preference.getString("dl_location", null).also { Preferences.get<String>("download_folder").also {
if (!File(it!!).canWrite()) if (it.startsWith("content") && Build.VERSION.SDK_INT > 19)
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() throw Exception()
} }
} catch (e: Exception) { } catch (e: Exception) {
preference.edit().remove("dl_location").apply() Preferences.remove("download_folder")
} }
if (!preference.getBoolean("low_quality_reset", false)) { if (!Preferences["reset_secure", false]) {
preference.edit() Preferences["security_mode"] = false
.putBoolean("low_quality", true) Preferences["reset_secure"] = true
.putBoolean("low_quality_reset", true)
.apply()
} }
histories = Histories(File(ContextCompat.getDataDir(this), "histories.json")) histories = SavedSet(File(ContextCompat.getDataDir(this), "histories.json"), 0)
favorites = Histories(File(ContextCompat.getDataDir(this), "favorites.json")) 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) if (BuildConfig.DEBUG)
FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false) FirebaseAnalytics.getInstance(this).setAnalyticsCollectionEnabled(false)*/
try { try {
ProviderInstaller.installIfNeeded(this) ProviderInstaller.installIfNeeded(this)
@@ -92,6 +146,8 @@ class Pupil : MultiDexApplication() {
e.printStackTrace() e.printStackTrace()
} }
BigImageViewer.initialize(FrescoImageLoader.with(this))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -102,6 +158,13 @@ class Pupil : MultiDexApplication() {
lockscreenVisibility = Notification.VISIBILITY_SECRET lockscreenVisibility = Notification.VISIBILITY_SECRET
}) })
manager.createNotificationChannel(NotificationChannel("downloader", getString(R.string.channel_downloader), NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.channel_downloader_description)
enableLights(false)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_SECRET
})
manager.createNotificationChannel(NotificationChannel("update", getString(R.string.channel_update), NotificationManager.IMPORTANCE_HIGH).apply { manager.createNotificationChannel(NotificationChannel("update", getString(R.string.channel_update), NotificationManager.IMPORTANCE_HIGH).apply {
description = getString(R.string.channel_update_description) description = getString(R.string.channel_update_description)
enableLights(true) enableLights(true)
@@ -109,7 +172,7 @@ class Pupil : MultiDexApplication() {
lockscreenVisibility = Notification.VISIBILITY_SECRET lockscreenVisibility = Notification.VISIBILITY_SECRET
}) })
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_HIGH).apply { manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_LOW).apply {
description = getString(R.string.channel_update_description) description = getString(R.string.channel_update_description)
enableLights(false) enableLights(false)
enableVibration(false) enableVibration(false)
@@ -117,8 +180,7 @@ class Pupil : MultiDexApplication() {
}) })
} }
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) AppCompatDelegate.setDefaultNightMode(when (Preferences.get<Boolean>("dark_mode")) {
AppCompatDelegate.setDefaultNightMode(when (preference.getBoolean("dark_mode", false)) {
true -> AppCompatDelegate.MODE_NIGHT_YES true -> AppCompatDelegate.MODE_NIGHT_YES
false -> AppCompatDelegate.MODE_NIGHT_NO false -> AppCompatDelegate.MODE_NIGHT_NO
}) })

View File

@@ -18,105 +18,98 @@
package xyz.quaver.pupil.adapters package xyz.quaver.pupil.adapters
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.Base64
import android.util.SparseBooleanArray
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.Toast
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.vectordrawable.graphics.drawable.Animatable2Compat import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.bumptech.glide.RequestManager
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.daimajia.swipe.SwipeLayout import com.daimajia.swipe.SwipeLayout
import com.daimajia.swipe.adapters.RecyclerSwipeAdapter import com.daimajia.swipe.adapters.RecyclerSwipeAdapter
import com.daimajia.swipe.interfaces.SwipeAdapterInterface import com.daimajia.swipe.interfaces.SwipeAdapterInterface
import com.google.android.material.chip.Chip import com.github.piasy.biv.loader.ImageLoader
import kotlinx.android.synthetic.main.item_galleryblock.view.* import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope import xyz.quaver.hitomi.getGallery
import kotlinx.coroutines.Dispatchers import xyz.quaver.hitomi.getGalleryInfo
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.getReader import xyz.quaver.hitomi.getReader
import xyz.quaver.pupil.BuildConfig import xyz.quaver.io.util.getChild
import xyz.quaver.pupil.Pupil
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.GalleryblockItemBinding
import xyz.quaver.pupil.favoriteTags
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.types.Tag import xyz.quaver.pupil.types.Tag
import xyz.quaver.pupil.util.Histories import xyz.quaver.pupil.ui.view.ProgressCard
import xyz.quaver.pupil.util.download.Cache 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 xyz.quaver.pupil.util.wordCapitalize
import java.util.* import java.io.File
import kotlin.collections.ArrayList
import kotlin.concurrent.schedule
class GalleryBlockAdapter(private val glide: RequestManager, private val galleries: List<GalleryBlock>) : RecyclerSwipeAdapter<RecyclerView.ViewHolder>(), SwipeAdapterInterface { class GalleryBlockAdapter(private val galleries: List<Int>) : RecyclerSwipeAdapter<RecyclerView.ViewHolder>(), SwipeAdapterInterface {
enum class ViewType { var updateAll = true
NEXT, var thin: Boolean = Preferences["thin"]
GALLERY,
PREV
}
private lateinit var favorites: Histories inner class GalleryViewHolder(val binding: GalleryblockItemBinding) : RecyclerView.ViewHolder(binding.root) {
private var galleryID: Int = 0
val timer = Timer()
var isThin = false
inner class GalleryViewHolder(val view: View) : RecyclerView.ViewHolder(view) {
var timerTask: TimerTask? = null
private fun updateProgress(context: Context, galleryID: Int) {
val reader = Cache(context).getReaderOrNull(galleryID)
init {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
if (reader == null || PreferenceManager.getDefaultSharedPreferences(context).getBoolean("cache_disable", false)) { while (updateAll) {
view.galleryblock_progressbar.visibility = View.GONE updateProgress(itemView.context)
view.galleryblock_progress_complete.visibility = View.GONE delay(1000)
return@launch }
}
} }
with(view.galleryblock_progressbar) { private fun updateProgress(context: Context) = CoroutineScope(Dispatchers.Main).launch {
with(binding.galleryblockCard) {
val imageList = Cache.getInstance(context, galleryID).metadata.imageList
progress = Cache(context).getImages(galleryID)?.size ?: 0 if (imageList == null) {
max = 0
if (visibility == View.GONE) { return@with
visibility = View.VISIBLE
max = reader.galleryInfo.files.size
} }
if (progress == max) { progress = imageList.count { it != null }
if (completeFlag.get(galleryID, false)) { max = imageList.size
with(view.galleryblock_progress_complete) {
setImageResource(R.drawable.ic_progressbar) this@GalleryViewHolder.binding.galleryblockId.setOnClickListener {
visibility = View.VISIBLE (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
} ClipData.newPlainText("gallery_id", galleryID.toString())
} else { )
with(view.galleryblock_progress_complete) { Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
setImageDrawable(AnimatedVectorDrawableCompat.create(context, R.drawable.ic_progressbar_complete).apply {
this?.start()
})
visibility = View.VISIBLE
}
completeFlag.put(galleryID, true)
} }
type = if (!imageList.contains(null)) {
val downloadManager = DownloadManager.getInstance(context)
if (downloadManager.getDownloadFolder(galleryID) == null)
ProgressCard.Type.CACHE
else
ProgressCard.Type.DOWNLOAD
} else } else
view.galleryblock_progress_complete.visibility = View.INVISIBLE ProgressCard.Type.LOADING
}
} }
} }
fun bind(galleryBlock: GalleryBlock) { fun bind(galleryID: Int) {
with(view) { this.galleryID = galleryID
val resources = context.resources updateProgress(itemView.context)
val cache = Cache.getInstance(itemView.context, galleryID)
val galleryBlock = runBlocking {
cache.getGalleryBlock()
} ?: return
val resources = itemView.context.resources
val languages = resources.getStringArray(R.array.languages).map { val languages = resources.getStringArray(R.array.languages).map {
it.split("|").let { split -> it.split("|").let { split ->
Pair(split[0], split[1]) Pair(split[0], split[1])
@@ -126,68 +119,63 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
val artists = galleryBlock.artists val artists = galleryBlock.artists
val series = galleryBlock.series val series = galleryBlock.series
if (isThin) binding.galleryblockThumbnail.apply {
galleryblock_thumbnail.layoutParams.width = context.resources.getDimensionPixelSize( setOnClickListener {
R.dimen.galleryblock_thumbnail_thin itemView.performClick()
) }
galleryblock_thumbnail.setImageDrawable(CircularProgressDrawable(context).also { setOnLongClickListener {
it.start() itemView.performLongClick()
}
setFailureImage(ContextCompat.getDrawable(context, R.drawable.image_broken_variant))
setImageLoaderCallback(object: ImageLoader.Callback {
override fun onFail(error: Exception?) {
Cache.getInstance(context, galleryID).let { cache ->
cache.cacheFolder.getChild(".thumbnail").let { if (it.exists()) it.delete() }
cache.downloadFolder?.getChild(".thumbnail")?.let { if (it.exists()) it.delete() }
}
}
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.Main).launch { CoroutineScope(Dispatchers.IO).launch {
val thumbnail = Cache(context).getThumbnail(galleryBlock.id).let { cache.getThumbnail().let { launch(Dispatchers.Main) {
if (it != null) showImage(it)
Base64.decode(it, Base64.DEFAULT) } }
else
null
}
galleryblock_thumbnail.post {
glide
.load(thumbnail)
.skipMemoryCache(true)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.error(R.drawable.image_broken_variant)
.apply {
if (BuildConfig.CENSOR)
override(5, 8)
}
.into(galleryblock_thumbnail)
} }
} }
//Check cache binding.galleryblockTitle.text = galleryBlock.title
val cache = Cache(context).getCachedGallery(galleryBlock.id) with(binding.galleryblockArtist) {
val reader = Cache(context).getReaderOrNull(galleryBlock.id) text = artists.joinToString { it.wordCapitalize() }
if (reader != null) {
val count = cache.listFiles()?.count {
Regex("^[0-9]+.+\$").matches(it.name)
} ?: 0
with(galleryblock_progressbar) {
max = reader.galleryInfo.files.size
progress = count
visibility = View.VISIBLE
}
} else
galleryblock_progressbar.visibility = View.GONE
if (timerTask == null)
timerTask = timer.schedule(0, 1000) {
updateProgress(context, galleryBlock.id)
}
galleryblock_title.text = galleryBlock.title
with(galleryblock_artist) {
text = artists.joinToString(", ") { it.wordCapitalize() }
visibility = when { visibility = when {
artists.isNotEmpty() -> View.VISIBLE artists.isNotEmpty() -> View.VISIBLE
else -> View.GONE else -> View.GONE
} }
CoroutineScope(Dispatchers.IO).launch {
val gallery = runCatching {
getGallery(galleryID)
}.getOrNull()
if (gallery?.groups?.isNotEmpty() != true)
return@launch
launch(Dispatchers.Main) {
text = context.getString(
R.string.galleryblock_artist_with_group,
artists.joinToString { it.wordCapitalize() },
gallery.groups.joinToString { it.wordCapitalize() }
)
} }
with(galleryblock_series) { }
}
with(binding.galleryblockSeries) {
text = text =
resources.getString( resources.getString(
R.string.galleryblock_series, R.string.galleryblock_series,
@@ -197,8 +185,8 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
else -> View.GONE else -> View.GONE
} }
} }
galleryblock_type.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize() binding.galleryblockType.text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
with(galleryblock_language) { with(binding.galleryblockLanguage) {
text = text =
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language]) resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
visibility = when { visibility = when {
@@ -207,53 +195,51 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
} }
} }
galleryblock_tag_group.removeAllViews() with(binding.galleryblockTagGroup) {
galleryBlock.relatedTags.forEach { onClickListener = {
galleryblock_tag_group.addView(Chip(context).apply { onChipClickedHandler.forEach { callback ->
val tag = Tag.parse(it).let { tag -> callback.invoke(it)
when {
tag.area != null -> tag
else -> Tag("tag", it)
} }
} }
chipIcon = when(tag.area) { tags.clear()
"male" -> {
setChipBackgroundColorResource(R.color.material_blue_700) CoroutineScope(Dispatchers.IO).launch {
setTextColor(ContextCompat.getColor(context, android.R.color.white)) tags.addAll(
ContextCompat.getDrawable(context, R.drawable.gender_male) 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
} }
"female" -> { }.map {
setChipBackgroundColorResource(R.color.material_pink_600) Tag.parse(it)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
ContextCompat.getDrawable(context, R.drawable.gender_female)
} }
else -> null )
launch(Dispatchers.Main) {
refresh()
} }
text = tag.tag.wordCapitalize()
setEnsureMinTouchTargetSize(false)
setOnClickListener {
for (callback in onChipClickedHandler)
callback.invoke(tag)
} }
})
} }
galleryblock_id.text = galleryBlock.id.toString() binding.galleryblockId.text = galleryBlock.id.toString()
galleryblock_pagecount.text = "-" binding.galleryblockPagecount.text = "-"
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val pageCount = kotlin.runCatching { val pageCount = kotlin.runCatching {
getReader(galleryBlock.id).galleryInfo.files.size getGalleryInfo(galleryBlock.id).files.size
}.getOrNull() ?: return@launch }.getOrNull() ?: return@launch
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
galleryblock_pagecount.text = context.getString(R.string.galleryblock_pagecount, pageCount) binding.galleryblockPagecount.text = itemView.context.getString(R.string.galleryblock_pagecount, pageCount)
} }
} }
if (!::favorites.isInitialized) with(binding.galleryblockFavorite) {
favorites = (context.applicationContext as Pupil).favorites
with(galleryblock_favorite) {
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty) setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
setOnClickListener { setOnClickListener {
when { when {
@@ -282,92 +268,45 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
// Make some views invisible to make it thinner // Make some views invisible to make it thinner
if (isThin) { if (thin) {
galleryblock_language.visibility = View.GONE binding.galleryblockTagGroup.visibility = View.GONE
galleryblock_type.visibility = View.GONE
galleryblock_tag_group.visibility = View.GONE
} }
} }
} }
}
class NextViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
class PrevViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
class ViewHolderFactory {
companion object {
fun getLayoutID(type: Int): Int {
return when(ViewType.values()[type]) {
ViewType.NEXT -> R.layout.item_next
ViewType.PREV -> R.layout.item_prev
ViewType.GALLERY -> R.layout.item_galleryblock
}
}
}
}
val completeFlag = SparseBooleanArray()
val onChipClickedHandler = ArrayList<((Tag) -> Unit)>() val onChipClickedHandler = ArrayList<((Tag) -> Unit)>()
var onDownloadClickedHandler: ((Int) -> Unit)? = null var onDownloadClickedHandler: ((Int) -> Unit)? = null
var onDeleteClickedHandler: ((Int) -> Unit)? = null var onDeleteClickedHandler: ((Int) -> Unit)? = null
var showNext = false
var showPrev = false
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return GalleryViewHolder(GalleryblockItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
fun getViewHolder(type: Int, view: View): RecyclerView.ViewHolder {
return when(ViewType.values()[type]) {
ViewType.NEXT -> NextViewHolder(view as LinearLayout)
ViewType.PREV -> PrevViewHolder(view as LinearLayout)
ViewType.GALLERY -> GalleryViewHolder(view as CardView)
}
}
return getViewHolder(
viewType,
LayoutInflater.from(parent.context).inflate(
ViewHolderFactory.getLayoutID(viewType),
parent,
false
)
)
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is GalleryViewHolder) { if (holder is GalleryViewHolder) {
val gallery = galleries[position-(if (showPrev) 1 else 0)] val galleryID = galleries[position]
holder.bind(gallery) holder.bind(galleryID)
with(holder.view.galleryblock_primary) { holder.binding.galleryblockCard.binding.download.setOnClickListener {
setOnClickListener {
holder.view.performClick()
}
setOnLongClickListener {
holder.view.performLongClick()
}
}
holder.view.galleryblock_download.setOnClickListener {
onDownloadClickedHandler?.invoke(position) onDownloadClickedHandler?.invoke(position)
} }
holder.view.galleryblock_delete.setOnClickListener { holder.binding.galleryblockCard.binding.delete.setOnClickListener {
onDeleteClickedHandler?.invoke(position) onDeleteClickedHandler?.invoke(position)
} }
mItemManger.bindView(holder.view, position) mItemManger.bindView(holder.binding.root, position)
holder.view.galleryblock_swipe_layout.addSwipeListener(object: SwipeLayout.SwipeListener { holder.binding.galleryblockCard.binding.swipeLayout.addSwipeListener(object: SwipeLayout.SwipeListener {
override fun onStartOpen(layout: SwipeLayout?) { override fun onStartOpen(layout: SwipeLayout?) {
mItemManger.closeAllExcept(layout) mItemManger.closeAllExcept(layout)
holder.view.galleryblock_download.text = holder.binding.galleryblockCard.binding.download.text =
if (Cache(holder.view.context).isDownloading(gallery.id)) if (DownloadManager.getInstance(holder.binding.root.context).isDownloading(galleryID))
holder.view.context.getString(android.R.string.cancel) holder.binding.root.context.getString(android.R.string.cancel)
else else
holder.view.context.getString(R.string.main_download) holder.binding.root.context.getString(R.string.main_download)
} }
override fun onClose(layout: SwipeLayout?) {} override fun onClose(layout: SwipeLayout?) {}
@@ -379,27 +318,7 @@ class GalleryBlockAdapter(private val glide: RequestManager, private val galleri
} }
} }
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) { override fun getItemCount() = galleries.size
super.onViewDetachedFromWindow(holder)
if (holder is GalleryViewHolder) { override fun getSwipeLayoutResourceId(position: Int) = R.id.swipe_layout
holder.timerTask?.cancel()
holder.timerTask = null
}
}
override fun getItemCount() =
(if (galleries.isEmpty()) 0 else galleries.size)+
(if (showNext) 1 else 0)+
(if (showPrev) 1 else 0)
override fun getItemViewType(position: Int): Int {
return when {
showPrev && position == 0 -> ViewType.PREV
showNext && position == galleries.size+(if (showPrev) 1 else 0) -> ViewType.NEXT
else -> ViewType.GALLERY
}.ordinal
}
override fun getSwipeLayoutResourceId(position: Int) = R.id.galleryblock_swipe_layout
} }

View File

@@ -1,87 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.item_mirrors.view.*
import xyz.quaver.pupil.R
import java.util.*
class MirrorAdapter(context: Context) : RecyclerView.Adapter<MirrorAdapter.ViewHolder>() {
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view)
val mirrors = context.resources.getStringArray(R.array.mirrors).map {
it.split('|').let { split ->
Pair(split.first(), split.last())
}
}.toMap()
val list = mirrors.keys.toMutableList().apply {
PreferenceManager.getDefaultSharedPreferences(context)
.getString("mirrors", "")!!
.split(">")
.reversed()
.forEach {
if (this.contains(it)) {
this.remove(it)
this.add(0, it)
}
}
}
val onItemMove : ((Int, Int) -> Unit) = { from, to ->
Collections.swap(list, from, to)
notifyItemMoved(from, to)
onItemMoved?.invoke(list)
}
var onStartDrag : ((ViewHolder) -> Unit)? = null
var onItemMoved : ((List<String>) -> (Unit))? = null
@SuppressLint("ClickableViewAccessibility")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
with(holder.view) {
mirror_name.text = mirrors[list.elementAt(position)]
mirror_button.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN)
onStartDrag?.invoke(holder)
true
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return LayoutInflater.from(parent.context).inflate(
R.layout.item_mirrors, parent, false
).let {
ViewHolder(it)
}
}
override fun getItemCount() = mirrors.size
}

View File

@@ -18,133 +18,233 @@
package xyz.quaver.pupil.adapters 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.RequestManager import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.facebook.drawee.backends.pipeline.Fresco
import com.google.android.material.snackbar.Snackbar import com.facebook.drawee.controller.BaseControllerListener
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.facebook.drawee.drawable.ScalingUtils
import kotlinx.android.synthetic.main.activity_reader.view.* import com.facebook.drawee.interfaces.DraweeController
import kotlinx.android.synthetic.main.item_reader.view.* import com.facebook.drawee.view.SimpleDraweeView
import kotlinx.coroutines.CoroutineScope import com.facebook.imagepipeline.image.ImageInfo
import kotlinx.coroutines.Dispatchers import com.github.piasy.biv.view.BigImageView
import kotlinx.coroutines.launch import com.github.piasy.biv.view.ImageShownCallback
import xyz.quaver.hitomi.Reader import com.github.piasy.biv.view.ImageViewFactory
import kotlinx.coroutines.*
import xyz.quaver.hitomi.GalleryInfo
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.download.DownloadWorker import xyz.quaver.pupil.databinding.ReaderItemBinding
import java.util.* import xyz.quaver.pupil.ui.ReaderActivity
import kotlin.concurrent.schedule import xyz.quaver.pupil.util.downloader.Cache
import java.io.File
import kotlin.math.roundToInt import kotlin.math.roundToInt
class ReaderAdapter(private val glide: RequestManager, class ReaderAdapter(
private val galleryID: Int) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() { private val activity: ReaderActivity,
private val galleryID: Int
var reader: Reader? = null ) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
val timer = Timer() var reader: GalleryInfo? = null
var isFullScreen = false var isFullScreen = false
var onItemClickListener : ((Int) -> (Unit))? = null var onItemClickListener : (() -> (Unit))? = null
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view) inner class ViewHolder(private val binding: ReaderItemBinding) : RecyclerView.ViewHolder(binding.root) {
init {
var downloadWorker: DownloadWorker? = null with (binding.image) {
setImageViewFactory(FrescoImageViewFactory().apply {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { updateView = { imageInfo ->
return LayoutInflater.from(parent.context).inflate( binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
R.layout.item_reader, parent, false dimensionRatio = "${imageInfo.width}:${imageInfo.height}"
).let { }
ViewHolder(it) }
})
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 onBindViewHolder(holder: ViewHolder, position: Int) { override fun onThumbnailShown() {}
holder.view as ConstraintLayout })
if (downloadWorker == null) setFailureImage(ContextCompat.getDrawable(itemView.context, R.drawable.image_broken_variant))
downloadWorker = DownloadWorker.getInstance(holder.view.context) setOnClickListener {
onItemClickListener?.invoke()
}
}
if (isFullScreen) { binding.root.setOnClickListener {
holder.view.layoutParams.height = RecyclerView.LayoutParams.MATCH_PARENT onItemClickListener?.invoke()
holder.view.container.layoutParams.height = ConstraintLayout.LayoutParams.MATCH_PARENT }
}
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 =
"${reader!!.files[position].width}:${reader!!.files[position].height}"
}
} else { } else {
holder.view.layoutParams.height = RecyclerView.LayoutParams.WRAP_CONTENT binding.root.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
holder.view.container.layoutParams.height = 0 binding.image.updateLayoutParams<ConstraintLayout.LayoutParams> {
height = ConstraintLayout.LayoutParams.MATCH_PARENT
(holder.view.container.layoutParams as ConstraintLayout.LayoutParams) dimensionRatio = null
.dimensionRatio = "W,${reader!!.galleryInfo.files[position].width}:${reader!!.galleryInfo.files[position].height}" }
binding.root.background = null
} }
holder.view.image.setOnPhotoTapListener { _, _, _ -> binding.readerIndex.text = (position+1).toString()
onItemClickListener?.invoke(position)
}
holder.view.container.setOnClickListener { val image = cache!!.getImage(position)
onItemClickListener?.invoke(position) val progress = activity.downloader?.progress?.get(galleryID)?.get(position)
}
holder.view.reader_index.text = (position+1).toString()
val image = downloadWorker!!.results[galleryID]?.get(position)
val progress = downloadWorker!!.progress[galleryID]?.get(position)
if (progress?.isInfinite() == true && image != null) { if (progress?.isInfinite() == true && image != null) {
holder.view.reader_item_progressbar.visibility = View.INVISIBLE binding.progressGroup.visibility = View.INVISIBLE
binding.image.showImage(image.uri)
holder.view.image.post {
glide
.load(image)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.fitCenter()
.error(R.drawable.image_broken_variant)
.into(holder.view.image)
}
} else { } else {
holder.view.reader_item_progressbar.visibility = View.VISIBLE binding.progressGroup.visibility = View.VISIBLE
binding.readerItemProgressbar.progress =
glide.clear(holder.view.image)
if (progress?.isNaN() == true) {
FirebaseCrashlytics.getInstance().recordException(
DownloadWorker.getInstance(holder.view.context).exception[galleryID]?.get(position)!!
)
glide
.load(R.drawable.image_broken_variant)
.into(holder.view.image)
Snackbar.make(holder.view.reader_layout, R.string.reader_error_retry, Snackbar.LENGTH_SHORT).apply {
setAction(android.R.string.no) { }
setAction(android.R.string.yes) {
downloadWorker!!.cancel(galleryID)
downloadWorker!!.queue.add(galleryID)
}
}.show()
return
} else {
holder.view.reader_item_progressbar.progress =
if (progress?.isInfinite() == true) if (progress?.isInfinite() == true)
100 100
else else
progress?.roundToInt() ?: 0 progress?.roundToInt() ?: 0
holder.view.image.setImageDrawable(null) clear()
}
timer.schedule(1000) {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
delay(1000)
notifyItemChanged(position) notifyItemChanged(position)
} }
} }
} }
fun clear() {
binding.image.mainView.let {
when (it) {
is SubsamplingScaleImageView ->
it.recycle()
is SimpleDraweeView ->
it.controller = null
}
}
}
} }
override fun getItemCount() = reader?.galleryInfo?.files?.size ?: 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ReaderItemBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
private var cache: Cache? = null
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(position)
}
override fun getItemCount() = reader?.files?.size ?: 0
override fun onViewRecycled(holder: ViewHolder) {
holder.clear()
}
} }
class FrescoImageViewFactory : ImageViewFactory() {
var updateView: ((ImageInfo) -> Unit)? = null
override fun createAnimatedImageView(
context: Context, imageType: Int,
initScaleType: Int
): View {
val view = SimpleDraweeView(context)
view.hierarchy.actualImageScaleType = scaleType(initScaleType)
return view
}
override fun loadAnimatedContent(
view: View, imageType: Int,
imageFile: File
) {
if (view is SimpleDraweeView) {
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
.setUri(Uri.parse("file://" + imageFile.absolutePath))
.setAutoPlayAnimations(true)
.setControllerListener(object: BaseControllerListener<ImageInfo>() {
override fun onIntermediateImageSet(id: String?, imageInfo: ImageInfo?) {
imageInfo?.let { updateView?.invoke(it) }
}
override fun onFinalImageSet(id: String?, imageInfo: ImageInfo?, animatable: Animatable?) {
imageInfo?.let { updateView?.invoke(it) }
}
})
.build()
view.controller = controller
}
}
override fun createThumbnailView(
context: Context,
scaleType: ImageView.ScaleType, willLoadFromNetwork: Boolean
): View {
return if (willLoadFromNetwork) {
val thumbnailView = SimpleDraweeView(context)
thumbnailView.hierarchy.actualImageScaleType = scaleType(scaleType)
thumbnailView
} else {
super.createThumbnailView(context, scaleType, false)
}
}
override fun loadThumbnailContent(view: View, thumbnail: Uri) {
if (view is SimpleDraweeView) {
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
.setUri(thumbnail)
.build()
view.controller = controller
}
}
private fun scaleType(value: Int): ScalingUtils.ScaleType {
return when (value) {
BigImageView.INIT_SCALE_TYPE_CENTER -> ScalingUtils.ScaleType.CENTER
BigImageView.INIT_SCALE_TYPE_CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
BigImageView.INIT_SCALE_TYPE_CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
BigImageView.INIT_SCALE_TYPE_FIT_END -> ScalingUtils.ScaleType.FIT_END
BigImageView.INIT_SCALE_TYPE_FIT_START -> ScalingUtils.ScaleType.FIT_START
BigImageView.INIT_SCALE_TYPE_FIT_XY -> ScalingUtils.ScaleType.FIT_XY
BigImageView.INIT_SCALE_TYPE_FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
else -> ScalingUtils.ScaleType.FIT_CENTER
}
}
private fun scaleType(scaleType: ImageView.ScaleType): ScalingUtils.ScaleType {
return when (scaleType) {
ImageView.ScaleType.CENTER -> ScalingUtils.ScaleType.CENTER
ImageView.ScaleType.CENTER_CROP -> ScalingUtils.ScaleType.CENTER_CROP
ImageView.ScaleType.CENTER_INSIDE -> ScalingUtils.ScaleType.CENTER_INSIDE
ImageView.ScaleType.FIT_END -> ScalingUtils.ScaleType.FIT_END
ImageView.ScaleType.FIT_START -> ScalingUtils.ScaleType.FIT_START
ImageView.ScaleType.FIT_XY -> ScalingUtils.ScaleType.FIT_XY
ImageView.ScaleType.FIT_CENTER -> ScalingUtils.ScaleType.FIT_CENTER
else -> ScalingUtils.ScaleType.FIT_CENTER
}
}
}

View File

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

View File

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

@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package xyz.quaver.pupil package xyz.quaver.pupil.receiver
import android.app.DownloadManager import android.app.DownloadManager
import android.app.PendingIntent import android.app.PendingIntent
@@ -28,18 +28,11 @@ import android.webkit.MimeTypeMap
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.preference.PreferenceManager import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.NOTIFICATION_ID_UPDATE import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.cancelImport
import java.io.File import java.io.File
class BroadcastReciever : BroadcastReceiver() { class UpdateBroadcastReceiver : BroadcastReceiver() {
companion object {
const val ACTION_CANCEL_IMPORT = "ACTION_CANCEL_IMPORT"
const val EXTRA_IMPORT_NOTIFICATION_ID = "EXTRA_IMPORT_NOTIFICATION_ID"
}
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
context ?: return context ?: return
@@ -48,12 +41,10 @@ class BroadcastReciever : BroadcastReceiver() {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> { DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
// Validate download // Validate download
val downloadID: Long = Preferences["update_download_id"]
val preference = PreferenceManager.getDefaultSharedPreferences(context)
val downloadID = preference.getLong("update_download_id", -1)
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadID) if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2) != downloadID)
return return
// Get target uri // Get target uri
@@ -62,19 +53,21 @@ class BroadcastReciever : BroadcastReceiver() {
.setFilterById(downloadID) .setFilterById(downloadID)
val uri = downloadManager.query(query).use { cursor -> val uri = downloadManager.query(query).use { cursor ->
cursor.moveToFirst() if (cursor.moveToFirst()) {
cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).let { cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).let {
val uri = Uri.parse(it) val uri = Uri.parse(it)
when (uri.scheme) { when (uri.scheme) {
"file" -> "file" ->
FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!)) FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", File(uri.path!!)
)
"content" -> uri "content" -> uri
else -> return else -> null
}
} }
} }
} else
null
} ?: return
// Build Notification // Build Notification
@@ -92,10 +85,7 @@ class BroadcastReciever : BroadcastReceiver() {
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.build() .build()
notificationManager.notify(NOTIFICATION_ID_UPDATE, notification) notificationManager.notify(R.id.notification_id_update, notification)
}
ACTION_CANCEL_IMPORT -> {
cancelImport = true
} }
} }
} }

View File

@@ -0,0 +1,427 @@
/*
* 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 androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat
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.cleanCache
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import xyz.quaver.pupil.util.ellipsize
import xyz.quaver.pupil.util.normalizeID
import xyz.quaver.pupil.util.requestBuilders
import java.io.IOException
import java.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)
}
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),
).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")
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 (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 = chain.proceed(request)
var retry = 5
while (!response.isSuccessful && retry > 0) {
response = chain.proceed(request)
retry--
}
response.newBuilder()
.body(response.body()?.let {
ProgressResponseBody(request.tag(), it, progressListener)
}).build()
}
//endregion
//region 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) {
e.printStackTrace()
if (e.message?.contains("cancel", true) == false) {
val galleryID = (call.request().tag() as Tag).galleryID
// Retry
cancel(galleryID)
download(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()
kotlin.runCatching {
val image = response.also { if (it.code() != 200) throw IOException() }.body()?.use { it.bytes() } ?: throw Exception()
val padding = ceil(progress[galleryID]?.size?.let { log10(it.toFloat()) } ?: 0F).toInt()
CoroutineScope(Dispatchers.IO).launch {
kotlin.runCatching {
Cache.getInstance(this@DownloadService, galleryID).putImage(index, "${index.toString().padStart(padding, '0')}.$ext", image)
}.onSuccess {
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 {
it.printStackTrace()
cancel(galleryID)
download(galleryID)
}
}
}
}
}
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 reader = cache.getReader()
// Gallery doesn't exist
if (reader == null) {
delete(galleryID)
progress[galleryID] = mutableListOf()
return@launch
}
histories.add(galleryID)
progress[galleryID] = MutableList(reader.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(reader.title?.ellipsize(30))
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)
}
}
reader.requestBuilders.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 {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
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() {
startForeground(R.id.downloader_notification_id, serviceNotification.build())
interceptors[Tag::class] = interceptor
}
override fun onDestroy() {
interceptors.remove(Tag::class)
}
}

View File

@@ -18,15 +18,33 @@
package xyz.quaver.pupil.types package xyz.quaver.pupil.types
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.android.parcel.Parcelize import kotlinx.parcelize.Parcelize
import xyz.quaver.floatingsearchview.suggestions.model.SearchSuggestion
import xyz.quaver.hitomi.Suggestion import xyz.quaver.hitomi.Suggestion
import xyz.quaver.pupil.util.translations
@Parcelize @Parcelize
data class TagSuggestion(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion { data class TagSuggestion(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion {
constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n) constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n)
override fun getBody(): String { @IgnoredOnParcel
return s override val body =
} if (translations[s] != null)
"${translations[s]} ($s)"
else
s
} }
@Parcelize
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

@@ -24,7 +24,7 @@ import kotlinx.serialization.Serializable
data class Tag(val area: String?, val tag: String, val isNegative: Boolean = false) { data class Tag(val area: String?, val tag: String, val isNegative: Boolean = false) {
companion object { companion object {
fun parse(tag: String) : Tag { fun parse(tag: String) : Tag {
if (tag.first() == '-') { if (tag.firstOrNull() == '-') {
tag.substring(1).split(Regex(":"), 2).let { tag.substring(1).split(Regex(":"), 2).let {
return when(it.size) { return when(it.size) {
2 -> Tag(it[0], it[1], true) 2 -> Tag(it[0], it[1], true)
@@ -62,12 +62,10 @@ data class Tag(val area: String?, val tag: String, val isNegative: Boolean = fal
return false return false
} }
override fun hashCode(): Int { override fun hashCode() = toString().hashCode()
return super.hashCode()
}
} }
class Tags(tag: List<Tag?>?) : ArrayList<Tag>() { class Tags(val tags: MutableSet<Tag> = mutableSetOf()) : MutableSet<Tag> by tags {
companion object { companion object {
fun parse(tags: String) : Tags { fun parse(tags: String) : Tags {
@@ -77,20 +75,13 @@ class Tags(tag: List<Tag?>?) : ArrayList<Tag>() {
Tag.parse(it) Tag.parse(it)
else else
null null
} }.filterNotNull().toMutableSet()
) )
} }
} }
init {
tag?.forEach {
if (it != null)
add(it)
}
}
fun contains(element: String): Boolean { fun contains(element: String): Boolean {
forEach { tags.forEach {
if (it.toString() == element) if (it.toString() == element)
return true return true
} }
@@ -99,23 +90,22 @@ class Tags(tag: List<Tag?>?) : ArrayList<Tag>() {
} }
fun add(element: String): Boolean { fun add(element: String): Boolean {
return super.add(Tag.parse(element)) return tags.add(Tag.parse(element))
} }
fun remove(element: String) { fun remove(element: String) {
filter { it.toString() == element }.forEach { tags.filter { it.toString() == element }.forEach {
remove(it) tags.remove(it)
} }
} }
fun removeByArea(area: String, isNegative: Boolean? = null) { fun removeByArea(area: String, isNegative: Boolean? = null) {
filter { it.area == area && (if(isNegative == null) true else (it.isNegative == isNegative)) }.forEach { tags.filter { it.area == area && (if(isNegative == null) true else (it.isNegative == isNegative)) }.forEach {
remove(it) tags.remove(it)
} }
} }
override fun toString(): String { override fun toString(): String {
return joinToString(" ") { it.toString() } 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

@@ -27,23 +27,24 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.andrognito.patternlockview.PatternLockView import com.andrognito.patternlockview.PatternLockView
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_lock.*
import kotlinx.android.synthetic.main.fragment_pattern_lock.*
import kotlinx.android.synthetic.main.fragment_pin_lock.*
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.databinding.LockActivityBinding
import xyz.quaver.pupil.ui.fragment.PINLockFragment import xyz.quaver.pupil.ui.fragment.PINLockFragment
import xyz.quaver.pupil.ui.fragment.PatternLockFragment import xyz.quaver.pupil.ui.fragment.PatternLockFragment
import xyz.quaver.pupil.util.Lock import xyz.quaver.pupil.util.Lock
import xyz.quaver.pupil.util.LockManager import xyz.quaver.pupil.util.LockManager
import xyz.quaver.pupil.util.Preferences
private var lastUnlocked = 0L
class LockActivity : AppCompatActivity() { class LockActivity : AppCompatActivity() {
private lateinit var lockManager: LockManager private lateinit var lockManager: LockManager
private var mode: String? = null private var mode: String? = null
private lateinit var binding: LockActivityBinding
private val patternLockFragment = PatternLockFragment().apply { private val patternLockFragment = PatternLockFragment().apply {
var lastPass = "" var lastPass = ""
onPatternDrawn = { onPatternDrawn = {
@@ -52,10 +53,11 @@ class LockActivity : AppCompatActivity() {
val result = lockManager.check(it) val result = lockManager.check(it)
if (result == true) { if (result == true) {
lastUnlocked = System.currentTimeMillis()
setResult(Activity.RESULT_OK) setResult(Activity.RESULT_OK)
finish() finish()
} else } else
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG) binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
} }
"add_lock" -> { "add_lock" -> {
if (lastPass.isEmpty()) { if (lastPass.isEmpty()) {
@@ -67,7 +69,7 @@ class LockActivity : AppCompatActivity() {
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it)) LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
finish() finish()
} else { } else {
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG) binding.patternLockView.setViewMode(PatternLockView.PatternViewMode.WRONG)
lastPass = "" lastPass = ""
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show() Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
@@ -86,18 +88,19 @@ class LockActivity : AppCompatActivity() {
val result = lockManager.check(it) val result = lockManager.check(it)
if (result == true) { if (result == true) {
lastUnlocked = System.currentTimeMillis()
setResult(Activity.RESULT_OK) setResult(Activity.RESULT_OK)
finish() finish()
} else { } else {
indicator_dots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply { binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
setAnimationListener(object: Animation.AnimationListener { setAnimationListener(object: Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) { override fun onAnimationEnd(animation: Animation?) {
pin_lock_view.resetPinLockView() binding.pinLockView.resetPinLockView()
pin_lock_view.isEnabled = true binding.pinLockView.isEnabled = true
} }
override fun onAnimationStart(animation: Animation?) { override fun onAnimationStart(animation: Animation?) {
pin_lock_view.isEnabled = false binding.pinLockView.isEnabled = false
} }
override fun onAnimationRepeat(animation: Animation?) { override fun onAnimationRepeat(animation: Animation?) {
@@ -111,22 +114,22 @@ class LockActivity : AppCompatActivity() {
if (lastPass.isEmpty()) { if (lastPass.isEmpty()) {
lastPass = it lastPass = it
pin_lock_view.resetPinLockView() binding.pinLockView.resetPinLockView()
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show() Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
} else { } else {
if (lastPass == it) { if (lastPass == it) {
LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it)) LockManager(context!!).add(Lock.generate(Lock.Type.PIN, it))
finish() finish()
} else { } else {
indicator_dots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply { binding.indicatorDots.startAnimation(AnimationUtils.loadAnimation(context, R.anim.shake).apply {
setAnimationListener(object: Animation.AnimationListener { setAnimationListener(object: Animation.AnimationListener {
override fun onAnimationEnd(animation: Animation?) { override fun onAnimationEnd(animation: Animation?) {
pin_lock_view.resetPinLockView() binding.pinLockView.resetPinLockView()
pin_lock_view.isEnabled = true binding.pinLockView.isEnabled = true
} }
override fun onAnimationStart(animation: Animation?) { override fun onAnimationStart(animation: Animation?) {
pin_lock_view.isEnabled = false binding.pinLockView.isEnabled = false
} }
override fun onAnimationRepeat(animation: Animation?) { override fun onAnimationRepeat(animation: Animation?) {
@@ -157,6 +160,7 @@ class LockActivity : AppCompatActivity() {
override fun onAuthenticationSucceeded( override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult) { result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result) super.onAuthenticationSucceeded(result)
lastUnlocked = System.currentTimeMillis()
setResult(RESULT_OK) setResult(RESULT_OK)
finish() finish()
return return
@@ -169,7 +173,8 @@ class LockActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_lock) binding = LockActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
lockManager = try { lockManager = try {
LockManager(this) LockManager(this)
@@ -185,6 +190,7 @@ class LockActivity : AppCompatActivity() {
} }
mode = intent.getStringExtra("mode") mode = intent.getStringExtra("mode")
val force = intent.getBooleanExtra("force", false)
when(mode) { when(mode) {
null -> { null -> {
@@ -194,11 +200,18 @@ class LockActivity : AppCompatActivity() {
return return
} }
if (System.currentTimeMillis() - lastUnlocked < 5*60*1000 && !force) {
lastUnlocked = System.currentTimeMillis()
setResult(RESULT_OK)
finish()
return
}
if ( if (
PreferenceManager.getDefaultSharedPreferences(this).getBoolean("lock_fingerprint", false) Preferences["lock_fingerprint"]
&& BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS && BiometricManager.from(this).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
) { ) {
lock_fingerprint.apply { binding.fingerprintBtn.apply {
isEnabled = true isEnabled = true
setOnClickListener { setOnClickListener {
showBiometricPrompt() showBiometricPrompt()
@@ -207,7 +220,7 @@ class LockActivity : AppCompatActivity() {
showBiometricPrompt() showBiometricPrompt()
} }
lock_pattern.apply { binding.patternBtn.apply {
isEnabled = lockManager.contains(Lock.Type.PATTERN) isEnabled = lockManager.contains(Lock.Type.PATTERN)
setOnClickListener { setOnClickListener {
supportFragmentManager.beginTransaction().replace( supportFragmentManager.beginTransaction().replace(
@@ -215,7 +228,7 @@ class LockActivity : AppCompatActivity() {
).commit() ).commit()
} }
} }
lock_pin.apply { binding.pinBtn.apply {
isEnabled = lockManager.contains(Lock.Type.PIN) isEnabled = lockManager.contains(Lock.Type.PIN)
setOnClickListener { setOnClickListener {
supportFragmentManager.beginTransaction().replace( supportFragmentManager.beginTransaction().replace(
@@ -223,7 +236,7 @@ class LockActivity : AppCompatActivity() {
).commit() ).commit()
} }
} }
lock_password.isEnabled = false binding.passwordBtn.isEnabled = false
when (lockManager.locks!!.first().type) { when (lockManager.locks!!.first().type) {
Lock.Type.PIN -> { Lock.Type.PIN -> {
@@ -241,20 +254,20 @@ class LockActivity : AppCompatActivity() {
} }
} }
"add_lock" -> { "add_lock" -> {
lock_pattern.isEnabled = false binding.patternBtn.isEnabled = false
lock_pin.isEnabled = false binding.pinBtn.isEnabled = false
lock_fingerprint.isEnabled = false binding.fingerprintBtn.isEnabled = false
lock_password.isEnabled = false binding.passwordBtn.isEnabled = false
when(intent.getStringExtra("type")!!) { when(intent.getStringExtra("type")!!) {
"pattern" -> { "pattern" -> {
lock_pattern.isEnabled = true binding.patternBtn.isEnabled = true
supportFragmentManager.beginTransaction().add( supportFragmentManager.beginTransaction().add(
R.id.lock_content, patternLockFragment R.id.lock_content, patternLockFragment
).commit() ).commit()
} }
"pin" -> { "pin" -> {
lock_pin.isEnabled = true binding.pinBtn.isEnabled = true
supportFragmentManager.beginTransaction().add( supportFragmentManager.beginTransaction().add(
R.id.lock_content, pinLockFragment R.id.lock_content, pinLockFragment
).commit() ).commit()

File diff suppressed because it is too large Load Diff

View File

@@ -18,38 +18,51 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.Manifest
import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import android.view.* import android.view.*
import android.widget.Toast import android.view.animation.Animation
import android.view.animation.AnticipateInterpolator
import android.view.animation.OvershootInterpolator
import android.view.animation.TranslateAnimation
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.vectordrawable.graphics.drawable.Animatable2Compat import androidx.vectordrawable.graphics.drawable.Animatable2Compat
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
import com.bumptech.glide.Glide
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.android.synthetic.main.activity_reader.* import com.google.mlkit.vision.face.Face
import kotlinx.android.synthetic.main.activity_reader.view.* import com.qtalk.recyclerviewfastscroller.RecyclerViewFastScroller
import kotlinx.android.synthetic.main.dialog_numberpicker.view.* import kotlinx.coroutines.CoroutineScope
import xyz.quaver.Code import kotlinx.coroutines.Dispatchers
import xyz.quaver.pupil.Pupil import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.ReaderAdapter import xyz.quaver.pupil.adapters.ReaderAdapter
import xyz.quaver.pupil.util.Histories import xyz.quaver.pupil.databinding.NumberpickerDialogBinding
import xyz.quaver.pupil.util.download.Cache import xyz.quaver.pupil.databinding.ReaderActivityBinding
import xyz.quaver.pupil.util.download.DownloadWorker import xyz.quaver.pupil.favorites
import java.util.* import xyz.quaver.pupil.services.DownloadService
import kotlin.concurrent.schedule import xyz.quaver.pupil.util.Preferences
import xyz.quaver.pupil.util.camera
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.startCamera
class ReaderActivity : AppCompatActivity() { class ReaderActivity : BaseActivity() {
private var galleryID = 0 private var galleryID = 0
private var currentPage = 0 private var currentPage = 0
@@ -59,38 +72,61 @@ class ReaderActivity : AppCompatActivity() {
set(value) { set(value) {
field = value field = value
(reader_recyclerview.adapter as ReaderAdapter).isFullScreen = value (binding.recyclerview.adapter as ReaderAdapter).isFullScreen = value
}
reader_progressbar.visibility = when { private lateinit var cache: Cache
value -> View.VISIBLE var downloader: DownloadService? = null
else -> View.GONE 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)
} }
} }
private val timer = Timer() override fun onServiceDisconnected(name: ComponentName?) {
downloader = null
}
}
private val snapHelper = PagerSnapHelper() private val snapHelper = PagerSnapHelper()
private var menu: Menu? = null private var menu: Menu? = null
private lateinit var favorites: Histories 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
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ReaderActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
title = getString(R.string.reader_loading) title = getString(R.string.reader_loading)
supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(false)
favorites = (application as Pupil).favorites
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE)
setContentView(R.layout.activity_reader)
handleIntent(intent) handleIntent(intent)
cache = Cache.getInstance(this, galleryID)
FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID) FirebaseCrashlytics.getInstance().setCustomKey("GalleryID", galleryID)
if (galleryID == 0) { if (galleryID == 0) {
@@ -98,8 +134,8 @@ class ReaderActivity : AppCompatActivity() {
return return
} }
initDownloadListener()
initView() initView()
initDownloader()
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -125,19 +161,6 @@ class ReaderActivity : AppCompatActivity() {
} }
} }
override fun onResume() {
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
if (preferences.getBoolean("security_mode", false))
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE)
else
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
super.onResume()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.reader, menu) menuInflater.inflate(R.menu.reader, menu)
@@ -152,20 +175,22 @@ class ReaderActivity : AppCompatActivity() {
return true return true
} }
override fun onOptionsItemSelected(item: MenuItem?): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when(item?.itemId) { when(item.itemId) {
R.id.reader_menu_page_indicator -> { R.id.reader_menu_page_indicator -> {
val view = LayoutInflater.from(this).inflate(R.layout.dialog_numberpicker, reader_layout, false) // TODO: Switch to DialogFragment
with(view.dialog_number_picker) { val binding = NumberpickerDialogBinding.inflate(layoutInflater, binding.root, false)
minValue=1
maxValue=Cache(context).getReaderOrNull(galleryID)?.galleryInfo?.files?.size ?: 0 with(binding.numberPicker) {
value=currentPage minValue = 1
maxValue = cache.metadata.reader?.files?.size ?: 0
value = currentPage
} }
val dialog = AlertDialog.Builder(this).apply { val dialog = AlertDialog.Builder(this).apply {
setView(view) setView(binding.root)
}.create() }.create()
view.dialog_ok.setOnClickListener { binding.okButton.setOnClickListener {
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(view.dialog_number_picker.value-1, 0) (this@ReaderActivity.binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(binding.numberPicker.value-1, 0)
dialog.dismiss() dialog.dismiss()
} }
@@ -188,14 +213,32 @@ class ReaderActivity : AppCompatActivity() {
return true 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
timer.cancel() update = false
(reader_recyclerview?.adapter as? ReaderAdapter)?.timer?.cancel()
if (!Cache(this).isDownloading(galleryID)) if (!DownloadManager.getInstance(this).isDownloading(galleryID))
DownloadWorker.getInstance(this@ReaderActivity).cancel(galleryID) DownloadService.cancel(this, galleryID)
} }
override fun onBackPressed() { override fun onBackPressed() {
@@ -217,12 +260,12 @@ class ReaderActivity : AppCompatActivity() {
//currentPage is 1-based //currentPage is 1-based
return when(keyCode) { return when(keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> { KeyEvent.KEYCODE_VOLUME_UP -> {
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage-2, 0) (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-2, 0)
true true
} }
KeyEvent.KEYCODE_VOLUME_DOWN -> { KeyEvent.KEYCODE_VOLUME_DOWN -> {
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0) (binding.recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(currentPage, 0)
true true
} }
@@ -230,51 +273,49 @@ class ReaderActivity : AppCompatActivity() {
} }
} }
private fun initDownloader() { private var update = true
val worker = DownloadWorker.getInstance(this).apply { private fun initDownloadListener() {
queue.add(galleryID) CoroutineScope(Dispatchers.Main).launch {
} while (update) {
delay(1000)
timer.schedule(1000, 1000) { val downloader = downloader ?: continue
if (worker.progress.indexOfKey(galleryID) < 0) //loading
return@schedule
if (worker.progress[galleryID] == null) { //Gallery not found if (!downloader.progress.containsKey(galleryID)) //loading
timer.cancel() continue
if (downloader.progress[galleryID]?.isEmpty() == true) { //Gallery not found
update = false
Snackbar Snackbar
.make(reader_layout, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE) .make(binding.root, R.string.reader_failed_to_find_gallery, Snackbar.LENGTH_INDEFINITE)
.show() .show()
return@launch
} }
runOnUiThread { binding.downloadProgressbar.max = binding.recyclerview.adapter?.itemCount ?: 0
reader_download_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0 binding.downloadProgressbar.progress =
reader_download_progressbar.progress = worker.progress[galleryID]?.count { !it.isFinite() } ?: 0 downloader.progress[galleryID]?.count { it.isInfinite() } ?: 0
reader_progressbar.max = reader_recyclerview.adapter?.itemCount ?: 0
if (title == getString(R.string.reader_loading)) { if (title == getString(R.string.reader_loading)) {
val reader = Cache(this@ReaderActivity).getReaderOrNull(galleryID) val reader = cache.metadata.reader
if (reader != null) { if (reader != null) {
with(binding.recyclerview.adapter as ReaderAdapter) {
with (reader_recyclerview.adapter as ReaderAdapter) {
this.reader = reader this.reader = reader
notifyDataSetChanged() notifyDataSetChanged()
} }
title = reader.galleryInfo.title title = reader.title
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${reader.galleryInfo.files.size}" menu?.findItem(R.id.reader_menu_page_indicator)?.title =
"$currentPage/${reader.files.size}"
menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, menu?.findItem(R.id.reader_type)?.icon = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.hitomi)
when (reader.code) {
Code.HITOMI -> R.drawable.hitomi
Code.HIYOBI -> R.drawable.ic_hiyobi
else -> android.R.color.transparent
})
} }
} }
if (worker.progress[galleryID]?.all { !it.isFinite() } == true) { //Download finished if (downloader.isCompleted(galleryID)) { //Download finished
reader_download_progressbar.visibility = View.GONE binding.downloadProgressbar.visibility = View.GONE
animateDownloadFAB(false) animateDownloadFAB(false)
} }
@@ -283,8 +324,8 @@ class ReaderActivity : AppCompatActivity() {
} }
private fun initView() { private fun initView() {
with(reader_recyclerview) { with(binding.recyclerview) {
adapter = ReaderAdapter(Glide.with(this@ReaderActivity), galleryID).apply { adapter = ReaderAdapter(this@ReaderActivity, galleryID).apply {
onItemClickListener = { onItemClickListener = {
if (isScroll) { if (isScroll) {
isScroll = false isScroll = false
@@ -293,7 +334,7 @@ class ReaderActivity : AppCompatActivity() {
scrollMode(false) scrollMode(false)
fullscreen(true) fullscreen(true)
} else { } else {
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPosition(currentPage) //Moves to next page because currentPage is 1-based indexing (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage, 0) //Moves to next page because currentPage is 1-based indexing
} }
} }
} }
@@ -303,9 +344,9 @@ class ReaderActivity : AppCompatActivity() {
super.onScrolled(recyclerView, dx, dy) super.onScrolled(recyclerView, dx, dy)
if (dy < 0) if (dy < 0)
this@ReaderActivity.reader_fab.showMenuButton(true) binding.fab.showMenuButton(true)
else if (dy > 0) else if (dy > 0)
this@ReaderActivity.reader_fab.hideMenuButton(true) binding.fab.hideMenuButton(true)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager val layoutManager = recyclerView.layoutManager as LinearLayoutManager
@@ -313,47 +354,61 @@ class ReaderActivity : AppCompatActivity() {
return return
currentPage = layoutManager.findFirstVisibleItemPosition()+1 currentPage = layoutManager.findFirstVisibleItemPosition()+1
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}" menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${recyclerView.adapter!!.itemCount}"
this@ReaderActivity.reader_progressbar.progress = currentPage
} }
}) })
} }
with(reader_fab_download) { with(binding.downloadFab) {
animateDownloadFAB(Cache(context).isDownloading(galleryID)) //If download in progress, animate button animateDownloadFAB(DownloadManager.getInstance(this@ReaderActivity).getDownloadFolder(galleryID) != null) //If download in progress, animate button
setOnClickListener { setOnClickListener {
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean("cache_disable", false)) val downloadManager = DownloadManager.getInstance(this@ReaderActivity)
Toast.makeText(context, R.string.settings_download_when_cache_disable_warning, Toast.LENGTH_SHORT).show()
else {
if (Cache(context).isDownloading(galleryID)) {
Cache(context).setDownloading(galleryID, false)
if (downloadManager.isDownloading(galleryID)) {
downloadManager.deleteDownloadFolder(galleryID)
animateDownloadFAB(false) animateDownloadFAB(false)
} else { } else {
Cache(context).setDownloading(galleryID, true) downloadManager.addDownloadFolder(galleryID)
DownloadService.download(context, galleryID, true)
animateDownloadFAB(true) animateDownloadFAB(true)
} }
} }
} }
}
with(reader_fab_retry) { with(binding.retryFab) {
setImageResource(R.drawable.refresh) setImageResource(R.drawable.refresh)
setOnClickListener { setOnClickListener {
DownloadWorker.getInstance(context).let { DownloadService.download(context, galleryID)
it.cancel(galleryID) }
it.queue.add(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(reader_fab_fullscreen) { with(binding.fullscreenFab) {
setImageResource(R.drawable.ic_fullscreen) setImageResource(R.drawable.ic_fullscreen)
setOnClickListener { setOnClickListener {
isFullscreen = true isFullscreen = true
fullscreen(isFullscreen) fullscreen(isFullscreen)
this@ReaderActivity.reader_fab.close(true) binding.fab.close(true)
} }
} }
} }
@@ -363,40 +418,55 @@ class ReaderActivity : AppCompatActivity() {
if (isFullscreen) { if (isFullscreen) {
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
supportActionBar?.hide() supportActionBar?.hide()
this@ReaderActivity.reader_fab.visibility = View.INVISIBLE binding.fab.visibility = View.INVISIBLE
binding.scroller.let {
it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_height)
it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_width)
it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb_horizontal)
it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.HORIZONTAL
}
} else { } else {
flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv() flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
supportActionBar?.show() supportActionBar?.show()
this@ReaderActivity.reader_fab.visibility = View.VISIBLE binding.fab.visibility = View.VISIBLE
binding.scroller.let {
it.handleWidth = resources.getDimensionPixelSize(R.dimen.thumb_width)
it.handleHeight = resources.getDimensionPixelSize(R.dimen.thumb_height)
it.handleDrawable = ContextCompat.getDrawable(this@ReaderActivity, R.drawable.thumb)
it.fastScrollDirection = RecyclerViewFastScroller.FastScrollDirection.VERTICAL
}
} }
window.attributes = this window.attributes = this
} }
reader_recyclerview.adapter = reader_recyclerview.adapter // Force to redraw binding.recyclerview.adapter = binding.recyclerview.adapter // Force to redraw
} }
private fun scrollMode(isScroll: Boolean) { private fun scrollMode(isScroll: Boolean) {
if (isScroll) { if (isScroll) {
snapHelper.attachToRecyclerView(null) snapHelper.attachToRecyclerView(null)
reader_recyclerview.layoutManager = LinearLayoutManager(this) binding.recyclerview.layoutManager = LinearLayoutManager(this)
} else { } else {
snapHelper.attachToRecyclerView(reader_recyclerview) snapHelper.attachToRecyclerView(binding.recyclerview)
reader_recyclerview.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) binding.recyclerview.layoutManager = object: LinearLayoutManager(this, HORIZONTAL, Preferences["rtl", false]) {
override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) {
extraLayoutSpace.fill(600)
}
}
} }
(reader_recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0) (binding.recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
} }
private fun animateDownloadFAB(animate: Boolean) { private fun animateDownloadFAB(animate: Boolean) {
with(reader_fab_download) { with(binding.downloadFab) {
if (animate) { if (animate) {
val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading) val icon = AnimatedVectorDrawableCompat.create(context, R.drawable.ic_downloading)
icon?.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() { icon?.registerAnimationCallback(object : Animatable2Compat.AnimationCallback() {
override fun onAnimationEnd(drawable: Drawable?) { override fun onAnimationEnd(drawable: Drawable?) {
val worker = DownloadWorker.getInstance(context) if (downloader?.isCompleted(galleryID) == true) // If download is finished, stop animating
if (worker.progress[galleryID]?.all { !it.isFinite() } == true) // If download is finished, stop animating
post { post {
setImageResource(R.drawable.ic_download) setImageResource(R.drawable.ic_download)
labelText = getString(R.string.reader_fab_download_cancel) labelText = getString(R.string.reader_fab_download_cancel)
@@ -417,4 +487,120 @@ class ReaderActivity : AppCompatActivity() {
} }
} }
} }
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

@@ -18,38 +18,15 @@
package xyz.quaver.pupil.ui package xyz.quaver.pupil.ui
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.settings_activity.*
import kotlinx.serialization.builtins.list
import kotlinx.serialization.builtins.serializer
import net.rdrei.android.dirchooser.DirectoryChooserActivity
import xyz.quaver.pupil.Pupil
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.fragment.LockSettingsFragment
import xyz.quaver.pupil.ui.fragment.SettingsFragment import xyz.quaver.pupil.ui.fragment.SettingsFragment
import xyz.quaver.pupil.util.*
import java.io.File
import java.nio.charset.Charset
class SettingsActivity : AppCompatActivity() { class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE)
setContentView(R.layout.settings_activity) setContentView(R.layout.settings_activity)
supportFragmentManager supportFragmentManager
.beginTransaction() .beginTransaction()
@@ -58,157 +35,11 @@ class SettingsActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
} }
override fun onResume() { override fun onOptionsItemSelected(item: MenuItem): Boolean {
val preferences = PreferenceManager.getDefaultSharedPreferences(this) when (item.itemId) {
if (preferences.getBoolean("security_mode", false))
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE)
else
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
super.onResume()
}
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
when (item?.itemId) {
android.R.id.home -> onBackPressed() android.R.id.home -> onBackPressed()
} }
return true return true
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when(requestCode) {
REQUEST_LOCK -> {
if (resultCode == Activity.RESULT_OK) {
supportFragmentManager
.beginTransaction()
.replace(R.id.settings, LockSettingsFragment())
.addToBackStack("Lock")
.commitAllowingStateLoss()
}
}
REQUEST_RESTORE -> {
if (resultCode == Activity.RESULT_OK) {
val uri = data?.data ?: return
try {
val str = contentResolver.openInputStream(uri).use { inputStream ->
inputStream!!
inputStream.readBytes().toString(Charset.defaultCharset())
}
(application as Pupil).favorites.addAll(json.parse(Int.serializer().list, str).also {
Snackbar.make(
window.decorView,
getString(R.string.settings_restore_successful, it.size),
Snackbar.LENGTH_LONG
).show()
})
} catch (e: Exception) {
Snackbar.make(
window.decorView,
R.string.settings_restore_failed,
Snackbar.LENGTH_LONG
).show()
}
}
}
REQUEST_DOWNLOAD_FOLDER -> {
if (resultCode == Activity.RESULT_OK) {
data?.data?.also { uri ->
val takeFlags: Int =
intent.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
contentResolver.takePersistableUriPermission(uri, takeFlags)
val file = uri.toFile(this)
if (file?.canWrite() != true)
Snackbar.make(
settings,
R.string.settings_dl_location_not_writable,
Snackbar.LENGTH_LONG
).show()
else
PreferenceManager.getDefaultSharedPreferences(this).edit()
.putString("dl_location", file.canonicalPath)
.apply()
}
}
}
REQUEST_DOWNLOAD_FOLDER_OLD -> {
if (resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
val directory = data?.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR)!!
if (!File(directory).canWrite())
Snackbar.make(
settings,
R.string.settings_dl_location_not_writable,
Snackbar.LENGTH_LONG
).show()
else
PreferenceManager.getDefaultSharedPreferences(this).edit()
.putString("dl_location", File(directory).canonicalPath)
.apply()
}
}
REQUEST_IMPORT_OLD_GALLERIES -> {
if (resultCode == Activity.RESULT_OK) {
data?.data?.also { uri ->
val takeFlags: Int =
intent.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
contentResolver.takePersistableUriPermission(uri, takeFlags)
val file = uri.toFile(this)
if (file?.canRead() != true)
Snackbar.make(
settings,
resources.getText(R.string.import_old_galleries_folder_not_readable),
Snackbar.LENGTH_LONG
).show()
else
importOldGalleries(this, file)
}
}
}
REQUEST_IMPORT_OLD_GALLERIES_OLD -> {
if (resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
val directory = data?.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR)!!
if (!File(directory).canRead())
Snackbar.make(
settings,
resources.getText(R.string.import_old_galleries_folder_not_readable),
Snackbar.LENGTH_LONG
).show()
else {
importOldGalleries(this, File(directory))
}
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
@SuppressLint("InlinedApi")
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
when (requestCode) {
REQUEST_WRITE_PERMISSION_AND_SAF -> {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra("android.content.extra.SHOW_ADVANCED", true)
}
startActivityForResult(intent, REQUEST_DOWNLOAD_FOLDER)
}
}
}
}
} }

View File

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

View File

@@ -0,0 +1,82 @@
/*
* 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.runBlocking
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()) }
val galleryBlock = runBlocking {
Cache.getInstance(requireContext(), galleryID).getGalleryBlock()
}
binding.message.text = getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolder() ?: "")
binding.edittext.setText(Preferences["download_folder_name", "[-id-] -title-"])
binding.edittext.addTextChangedListener {
binding.message.text = requireContext().getString(R.string.settings_download_folder_name_message, formatMap.keys.toString(), galleryBlock?.formatDownloadFolderTest(it.toString()) ?: "")
}
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

@@ -1,138 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.dialog
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Dialog
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.LinearLayout
import android.widget.RadioButton
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import kotlinx.android.synthetic.main.item_dl_location.view.*
import net.rdrei.android.dirchooser.DirectoryChooserActivity
import net.rdrei.android.dirchooser.DirectoryChooserConfig
import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.*
import java.io.File
@SuppressLint("InflateParams")
class DownloadLocationDialog(val activity: Activity) : AlertDialog(activity) {
private val preference = PreferenceManager.getDefaultSharedPreferences(context)
private val buttons = mutableListOf<Pair<RadioButton, File?>>()
override fun onCreate(savedInstanceState: Bundle?) {
setTitle(R.string.settings_dl_location)
setView(build())
setButton(Dialog.BUTTON_POSITIVE, context.getText(android.R.string.ok)) { _, _ -> }
super.onCreate(savedInstanceState)
}
private fun build() : View {
val view = layoutInflater.inflate(R.layout.dialog_dl_location, null) as LinearLayout
val externalFilesDirs = ContextCompat.getExternalFilesDirs(context, null)
externalFilesDirs.forEachIndexed { index, dir ->
dir ?: return@forEachIndexed
view.addView(layoutInflater.inflate(R.layout.item_dl_location, view, false).apply {
location_type.text = context.getString(when (index) {
0 -> R.string.settings_dl_location_internal
else -> R.string.settings_dl_location_removable
})
location_available.text = context.getString(
R.string.settings_dl_location_available,
byteToString(dir.freeSpace)
)
setOnClickListener {
buttons.forEach { pair ->
pair.first.isChecked = false
}
button.performClick()
preference.edit().putString("dl_location", dir.canonicalPath).apply()
}
buttons.add(button to dir)
})
}
view.addView(layoutInflater.inflate(R.layout.item_dl_location, view, false).apply {
location_type.text = context.getString(R.string.settings_dl_location_custom)
setOnClickListener {
buttons.forEach { pair ->
pair.first.isChecked = false
}
button.performClick()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_WRITE_PERMISSION_AND_SAF)
else {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra("android.content.extra.SHOW_ADVANCED", true)
}
activity.startActivityForResult(intent, REQUEST_DOWNLOAD_FOLDER)
}
dismiss()
} else { // Can't use SAF on old Androids!
val config = DirectoryChooserConfig.builder()
.newDirectoryName("Pupil")
.allowNewDirectoryNameModification(true)
.build()
val intent = Intent(context, DirectoryChooserActivity::class.java).apply {
putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config)
}
activity.startActivityForResult(intent, REQUEST_DOWNLOAD_FOLDER_OLD)
dismiss()
}
}
buttons.add(button to null)
})
externalFilesDirs.indexOfFirst {
it.canonicalPath == getDownloadDirectory(context).canonicalPath
}.let { index ->
if (index < 0)
buttons.first().first.isChecked = true
else
buttons[index].first.isChecked = true
}
return view
}
}

View File

@@ -0,0 +1,199 @@
/*
* 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.Build
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 net.rdrei.android.dirchooser.DirectoryChooserActivity
import net.rdrei.android.dirchooser.DirectoryChooserConfig
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
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
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 val requestDownloadFolderOldLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val context = context ?: return@registerForActivityResult
val dialog = dialog ?: return@registerForActivityResult
if (it.resultCode == DirectoryChooserActivity.RESULT_CODE_DIR_SELECTED) {
val directory = it.data?.getStringExtra(DirectoryChooserActivity.RESULT_SELECTED_DIR)!!
if (!File(directory).canWrite()) {
Snackbar.make(
dialog.window!!.decorView.rootView,
R.string.settings_download_folder_not_writable,
Snackbar.LENGTH_LONG
).show()
val downloadFolder = DownloadManager.getInstance(context).downloadFolder.canonicalPath
val key = entries.keys.firstOrNull { it?.canonicalPath == downloadFolder }
entries[key]!!.button.isChecked = true
if (key == null) entries[key]!!.locationAvailable.text = downloadFolder
}
else {
entries[null]?.locationAvailable?.text = directory
Preferences["download_folder"] = File(directory).toURI().toString()
}
}
}
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()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra("android.content.extra.SHOW_ADVANCED", true)
}
requestDownloadFolderLauncher.launch(intent)
} else { // Can't use SAF on old Androids!
val config = DirectoryChooserConfig.builder()
.newDirectoryName("Pupil")
.allowNewDirectoryNameModification(true)
.build()
val intent = Intent(context, DirectoryChooserActivity::class.java).apply {
putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config)
}
requestDownloadFolderOldLauncher.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

@@ -18,54 +18,49 @@
package xyz.quaver.pupil.ui.dialog package xyz.quaver.pupil.ui.dialog
import android.app.Dialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout.LayoutParams import android.widget.LinearLayout.LayoutParams
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.RequestManager import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.dialog_gallery.*
import kotlinx.android.synthetic.main.gallery_details.view.*
import kotlinx.android.synthetic.main.item_gallery_details.view.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import xyz.quaver.hitomi.Gallery import xyz.quaver.hitomi.Gallery
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.getGallery import xyz.quaver.hitomi.getGallery
import xyz.quaver.pupil.BuildConfig
import xyz.quaver.pupil.Pupil
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.GalleryBlockAdapter import xyz.quaver.pupil.adapters.GalleryBlockAdapter
import xyz.quaver.pupil.adapters.ThumbnailAdapter 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.types.Tag
import xyz.quaver.pupil.ui.ReaderActivity import xyz.quaver.pupil.ui.ReaderActivity
import xyz.quaver.pupil.ui.view.TagChip
import xyz.quaver.pupil.util.ItemClickSupport import xyz.quaver.pupil.util.ItemClickSupport
import xyz.quaver.pupil.util.download.Cache import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.wordCapitalize import xyz.quaver.pupil.util.wordCapitalize
import java.util.*
import kotlin.collections.ArrayList
class GalleryDialog(context: Context, private val glide: RequestManager, private val galleryID: Int) : Dialog(context) { class GalleryDialog(context: Context, private val galleryID: Int) : AlertDialog(context) {
private val languages = context.resources.getStringArray(R.array.languages).map {
it.split("|").let { split ->
Pair(split[0], split[1])
}
}.toMap()
val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>() val onChipClickedHandler = ArrayList<((Tag) -> (Unit))>()
private lateinit var binding: GalleryDialogBinding
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.dialog_gallery) binding = GalleryDialogBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.attributes.apply { window?.attributes.apply {
this ?: return@apply this ?: return@apply
@@ -74,13 +69,12 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
height = LayoutParams.MATCH_PARENT height = LayoutParams.MATCH_PARENT
} }
with(gallery_fab) { with(binding.fab) {
setImageDrawable(ContextCompat.getDrawable(context, R.drawable.arrow_right)) setImageDrawable(ContextCompat.getDrawable(context, R.drawable.arrow_right))
setOnClickListener { setOnClickListener {
context.startActivity(Intent(context, ReaderActivity::class.java).apply { context.startActivity(Intent(context, ReaderActivity::class.java).apply {
putExtra("galleryID", galleryID) putExtra("galleryID", galleryID)
}) })
(context.applicationContext as Pupil).histories.add(galleryID)
} }
} }
@@ -88,12 +82,12 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
try { try {
val gallery = getGallery(galleryID) val gallery = getGallery(galleryID)
gallery_cover.post { launch (Dispatchers.Main) {
gallery_progressbar.visibility = View.GONE binding.progressbar.visibility = View.GONE
gallery_title.text = gallery.title binding.title.text = gallery.title
gallery_artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() } binding.artist.text = gallery.artists.joinToString(", ") { it.wordCapitalize() }
with(gallery_type) { with(binding.type) {
text = gallery.type.wordCapitalize() text = gallery.type.wordCapitalize()
setOnClickListener { setOnClickListener {
gallery.type.let { gallery.type.let {
@@ -110,28 +104,26 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
} }
} }
glide binding.cover.showImage(Uri.parse(gallery.cover))
.load(gallery.cover)
.apply {
if (BuildConfig.CENSOR)
override(5, 8)
}.into(gallery_cover)
addDetails(gallery) addDetails(gallery)
addThumbnails(gallery) addThumbnails(gallery)
addRelated(gallery) addRelated(gallery)
} }
} catch (e: Exception) { } catch (e: Exception) {
Snackbar.make(gallery_layout, R.string.unable_to_connect, Snackbar.LENGTH_INDEFINITE).show() Snackbar.make(binding.root, R.string.unable_to_connect, Snackbar.LENGTH_INDEFINITE).apply {
if (Locale.getDefault().language == "ko")
setAction(context.getText(R.string.https_text)) {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(context.getString(R.string.https))))
}
}.show()
} }
} }
} }
private fun addDetails(gallery: Gallery) { private fun addDetails(gallery: Gallery) {
val inflater = LayoutInflater.from(context) GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
type.setText(R.string.gallery_details)
inflater.inflate(R.layout.gallery_details, gallery_contents, false).apply {
gallery_details.setText(R.string.gallery_details)
listOf( listOf(
R.string.gallery_artists, R.string.gallery_artists,
@@ -147,7 +139,18 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
listOf(gallery.language).map { Tag("language", it) }, listOf(gallery.language).map { Tag("language", it) },
gallery.series.map { Tag("series", it) }, gallery.series.map { Tag("series", it) },
gallery.characters.map { Tag("character", it) }, gallery.characters.map { Tag("character", it) },
gallery.tags.map { gallery.tags.sortedBy {
val tag = Tag.parse(it)
if (favoriteTags.contains(tag))
-1
else
when(Tag.parse(it).area) {
"female" -> 0
"male" -> 1
else -> 2
}
}.map {
Tag.parse(it).let { tag -> Tag.parse(it).let { tag ->
when { when {
tag.area != null -> tag tag.area != null -> tag
@@ -159,33 +162,12 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
).filter { ).filter {
(_, content) -> content.isNotEmpty() (_, content) -> content.isNotEmpty()
}.forEach { (title, content) -> }.forEach { (title, content) ->
inflater.inflate(R.layout.item_gallery_details, gallery_details_contents, false).apply { GalleryDialogTagsBinding.inflate(layoutInflater, contents, true).apply {
gallery_details_type.setText(title) type.setText(title)
content.forEach { tag -> content.forEach { tag ->
gallery_details_tags.addView( tags.addView(
Chip(context).apply { TagChip(context, tag).apply {
chipIcon = when(tag.area) {
"male" -> {
setChipBackgroundColorResource(R.color.material_blue_700)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
ContextCompat.getDrawable(context, R.drawable.gender_male)
}
"female" -> {
setChipBackgroundColorResource(R.color.material_pink_600)
setTextColor(ContextCompat.getColor(context, android.R.color.white))
ContextCompat.getDrawable(context, R.drawable.gender_female)
}
else -> null
}
text = when (tag.area) {
"language" -> languages[tag.tag]
else -> tag.tag.wordCapitalize()
}
setEnsureMinTouchTargetSize(false)
setOnClickListener { setOnClickListener {
onChipClickedHandler.forEach { handler -> onChipClickedHandler.forEach { handler ->
handler.invoke(tag) handler.invoke(tag)
@@ -194,37 +176,36 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
} }
) )
} }
}.let {
gallery_details_contents.addView(it)
} }
} }
}.let {
gallery_contents.addView(it)
} }
} }
private fun addThumbnails(gallery: Gallery) { private fun addThumbnails(gallery: Gallery) {
val inflater = LayoutInflater.from(context) GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
type.setText(R.string.gallery_thumbnails)
inflater.inflate(R.layout.gallery_details, gallery_contents, false).apply { val pager = ViewPager2(context).apply {
gallery_details.setText(R.string.gallery_thumbnails) adapter = ThumbnailPageAdapter(gallery.thumbnails)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
RecyclerView(context).apply { }
layoutManager = GridLayoutManager(context, 3)
adapter = ThumbnailAdapter(glide, gallery.thumbnails) contents.addView(
}.let { pager,
gallery_details_contents.addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
)
// TODO: Change to direct allocation
GalleryDialogDotindicatorBinding.inflate(layoutInflater, contents, true).apply {
dotindicator.setViewPager2(pager)
} }
}.let {
gallery_contents.addView(it)
} }
} }
private fun addRelated(gallery: Gallery) { private fun addRelated(gallery: Gallery) {
val inflater = LayoutInflater.from(context) val galleries = mutableListOf<Int>()
val galleries = ArrayList<GalleryBlock>()
val adapter = GalleryBlockAdapter(glide, galleries).apply { val adapter = GalleryBlockAdapter(galleries).apply {
onChipClickedHandler.add { tag -> onChipClickedHandler.add { tag ->
this@GalleryDialog.onChipClickedHandler.forEach { handler -> this@GalleryDialog.onChipClickedHandler.forEach { handler ->
handler.invoke(tag) handler.invoke(tag)
@@ -232,39 +213,21 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
} }
} }
CoroutineScope(Dispatchers.Main).launch { GalleryDialogDetailsBinding.inflate(layoutInflater, binding.contents, true).apply {
gallery.related.forEachIndexed { i, galleryID -> type.setText(R.string.gallery_related)
async(Dispatchers.IO) {
Cache(context).getGalleryBlock(galleryID)
}.let {
val galleryBlock = it.await() ?: return@let
galleries.add(galleryBlock) contents.addView(RecyclerView(context).apply {
adapter.notifyItemInserted(i)
}
}
}
inflater.inflate(R.layout.gallery_details, gallery_contents, false).apply {
gallery_details.setText(R.string.gallery_related)
RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
this.adapter = adapter this.adapter = adapter
ItemClickSupport.addTo(this) ItemClickSupport.addTo(this).apply {
.setOnItemClickListener { _, position, _ -> onItemClickListener = { _, position, _ ->
context.startActivity(Intent(context, ReaderActivity::class.java).apply { context.startActivity(Intent(context, ReaderActivity::class.java).apply {
putExtra("galleryID", galleries[position].id) putExtra("galleryID", galleries[position])
}) })
(context.applicationContext as Pupil).histories.add(galleries[position].id)
} }
.setOnItemLongClickListener { _, position, _ -> onItemLongClickListener = { _, position, _ ->
GalleryDialog( GalleryDialog(context, galleries[position]).apply {
context,
glide,
galleries[position].id
).apply {
onChipClickedHandler.add { tag -> onChipClickedHandler.add { tag ->
this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) } this@GalleryDialog.onChipClickedHandler.forEach { it.invoke(tag) }
} }
@@ -272,11 +235,20 @@ class GalleryDialog(context: Context, private val glide: RequestManager, private
true true
} }
}.let {
gallery_details_contents.addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
} }
}.let { })
gallery_contents.addView(it)
CoroutineScope(Dispatchers.IO).launch {
gallery.related.forEach { galleryID ->
Cache.getInstance(context, galleryID).getGalleryBlock()?.let {
galleries.add(galleryID)
}
}
withContext(Dispatchers.Main) {
adapter.notifyDataSetChanged()
}
}
} }
} }

View File

@@ -1,94 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.ui.dialog
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import xyz.quaver.pupil.R
import xyz.quaver.pupil.adapters.MirrorAdapter
class MirrorDialog(context: Context) : AlertDialog(context) {
class ItemTouchHelperCallback : ItemTouchHelper.Callback() {
var onMoveItem : ((Int, Int) -> (Unit))? = null
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
) = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
onMoveItem?.invoke(viewHolder.adapterPosition, target.adapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
}
}
@SuppressLint("InflateParams")
override fun onCreate(savedInstanceState: Bundle?) {
setTitle(R.string.settings_mirror_title)
setView(build())
setButton(Dialog.BUTTON_POSITIVE, context.getString(android.R.string.ok)) { _, _ -> }
super.onCreate(savedInstanceState)
}
private fun build() : View {
return RecyclerView(context).apply recyclerview@{
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
layoutManager = LinearLayoutManager(context)
adapter = MirrorAdapter(context).apply adapter@{
val itemTouchHelper = ItemTouchHelper(ItemTouchHelperCallback().apply {
onMoveItem = this@adapter.onItemMove
}).apply {
attachToRecyclerView(this@recyclerview)
}
onStartDrag = {
itemTouchHelper.startDrag(it)
}
onItemMoved = {
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putString("mirrors", it.joinToString(">"))
.apply()
}
}
}
}
}

View File

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

View File

@@ -24,15 +24,14 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.LockActivity import xyz.quaver.pupil.ui.LockActivity
import xyz.quaver.pupil.util.Lock import xyz.quaver.pupil.util.Lock
import xyz.quaver.pupil.util.LockManager import xyz.quaver.pupil.util.LockManager
import xyz.quaver.pupil.util.Preferences
class LockSettingsFragment : class LockSettingsFragment : PreferenceFragmentCompat() {
PreferenceFragmentCompat() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@@ -54,7 +53,7 @@ class LockSettingsFragment :
if (lockManager.isEmpty()) { if (lockManager.isEmpty()) {
(findPreference<Preference>("lock_fingerprint") as SwitchPreferenceCompat).isChecked = false (findPreference<Preference>("lock_fingerprint") as SwitchPreferenceCompat).isChecked = false
PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean("lock_fingerprint", false).apply() Preferences["lock_fingerprint"] = false
} }
} }
@@ -75,11 +74,11 @@ class LockSettingsFragment :
setTitle(R.string.warning) setTitle(R.string.warning)
setMessage(R.string.settings_lock_remove_message) setMessage(R.string.settings_lock_remove_message)
setPositiveButton(android.R.string.yes) { _, _ -> setPositiveButton(android.R.string.ok) { _, _ ->
lockManager.remove(Lock.Type.PATTERN) lockManager.remove(Lock.Type.PATTERN)
onResume() onResume()
} }
setNegativeButton(android.R.string.no) { _, _ -> } setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show() }.show()
} else { } else {
val intent = Intent(requireContext(), LockActivity::class.java).apply { val intent = Intent(requireContext(), LockActivity::class.java).apply {
@@ -108,11 +107,11 @@ class LockSettingsFragment :
setTitle(R.string.warning) setTitle(R.string.warning)
setMessage(R.string.settings_lock_remove_message) setMessage(R.string.settings_lock_remove_message)
setPositiveButton(android.R.string.yes) { _, _ -> setPositiveButton(android.R.string.ok) { _, _ ->
lockManager.remove(Lock.Type.PIN) lockManager.remove(Lock.Type.PIN)
onResume() onResume()
} }
setNegativeButton(android.R.string.no) { _, _ -> } setNegativeButton(android.R.string.cancel) { _, _ -> }
}.show() }.show()
} else { } else {
val intent = Intent(requireContext(), LockActivity::class.java).apply { val intent = Intent(requireContext(), LockActivity::class.java).apply {

View File

@@ -0,0 +1,106 @@
/*
* 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.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.snackbar.Snackbar
import okhttp3.*
import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.favorites
import xyz.quaver.pupil.util.restore
import java.io.File
import java.io.IOException
class ManageFavoritesFragment : PreferenceFragmentCompat() {
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 request = Request.Builder()
.url(context.getString(R.string.backup_url))
.post(
FormBody.Builder()
.add("f:1", File(ContextCompat.getDataDir(context), "favorites.json").readText())
.build()
).build()
client.newCall(request).enqueue(object: Callback {
override fun onFailure(call: Call, e: IOException) {
val view = view ?: return
Snackbar.make(view, R.string.settings_backup_failed, Snackbar.LENGTH_LONG).show()
}
override fun onResponse(call: Call, response: Response) {
if (response.code() != 200) {
response.close()
return
}
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, response.body()?.use { it.string() }?.replace("\n", ""))
}.let {
getContext()?.startActivity(Intent.createChooser(it, getString(R.string.settings_backup_share)))
}
}
})
true
}
findPreference<Preference>("restore")?.setOnPreferenceClickListener {
val editText = EditText(context).apply {
setText(context.getString(R.string.backup_url), TextView.BufferType.EDITABLE)
}
AlertDialog.Builder(context)
.setTitle(R.string.settings_restore_title)
.setView(editText)
.setPositiveButton(android.R.string.ok) { _, _ ->
restore(editText.text.toString(),
onFailure = onFailure@{
val view = view ?: return@onFailure
Snackbar.make(view, R.string.settings_restore_failed, Snackbar.LENGTH_LONG).show()
}, onSuccess = onSuccess@{
val view = view ?: return@onSuccess
Snackbar.make(view, context.getString(R.string.settings_restore_success, it.size), Snackbar.LENGTH_LONG).show()
})
}.setNegativeButton(android.R.string.cancel) { _, _ ->
// Do Nothing
}.show()
true
}
}
}

View File

@@ -0,0 +1,201 @@
/*
* 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 androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import xyz.quaver.io.FileX
import xyz.quaver.io.util.deleteRecursively
import xyz.quaver.pupil.R
import xyz.quaver.pupil.histories
import xyz.quaver.pupil.util.byteToString
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import java.io.File
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) {
this ?: return false
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()
}
"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
}
}
override fun onDestroy() {
job?.cancel()
super.onDestroy()
}
}

View File

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

View File

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

View File

@@ -18,34 +18,31 @@
package xyz.quaver.pupil.ui.fragment package xyz.quaver.pupil.ui.fragment
import android.Manifest import android.app.Activity
import android.content.* import android.content.*
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.ActivityCompat import androidx.preference.*
import androidx.core.content.ContextCompat import com.google.android.gms.oss.licenses.OssLicensesMenuActivity
import androidx.preference.Preference import kotlinx.coroutines.CoroutineScope
import androidx.preference.PreferenceCategory import kotlinx.coroutines.Dispatchers
import androidx.preference.PreferenceFragmentCompat import kotlinx.coroutines.launch
import androidx.preference.PreferenceManager import okhttp3.Dispatcher
import com.google.android.material.snackbar.Snackbar import xyz.quaver.io.FileX
import net.rdrei.android.dirchooser.DirectoryChooserActivity import xyz.quaver.io.util.getChild
import net.rdrei.android.dirchooser.DirectoryChooserConfig
import xyz.quaver.pupil.Pupil
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.client
import xyz.quaver.pupil.clientBuilder
import xyz.quaver.pupil.clientHolder
import xyz.quaver.pupil.ui.LockActivity import xyz.quaver.pupil.ui.LockActivity
import xyz.quaver.pupil.ui.SettingsActivity import xyz.quaver.pupil.ui.SettingsActivity
import xyz.quaver.pupil.ui.dialog.DefaultQueryDialog import xyz.quaver.pupil.ui.dialog.*
import xyz.quaver.pupil.ui.dialog.DownloadLocationDialog
import xyz.quaver.pupil.ui.dialog.MirrorDialog
import xyz.quaver.pupil.ui.dialog.ProxyDialog
import xyz.quaver.pupil.util.* import xyz.quaver.pupil.util.*
import java.io.File import xyz.quaver.pupil.util.downloader.DownloadManager
import java.util.*
import java.util.concurrent.Executors
class SettingsFragment : class SettingsFragment :
PreferenceFragmentCompat(), PreferenceFragmentCompat(),
@@ -53,7 +50,15 @@ class SettingsFragment :
Preference.OnPreferenceChangeListener, Preference.OnPreferenceChangeListener,
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener {
lateinit var sharedPreference: SharedPreferences private val lockLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
parentFragmentManager
.beginTransaction()
.replace(R.id.settings, LockSettingsFragment())
.addToBackStack("Lock")
.commitAllowingStateLoss()
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@@ -73,12 +78,6 @@ class SettingsFragment :
} }
} }
private fun getDirSize(dir: File) : String {
val size = dir.walk().map { it.length() }.sum()
return getString(R.string.settings_clear_summary, byteToString(size))
}
override fun onPreferenceClick(preference: Preference?): Boolean { override fun onPreferenceClick(preference: Preference?): Boolean {
with (preference) { with (preference) {
this ?: return false this ?: return false
@@ -87,122 +86,31 @@ class SettingsFragment :
"app_version" -> { "app_version" -> {
checkUpdate(activity as SettingsActivity, true) checkUpdate(activity as SettingsActivity, true)
} }
"delete_cache" -> { "download_folder" -> {
val dir = File(requireContext().cacheDir, "imageCache") DownloadLocationDialogFragment().show(parentFragmentManager, "Download Location Dialog")
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_clear_cache_alert_message)
setPositiveButton(android.R.string.yes) { _, _ ->
if (dir.exists())
dir.deleteRecursively()
summary = getDirSize(dir)
}
setNegativeButton(android.R.string.no) { _, _ -> }
}.show()
}
"delete_downloads" -> {
val dir = getDownloadDirectory(requireContext())
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_clear_downloads_alert_message)
setPositiveButton(android.R.string.yes) { _, _ ->
if (dir.exists())
dir.deleteRecursively()
summary = getDirSize(dir)
}
setNegativeButton(android.R.string.no) { _, _ -> }
}.show()
}
"clear_history" -> {
val histories = (requireContext().applicationContext as Pupil).histories
AlertDialog.Builder(requireContext()).apply {
setTitle(R.string.warning)
setMessage(R.string.settings_clear_history_alert_message)
setPositiveButton(android.R.string.yes) { _, _ ->
histories.clear()
summary = getString(R.string.settings_clear_history_summary, histories.size)
}
setNegativeButton(android.R.string.no) { _, _ -> }
}.show()
}
"dl_location" -> {
DownloadLocationDialog(requireActivity()).show()
} }
"default_query" -> { "default_query" -> {
DefaultQueryDialog(requireContext()).apply { DefaultQueryDialog().apply {
onPositiveButtonClickListener = { newTags -> onPositiveButtonClickListener = { newTags ->
sharedPreferences.edit().putString("default_query", newTags.toString()).apply() Preferences["default_query"] = newTags.toString()
summary = newTags.toString() summary = newTags.toString()
} }
}.show() }.show(parentFragmentManager, "Default Query Dialog")
} }
"app_lock" -> { "app_lock" -> {
val intent = Intent(requireContext(), LockActivity::class.java) val intent = Intent(requireContext(), LockActivity::class.java).apply {
activity?.startActivityForResult(intent, REQUEST_LOCK) putExtra("force", true)
} }
"mirrors" -> { lockLauncher.launch(intent)
MirrorDialog(requireContext())
.show()
} }
"proxy" -> { "proxy" -> {
ProxyDialog(requireContext()) ProxyDialogFragment().show(parentFragmentManager, "Proxy Dialog")
.show()
}
"nomedia" -> {
File(getDownloadDirectory(context), ".nomedia").createNewFile()
}
"backup" -> {
File(ContextCompat.getDataDir(requireContext()), "favorites.json").copyTo(
File(getDownloadDirectory(requireContext()), "favorites.json"),
true
)
Snackbar.make(this@SettingsFragment.listView, R.string.settings_backup_snackbar, Snackbar.LENGTH_LONG)
.show()
}
"restore" -> {
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
activity?.startActivityForResult(intent, REQUEST_RESTORE)
}
"old_import_galleries" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED)
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQUEST_WRITE_PERMISSION_AND_SAF)
else {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
putExtra("android.content.extra.SHOW_ADVANCED", true)
}
activity?.startActivityForResult(intent, REQUEST_IMPORT_OLD_GALLERIES)
}
} else { // Can't use SAF on old Androids!
val config = DirectoryChooserConfig.builder()
.newDirectoryName("Pupil")
.allowNewDirectoryNameModification(true)
.build()
val intent = Intent(requireContext(), DirectoryChooserActivity::class.java).apply {
putExtra(DirectoryChooserActivity.EXTRA_CONFIG, config)
}
activity?.startActivityForResult(intent, REQUEST_IMPORT_OLD_GALLERIES_OLD)
}
} }
"user_id" -> { "user_id" -> {
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip( (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText("user_id", sharedPreference.getString("user_id", "")) ClipData.newPlainText("user_id", Preferences.get<String>("user_id"))
) )
Toast.makeText(context, R.string.settings_user_id_toast, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
} }
else -> return false else -> return false
} }
@@ -216,6 +124,21 @@ class SettingsFragment :
this ?: return false this ?: return false
when (key) { 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" -> { "dark_mode" -> {
AppCompatDelegate.setDefaultNightMode(when (newValue as Boolean) { AppCompatDelegate.setDefaultNightMode(when (newValue as Boolean) {
true -> AppCompatDelegate.MODE_NIGHT_YES true -> AppCompatDelegate.MODE_NIGHT_YES
@@ -237,11 +160,26 @@ class SettingsFragment :
when (key) { when (key) {
"proxy" -> { "proxy" -> {
summary = getProxyInfo(requireContext()).type.name summary = context?.let { getProxyInfo().type.name }
} }
"dl_location" -> { "download_folder" -> {
summary = getDownloadDirectory(requireContext()).canonicalPath 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
} }
} }
} }
@@ -249,12 +187,16 @@ class SettingsFragment :
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey) setPreferencesFromResource(R.xml.root_preferences, rootKey)
sharedPreference = PreferenceManager.getDefaultSharedPreferences(requireContext()) Preferences.registerOnSharedPreferenceChangeListener(this)
sharedPreference.registerOnSharedPreferenceChangeListener(this)
initPreferences() initPreferences()
} }
override fun onDestroy() {
Preferences.unregisterOnSharedPreferenceChangeListener(this)
super.onDestroy()
}
private fun initPreferences() { private fun initPreferences() {
for (i in 0 until preferenceScreen.preferenceCount) { for (i in 0 until preferenceScreen.preferenceCount) {
@@ -264,7 +206,7 @@ class SettingsFragment :
else else
listOf(this) listOf(this)
}.forEach { preference -> }.forEach { preference ->
with (preference) { with (preference) with@{
when (key) { when (key) {
"app_version" -> { "app_version" -> {
@@ -274,31 +216,29 @@ class SettingsFragment :
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
"delete_cache" -> { "download_folder_name" -> {
val dir = File(requireContext().cacheDir, "imageCache") summary = Preferences["download_folder_name", "[-id-] -title-"]
summary = getDirSize(dir)
setOnPreferenceClickListener {
DownloadFolderNameDialogFragment().show(requireActivity().supportFragmentManager, "Download Location Dialog")
true
}
}
"download_folder" -> {
summary = FileX(context, Preferences.get<String>("download_folder")).canonicalPath
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
"delete_downloads" -> { "nomedia" -> {
val dir = getDownloadDirectory(requireContext()) (this as SwitchPreferenceCompat).isChecked = kotlin.runCatching {
summary = getDirSize(dir) DownloadManager.getInstance(context).downloadFolder.getChild(".nomedia").exists()
}.getOrDefault(false)
onPreferenceClickListener = this@SettingsFragment onPreferenceChangeListener = this@SettingsFragment
}
"clear_history" -> {
val histories = (requireActivity().application as Pupil).histories
summary = getString(R.string.settings_clear_history_summary, histories.size)
onPreferenceClickListener = this@SettingsFragment
}
"dl_location" -> {
summary = getDownloadDirectory(requireContext()).canonicalPath
onPreferenceClickListener = this@SettingsFragment
} }
"default_query" -> { "default_query" -> {
summary = sharedPreference.getString("default_query", "") ?: "" summary = Preferences.get<String>("default_query")
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
@@ -319,33 +259,48 @@ class SettingsFragment :
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
"mirrors" -> {
onPreferenceClickListener = this@SettingsFragment
}
"proxy" -> { "proxy" -> {
summary = getProxyInfo(requireContext()).type.name summary = getProxyInfo().type.name
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
"tag_translation" -> {
this as ListPreference
isEnabled = false
CoroutineScope(Dispatchers.IO).launch {
kotlin.runCatching {
val languages = getAvailableLanguages().distinct().toTypedArray()
entries = languages.map { Locale(it).let { loc -> loc.getDisplayLanguage(loc) } }.toTypedArray()
entryValues = languages
launch(Dispatchers.Main) {
isEnabled = true
}
}
}
onPreferenceChangeListener = this@SettingsFragment
}
"dark_mode" -> { "dark_mode" -> {
onPreferenceChangeListener = this@SettingsFragment onPreferenceChangeListener = this@SettingsFragment
} }
"nomedia" -> {
onPreferenceClickListener = this@SettingsFragment
}
"backup" -> {
onPreferenceClickListener = this@SettingsFragment
}
"restore" -> {
onPreferenceClickListener = this@SettingsFragment
}
"old_import_galleries" -> { "old_import_galleries" -> {
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
"user_id" -> { "user_id" -> {
summary = sharedPreference.getString("user_id", "") summary = Preferences.get<String>("user_id")
onPreferenceClickListener = this@SettingsFragment onPreferenceClickListener = this@SettingsFragment
} }
"oss" -> {
setOnPreferenceClickListener {
context?.startActivity(Intent(context, OssLicensesMenuActivity::class.java))
true
}
}
} }
} }

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().toLowerCase(Locale.getDefault()))
}
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

@@ -1,39 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonConfiguration
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import xyz.quaver.proxy
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
const val REQUEST_LOCK = 38238
const val REQUEST_RESTORE = 16546
const val REQUEST_IMPORT_OLD_GALLERIES = 6458
const val REQUEST_IMPORT_OLD_GALLERIES_OLD = 5946
const val REQUEST_DOWNLOAD_FOLDER = 3874
const val REQUEST_DOWNLOAD_FOLDER_OLD = 3425
const val REQUEST_WRITE_PERMISSION_AND_SAF = 13900
const val NOTIFICATION_ID_UPDATE = 2345
val json = Json(JsonConfiguration.Stable)

View File

@@ -1,107 +0,0 @@
package xyz.quaver.pupil.util;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import xyz.quaver.pupil.R;
/*
Source: http://www.littlerobots.nl/blog/Handle-Android-RecyclerView-Clicks/
USAGE:
ItemClickSupport.addTo(mRecyclerView).setOnItemClickListener(new ItemClickSupport.OnItemClickListener() {
@Override
public void onItemClicked(RecyclerView recyclerView, int position, View v) {
// do it
}
});
*/
public class ItemClickSupport {
private final RecyclerView mRecyclerView;
private OnItemClickListener mOnItemClickListener;
private OnItemLongClickListener mOnItemLongClickListener;
private View.OnClickListener mOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mOnItemClickListener != null) {
RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
mOnItemClickListener.onItemClicked(mRecyclerView, holder.getAdapterPosition(), v);
}
}
};
private View.OnLongClickListener mOnLongClickListener = new View.OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
if (mOnItemLongClickListener != null) {
RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
return mOnItemLongClickListener.onItemLongClicked(mRecyclerView, holder.getAdapterPosition(), v);
}
return false;
}
};
private RecyclerView.OnChildAttachStateChangeListener mAttachListener
= new RecyclerView.OnChildAttachStateChangeListener() {
@Override
public void onChildViewAttachedToWindow(@NonNull View view) {
if (mOnItemClickListener != null) {
view.setOnClickListener(mOnClickListener);
}
if (mOnItemLongClickListener != null) {
view.setOnLongClickListener(mOnLongClickListener);
}
}
@Override
public void onChildViewDetachedFromWindow(@NonNull View view) {
}
};
private ItemClickSupport(RecyclerView recyclerView) {
mRecyclerView = recyclerView;
mRecyclerView.setTag(R.id.item_click_support, this);
mRecyclerView.addOnChildAttachStateChangeListener(mAttachListener);
}
public static ItemClickSupport addTo(RecyclerView view) {
ItemClickSupport support = (ItemClickSupport) view.getTag(R.id.item_click_support);
if (support == null) {
support = new ItemClickSupport(view);
}
return support;
}
public static ItemClickSupport removeFrom(RecyclerView view) {
ItemClickSupport support = (ItemClickSupport) view.getTag(R.id.item_click_support);
if (support != null) {
support.detach(view);
}
return support;
}
public ItemClickSupport setOnItemClickListener(OnItemClickListener listener) {
mOnItemClickListener = listener;
return this;
}
public ItemClickSupport setOnItemLongClickListener(OnItemLongClickListener listener) {
mOnItemLongClickListener = listener;
return this;
}
private void detach(RecyclerView view) {
view.removeOnChildAttachStateChangeListener(mAttachListener);
view.setTag(R.id.item_click_support, null);
}
public interface OnItemClickListener {
void onItemClicked(RecyclerView recyclerView, int position, View v);
}
public interface OnItemLongClickListener {
boolean onItemLongClicked(RecyclerView recyclerView, int position, View v);
}
}

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

@@ -0,0 +1,95 @@
/*
* 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.serialization.*
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import java.io.File
import java.util.*
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)
}
}
@Synchronized
@OptIn(ExperimentalSerializationApi::class)
fun save() {
file.writeText(Json.encodeToString(serializer, set.toList()))
}
@Synchronized
override fun add(element: T): Boolean {
load()
set.remove(element)
return set.add(element).also {
save()
}
}
@Synchronized
override fun addAll(elements: Collection<T>): Boolean {
load()
set.removeAll(elements)
return set.addAll(elements).also {
save()
}
}
@Synchronized
override fun remove(element: T): Boolean {
load()
return set.remove(element).also {
save()
}
}
@Synchronized
override fun clear() {
set.clear()
save()
}
}

View File

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

View File

@@ -0,0 +1,119 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
@file:Suppress("DEPRECATION", "Recycle")
package xyz.quaver.pupil.util
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.ImageFormat
import android.graphics.SurfaceTexture
import android.hardware.Camera
import android.view.Surface
import android.view.WindowManager
import com.google.android.gms.tasks.Task
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.Face
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
/** Check if this device has a camera */
private fun Context.checkCameraHardware() =
this.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
private fun openFrontCamera() : Pair<Camera?, Int> {
var camera: Camera? = null
var cameraID: Int = -1
val cameraInfo = Camera.CameraInfo()
for (i in 0 until Camera.getNumberOfCameras()) {
Camera.getCameraInfo(i, cameraInfo)
if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT)
runCatching { Camera.open(i) }.getOrNull()?.let { camera = it; cameraID = i }
if (camera != null) break
}
return Pair(camera, cameraID)
}
val orientations = mapOf(
Surface.ROTATION_0 to 0,
Surface.ROTATION_90 to 90,
Surface.ROTATION_180 to 180,
Surface.ROTATION_270 to 270,
)
private fun getRotation(context: Context, cameraID: Int): Int {
val cameraRotation = Camera.CameraInfo().also { Camera.getCameraInfo(cameraID, it) }.orientation
val rotation = orientations[(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.rotation] ?: error("")
return (cameraRotation + rotation) % 360
}
var camera: Camera? = null
var surfaceTexture: SurfaceTexture? = null
private val detector = FaceDetection.getClient(
FaceDetectorOptions.Builder()
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
.build()
)
private var process: Task<List<Face>>? = null
fun startCamera(context: Context, callback: (List<Face>) -> Unit) {
if (camera != null) closeCamera()
val cameraID = openFrontCamera().let { (cam, cameraID) ->
cam ?: return
camera = cam
cameraID
}
with (camera!!) {
parameters = parameters.apply {
setPreviewSize(640, 480)
previewFormat = ImageFormat.NV21
}
setPreviewTexture(surfaceTexture ?: SurfaceTexture(0).also {
surfaceTexture = it
})
startPreview()
setPreviewCallback { bytes, _ ->
if (process?.isComplete == false)
return@setPreviewCallback
val rotation = getRotation(context, cameraID)
val image = InputImage.fromByteArray(bytes, 640, 480, rotation, InputImage.IMAGE_FORMAT_NV21)
process = detector.process(image)
.addOnSuccessListener(callback)
}
}
}
fun closeCamera() {
camera?.setPreviewCallback(null)
camera?.stopPreview()
surfaceTexture?.release()
surfaceTexture = null
camera?.release()
camera = null
}

View File

@@ -1,308 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util.download
import android.content.Context
import android.content.ContextWrapper
import android.util.Base64
import android.util.Log
import android.util.SparseArray
import androidx.preference.PreferenceManager
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.*
import xyz.quaver.Code
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.Reader
import xyz.quaver.proxy
import xyz.quaver.pupil.util.getCachedGallery
import xyz.quaver.pupil.util.getDownloadDirectory
import xyz.quaver.pupil.util.isParentOf
import xyz.quaver.pupil.util.json
import java.io.File
import java.io.FileOutputStream
import java.net.URL
import java.util.*
import java.util.concurrent.locks.Lock
import java.util.concurrent.locks.ReentrantLock
class Cache(context: Context) : ContextWrapper(context) {
companion object {
private val moving = mutableListOf<Int>()
private val readers = SparseArray<Reader?>()
}
private val locks = SparseArray<Lock>()
private fun lock(galleryID: Int) {
synchronized(locks) {
if (locks.indexOfKey(galleryID) < 0)
locks.put(galleryID, ReentrantLock())
}
locks[galleryID].lock()
}
private fun unlock(galleryID: Int) {
locks[galleryID]?.unlock()
}
private val preference = PreferenceManager.getDefaultSharedPreferences(this)
// Search in this order
// Download -> Cache
fun getCachedGallery(galleryID: Int) = getCachedGallery(this, galleryID).also {
if (!it.exists() && !preference.getBoolean("cache_disable", false))
it.mkdirs()
}
fun getCachedMetadata(galleryID: Int) : Metadata? {
val file = File(getCachedGallery(galleryID), ".metadata")
if (!file.exists())
return null
return try {
json.parse(Metadata.serializer(), file.readText())
} catch (e: Exception) {
//File corrupted
file.delete()
null
}
}
fun setCachedMetadata(galleryID: Int, metadata: Metadata) {
if (preference.getBoolean("cache_disable", false))
return
val file = File(getCachedGallery(galleryID), ".metadata").also {
if (!it.exists())
it.createNewFile()
}
file.writeText(json.stringify(Metadata.serializer(), metadata))
}
suspend fun getThumbnail(galleryID: Int): String? {
val metadata = Cache(this).getCachedMetadata(galleryID)
@Suppress("BlockingMethodInNonBlockingContext")
val thumbnail = if (metadata?.thumbnail == null)
withContext(Dispatchers.IO) {
val thumbnails = getGalleryBlock(galleryID)?.thumbnails
try {
Base64.encodeToString(URL(thumbnails?.firstOrNull()).openConnection(proxy).getInputStream().use {
it.readBytes()
}, Base64.DEFAULT)
} catch (e: Exception) {
null
}
}
else
metadata.thumbnail
setCachedMetadata(
galleryID,
Metadata(Cache(this).getCachedMetadata(galleryID), thumbnail = thumbnail)
)
return thumbnail
}
suspend fun getGalleryBlock(galleryID: Int): GalleryBlock? {
val metadata = Cache(this).getCachedMetadata(galleryID)
val sources = listOf(
{ xyz.quaver.hitomi.getGalleryBlock(galleryID) },
{ xyz.quaver.hiyobi.getGalleryBlock(galleryID) }
)
val galleryBlock = if (metadata?.galleryBlock == null) {
CoroutineScope(Dispatchers.IO).async {
var galleryBlock: GalleryBlock? = null
for (source in sources) {
galleryBlock = try {
source.invoke()
} catch (e: Exception) {
null
}
if (galleryBlock != null)
break
}
galleryBlock
}.await() ?: return null
}
else
metadata.galleryBlock
setCachedMetadata(
galleryID,
Metadata(Cache(this).getCachedMetadata(galleryID), galleryBlock = galleryBlock)
)
return galleryBlock
}
fun getReaderOrNull(galleryID: Int): Reader? {
return readers[galleryID] ?: getCachedMetadata(galleryID)?.reader
}
suspend fun getReader(galleryID: Int): Reader? {
val metadata = getCachedMetadata(galleryID)
val mirrors = preference.getString("mirrors", null)?.split('>') ?: listOf()
val sources = mapOf(
Code.HITOMI to { xyz.quaver.hitomi.getReader(galleryID) },
Code.HIYOBI to { xyz.quaver.hiyobi.getReader(galleryID) }
).let {
if (mirrors.isNotEmpty())
it.toSortedMap(
Comparator { o1, o2 ->
mirrors.indexOf(o1.name) - mirrors.indexOf(o2.name)
}
)
else
it
}
val reader =
if (readers[galleryID] != null)
return readers[galleryID]
else if (metadata?.reader == null) {
var retval: Reader? = null
for (source in sources) {
retval = try {
withContext(Dispatchers.IO) {
withTimeoutOrNull(1000) {
source.value.invoke()
}
}
} catch (e: Exception) {
FirebaseCrashlytics.getInstance().recordException(e)
null
}
if (retval != null)
break
}
retval
} else
metadata.reader
readers.put(galleryID, reader)
setCachedMetadata(
galleryID,
Metadata(Cache(this).getCachedMetadata(galleryID), readers = reader)
)
return reader
}
val imageNameRegex = Regex("""^\d+\..+$""")
fun getImages(galleryID: Int): List<File?>? {
val gallery = getCachedGallery(galleryID)
return gallery.list { _, name ->
imageNameRegex.matches(name)
}?.map {
File(gallery, it)
}
}
val imageExtensions = listOf(
"png",
"jpg",
"webp",
"gif"
)
fun getImage(galleryID: Int, index: Int): File? {
val gallery = getCachedGallery(galleryID)
for (ext in imageExtensions) {
File(gallery, "%05d.$ext".format(index)).let {
if (it.exists())
return it
}
}
return null
}
fun putImage(galleryID: Int, index: Int, ext: String, data: ByteArray) {
if (preference.getBoolean("cache_disable", false))
return
val cache = File(getCachedGallery(galleryID), "%05d.$ext".format(index)).also {
if (!it.exists())
it.createNewFile()
}
try {
FileOutputStream(cache).use {
it.write(data)
}
} catch (e: Exception) {
cache.delete()
}
}
fun moveToDownload(galleryID: Int) {
if (preference.getBoolean("cache_disable", false))
return
if (moving.contains(galleryID))
return
CoroutineScope(Dispatchers.IO).launch {
val cache = getCachedGallery(galleryID).also {
if (!it.exists())
return@launch
}
val download = File(getDownloadDirectory(this@Cache), galleryID.toString())
if (download.isParentOf(cache))
return@launch
Log.i("PUPILD", "MOVING ${cache.canonicalPath} --> ${download.canonicalPath}")
cache.copyRecursively(download, true) { file, err ->
Log.i("PUPILD", "MOVING ERROR ${file.canonicalPath} ${err.message}")
OnErrorAction.SKIP
}
Log.i("PUPILD", "MOVED ${cache.canonicalPath}")
Log.i("PUPILD", "DELETING ${cache.canonicalPath}")
cache.deleteRecursively()
Log.i("PUPILD", "DELETED ${cache.canonicalPath}")
}
}
fun isDownloading(galleryID: Int) = getCachedMetadata(galleryID)?.isDownloading == true
fun setDownloading(galleryID: Int, isDownloading: Boolean) {
setCachedMetadata(galleryID, Metadata(getCachedMetadata(galleryID), isDownloading = isDownloading))
}
}

View File

@@ -1,457 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util.download
import android.app.PendingIntent
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.SharedPreferences
import android.util.Log
import android.util.SparseArray
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.preference.PreferenceManager
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.*
import okhttp3.*
import okio.*
import xyz.quaver.Code
import xyz.quaver.hitomi.Reader
import xyz.quaver.hitomi.getReferer
import xyz.quaver.hitomi.imageUrlFromImage
import xyz.quaver.hiyobi.cookie
import xyz.quaver.hiyobi.createImgList
import xyz.quaver.hiyobi.user_agent
import xyz.quaver.proxy
import xyz.quaver.pupil.R
import xyz.quaver.pupil.ui.ReaderActivity
import java.io.File
import java.io.IOException
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class)
class DownloadWorker private constructor(context: Context) : ContextWrapper(context) {
private val preferences : SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
//region ProgressListener
@Suppress("UNCHECKED_CAST")
private val progressListener = object: ProgressListener {
override fun update(tag: Any?, bytesRead: Long, contentLength: Long, done: Boolean) {
val (galleryID, index) = (tag as? Pair<Int, Int>) ?: return
if (!done && progress[galleryID]?.get(index)?.isFinite() == true)
progress[galleryID]?.set(index, bytesRead * 100F / contentLength)
}
}
interface ProgressListener {
fun update(tag: Any?, bytesRead : Long, contentLength: Long, done: Boolean)
}
class ProgressResponseBody(
val tag: Any?,
val responseBody: ResponseBody,
val progressListener : ProgressListener
) : ResponseBody() {
private var bufferedSource : BufferedSource? = null
override fun contentLength() = responseBody.contentLength()
override fun contentType() = responseBody.contentType()
override fun source(): BufferedSource {
if (bufferedSource == null)
bufferedSource = Okio.buffer(source(responseBody.source()))
return bufferedSource!!
}
private fun source(source: Source) = object: ForwardingSource(source) {
var totalBytesRead = 0L
override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount)
totalBytesRead += if (bytesRead == -1L) 0L else bytesRead
progressListener.update(tag, totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead
}
}
}
//endregion
//region Singleton
companion object {
@Volatile private var instance: DownloadWorker? = null
fun getInstance(context: Context) =
instance ?: synchronized(this) {
instance ?: DownloadWorker(context).also { instance = it }
}
}
//endregion
val notificationManager = NotificationManagerCompat.from(context)
val queue = LinkedBlockingQueue<Int>()
/*
* KEY
* primary galleryID
* secondary index
* PRIMARY VALUE
* MutableList -> Download in progress
* null -> Loading / Gallery doesn't exist
* SECONDARY VALUE
* 0 <= value < 100 -> Download in progress
* Float.POSITIVE_INFINITY -> Download completed
* Float.NaN -> Exception
*/
val progress = SparseArray<MutableList<Float>?>()
/*
* KEY
* primary galleryID
* secondary index
* PRIMARY VALUE
* MutableList -> Download in progress / Loading
* null -> Gallery doesn't exist
* SECONDARY VALUE
* Throwable -> Exception
* null -> Download in progress / Loading
*/
val exception = SparseArray<MutableList<Throwable?>?>()
val results = SparseArray<MutableList<ByteArray?>?>()
val notification = SparseArray<NotificationCompat.Builder>()
private val loop = loop()
private val worker = SparseArray<Job?>()
val interceptor = Interceptor { chain ->
val request = chain.request()
var response = chain.proceed(request)
var retry = 5
while (!response.isSuccessful && retry > 0) {
response = chain.proceed(request)
retry--
}
response.newBuilder()
.body(response.body()?.let {
ProgressResponseBody(request.tag(), it, progressListener)
}).build()
}
val client =
OkHttpClient.Builder()
.connectTimeout(0, TimeUnit.SECONDS)
.addInterceptor(interceptor)
.readTimeout(0, TimeUnit.SECONDS)
.dispatcher(Dispatcher().apply {
maxRequests = 4
maxRequestsPerHost = 4
})
.proxy(proxy)
.build()
fun stop() {
queue.clear()
loop.cancel()
for (i in 0 until worker.size()) {
val galleryID = worker.keyAt(i)
Cache(this@DownloadWorker).setDownloading(galleryID, false)
worker[galleryID]?.cancel()
}
client.dispatcher().queuedCalls().filter {
it.request().tag() is Pair<*, *>
}.forEach {
it.cancel()
}
progress.clear()
exception.clear()
results.clear()
notification.clear()
notificationManager.cancelAll()
}
fun cancel(galleryID: Int) {
queue.remove(galleryID)
worker[galleryID]?.cancel()
client.dispatcher().queuedCalls().filter {
((it.request().tag() as Pair<*, *>).first as Int) == galleryID
}.forEach {
it.cancel()
}
progress.remove(galleryID)
exception.remove(galleryID)
results.remove(galleryID)
notification.remove(galleryID)
notificationManager.cancel(galleryID)
if (progress.indexOfKey(galleryID) >= 0)
Cache(this@DownloadWorker).setDownloading(galleryID, false)
}
fun isCompleted(galleryID: Int) = progress[galleryID]?.all { !it.isFinite() } == true
private fun queueDownload(galleryID: Int, reader: Reader, index: Int, callback: Callback) {
val lowQuality = preferences.getBoolean("low_quality", false)
val request = Request.Builder().apply {
when (reader.code) {
Code.HITOMI -> {
url(
imageUrlFromImage(
galleryID,
reader.galleryInfo.files[index],
!lowQuality
)
)
addHeader("Referer", getReferer(galleryID))
}
Code.HIYOBI -> {
url(createImgList(galleryID, reader, lowQuality)[index].path)
addHeader("User-Agent", user_agent)
addHeader("Cookie", cookie)
}
else -> {
//shouldn't be called anyway
}
}
tag(galleryID to index)
}.build()
client.newCall(request).enqueue(callback)
Log.i("PUPILD", "DOWNLOADING ($galleryID, $index) from ${request.url()}")
}
private fun download(galleryID: Int) = CoroutineScope(Dispatchers.IO).launch {
val reader = Cache(this@DownloadWorker).getReader(galleryID)
//gallery doesn't exist
if (reader == null) {
progress.put(galleryID, null)
exception.put(galleryID, null)
results.put(galleryID, null)
Cache(this@DownloadWorker).setDownloading(galleryID, false)
return@launch
}
val cache = Cache(this@DownloadWorker).getImages(galleryID)
progress.put(galleryID, reader.galleryInfo.files.indices.map { index ->
if (cache?.firstOrNull { it?.nameWithoutExtension?.toIntOrNull() == index } != null)
Float.POSITIVE_INFINITY
else
0F
}.toMutableList())
exception.put(galleryID, reader.galleryInfo.files.map { null }.toMutableList())
results.put(galleryID, reader.galleryInfo.files.indices.map { index ->
cache?.firstOrNull { it?.nameWithoutExtension?.toIntOrNull() == index }?.readBytes()
}.toMutableList())
if (notification[galleryID] == null)
initNotification(galleryID)
notification[galleryID].setContentTitle(reader.galleryInfo.title)
notify(galleryID)
if (isCompleted(galleryID)) {
with(Cache(this@DownloadWorker)) {
if (isDownloading(galleryID)) {
moveToDownload(galleryID)
setDownloading(galleryID, false)
}
}
return@launch
}
for (i in reader.galleryInfo.files.indices) {
val callback = object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.i("PUPILD", "FAIL ${call.request().tag()} (${e.message})")
if (e.message?.contains("cancel", true) != true)
FirebaseCrashlytics.getInstance().recordException(e)
progress[galleryID]?.set(i, Float.NaN)
exception[galleryID]?.set(i, e)
notify(galleryID)
CoroutineScope(Dispatchers.IO).launch {
if (isCompleted(galleryID)) {
with(Cache(this@DownloadWorker)) {
if (isDownloading(galleryID)) {
moveToDownload(galleryID)
setDownloading(galleryID, false)
}
}
}
}
}
override fun onResponse(call: Call, response: Response) {
Log.i("PUPILD", "OK ${call.request().tag()}")
val ext = call.request().url().encodedPath().split('.').last()
try {
response.body().use {
it!!
results[galleryID]?.set(i, it.source().readByteArray())
}
progress[galleryID]?.set(i, Float.POSITIVE_INFINITY)
notify(galleryID)
CoroutineScope(Dispatchers.IO).launch {
results[galleryID]?.get(i)?.also {
Cache(this@DownloadWorker).putImage(galleryID, i, ext, it)
}
if (isCompleted(galleryID)) {
with(Cache(this@DownloadWorker)) {
if (isDownloading(galleryID)) {
moveToDownload(galleryID)
setDownloading(galleryID, false)
}
}
}
}
Log.i("PUPILD", "SUCCESS ${call.request().tag()}")
} catch (e: Exception) {
progress[galleryID]?.set(i, Float.NaN)
exception[galleryID]?.set(i, e)
notify(galleryID)
CoroutineScope(Dispatchers.IO).launch {
if (isCompleted(galleryID)) {
with(Cache(this@DownloadWorker)) {
if (isDownloading(galleryID)) {
moveToDownload(galleryID)
setDownloading(galleryID, false)
}
}
}
}
File(Cache(this@DownloadWorker).getCachedGallery(galleryID), "%05d.$ext".format(i)).delete()
Log.i("PUPILD", "FAIL ON OK ${call.request().tag()} (${e.message})")
}
}
}
if (progress[galleryID]?.get(i)?.isFinite() == true) {
queueDownload(galleryID, reader, i, callback)
Log.i("PUPILD", "$galleryID QUEUED $i")
} else {
Log.i("PUPILD", "$galleryID SKIPPED $i (${progress[galleryID]?.get(i)})")
}
}
}
private fun notify(galleryID: Int) {
val max = progress[galleryID]?.size ?: 0
val progress = progress[galleryID]?.count { !it.isFinite() } ?: 0
Log.i("PUPILD", "NOTIFY $galleryID ${isCompleted(galleryID)} $progress/$max")
if (isCompleted(galleryID)) {
notification[galleryID]
?.setContentText(getString(R.string.reader_notification_complete))
?.setSmallIcon(android.R.drawable.stat_sys_download_done)
?.setProgress(0, 0, false)
?.setOngoing(false)
notificationManager.cancel(galleryID)
} else
notification[galleryID]
?.setProgress(max, progress, false)
?.setContentText("$progress/$max")
if (Cache(this).isDownloading(galleryID) && notification[galleryID] != null)
notificationManager.notify(galleryID, notification[galleryID].build())
else
notificationManager.cancel(galleryID)
}
private fun initNotification(galleryID: Int) {
val intent = Intent(this, ReaderActivity::class.java).apply {
putExtra("galleryID", galleryID)
}
val pendingIntent = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(intent)
getPendingIntent(galleryID, PendingIntent.FLAG_UPDATE_CURRENT)
}
notification.put(galleryID, NotificationCompat.Builder(this, "download").apply {
setContentTitle(getString(R.string.reader_loading))
setContentText(getString(R.string.reader_notification_text))
setSmallIcon(android.R.drawable.stat_sys_download) // had to use this because old android doesn't support VectorDrawable on Notification :P
setContentIntent(pendingIntent)
setProgress(0, 0, true)
setOngoing(true)
})
}
private fun loop() = CoroutineScope(Dispatchers.Default).launch {
while (true) {
if (queue.isEmpty())
continue
val galleryID = queue.peek() ?: continue
if (progress.indexOfKey(galleryID) >= 0) // Gallery already downloading!
cancel(galleryID)
if (notification[galleryID] == null)
initNotification(galleryID)
if (Cache(this@DownloadWorker).isDownloading(galleryID))
notificationManager.notify(galleryID, notification[galleryID].build())
Log.i("PUPILD", "QUEUED $galleryID")
worker.put(galleryID, download(galleryID))
queue.poll()
}
}
}

View File

@@ -1,44 +0,0 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util.download
import kotlinx.serialization.Serializable
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.Reader
@Serializable
data class Metadata(
val thumbnail: String? = null,
val galleryBlock: GalleryBlock? = null,
val reader: Reader? = null,
val isDownloading: Boolean? = null
) {
constructor(
metadata: Metadata?,
thumbnail: String? = null,
galleryBlock: GalleryBlock? = null,
readers: Reader? = null,
isDownloading: Boolean? = null
) : this(
thumbnail ?: metadata?.thumbnail,
galleryBlock ?: metadata?.galleryBlock,
readers ?: metadata?.reader,
isDownloading ?: metadata?.isDownloading
)
}

View File

@@ -0,0 +1,234 @@
/*
* 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Request
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.GalleryInfo
import xyz.quaver.io.FileX
import xyz.quaver.io.util.*
import xyz.quaver.pupil.client
import xyz.quaver.pupil.util.Preferences
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
@Serializable
data class Metadata(
var galleryBlock: GalleryBlock? = null,
var reader: GalleryInfo? = null,
var imageList: MutableList<String?>? = null
) {
fun copy(): Metadata = Metadata(galleryBlock, reader, 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 {
Json.decodeFromString<Metadata>(it)
}
}.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 {
xyz.quaver.hitomi.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)
.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 getReader(): GalleryInfo? {
return metadata.reader
?: withContext(Dispatchers.IO) {
try {
xyz.quaver.hitomi.getGalleryInfo(galleryID).also {
setMetadata { metadata ->
metadata.reader = 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")
fun putImage(index: Int, fileName: String, data: ByteArray) {
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,130 @@
/*
* 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.runBlocking
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.*
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
}
}.invoke()
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 = {
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 ?: {
file.createNewFile()
mutableMapOf<Int, String>()
}.invoke()
}.invoke()
}
return downloadFolderMapInstance ?: mutableMapOf()
}
@Synchronized
fun isDownloading(galleryID: Int): Boolean {
val isThisGallery: (Call) -> Boolean = { (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) }
@Synchronized
fun addDownloadFolder(galleryID: Int) {
val name = runBlocking {
Cache.getInstance(this@DownloadManager, galleryID).getGalleryBlock()
}?.formatDownloadFolder() ?: return
val folder = downloadFolder.getChild(name)
if (folder.exists())
return
folder.mkdir()
downloadFolderMap[galleryID] = folder.name
downloadFolder.getChild(".download").let { if (!it.exists()) it.createNewFile() }
downloadFolder.getChild(".download").writeText(Json.encodeToString(downloadFolderMap))
}
@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

@@ -18,200 +18,50 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.annotation.TargetApi
import android.content.Context import android.content.Context
import android.net.Uri import kotlinx.coroutines.CoroutineScope
import android.os.Build import kotlinx.coroutines.Dispatchers
import android.os.storage.StorageManager import kotlinx.coroutines.launch
import android.provider.DocumentsContract import kotlinx.coroutines.sync.Mutex
import androidx.core.content.ContextCompat import kotlinx.coroutines.sync.withLock
import androidx.preference.PreferenceManager import xyz.quaver.pupil.histories
import xyz.quaver.pupil.util.downloader.Cache
import xyz.quaver.pupil.util.downloader.DownloadManager
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.lang.reflect.Array
import java.net.URL
val mutex = Mutex()
fun cleanCache(context: Context) = CoroutineScope(Dispatchers.IO).launch {
if (mutex.isLocked) return@launch
fun getCachedGallery(context: Context, galleryID: Int) = mutex.withLock {
File(getDownloadDirectory(context), galleryID.toString()).let { val cacheFolder = File(context.cacheDir, "imageCache")
if (it.exists()) val downloadManager = DownloadManager.getInstance(context)
it
else val limit = (Preferences.get<String>("cache_limit").toLongOrNull() ?: 0L)*1024*1024*1024
File(context.cacheDir, "imageCache/$galleryID")
if (limit == 0L) return@withLock
val cacheSize = {
var size = 0L
cacheFolder.walk().forEach {
size += it.length()
} }
fun getDownloadDirectory(context: Context) = size
PreferenceManager.getDefaultSharedPreferences(context).getString("dl_location", null).let {
if (it != null && !it.startsWith("content"))
File(it)
else
context.getExternalFilesDir(null)!!
} }
fun URL.download(to: File, onDownloadProgress: ((Long, Long) -> Unit)? = null) { if (cacheSize.invoke() > limit)
while (cacheSize.invoke() > limit/2) {
val caches = cacheFolder.list() ?: return@withLock
if (to.parentFile?.exists() == false) synchronized(histories) {
to.parentFile!!.mkdirs() (histories.firstOrNull {
caches.contains(it.toString()) && !downloadManager.isDownloading(it)
if (!to.exists()) } ?: return@withLock).let {
to.createNewFile() Cache.delete(context, it)
FileOutputStream(to).use { out ->
with(openConnection()) {
val fileSize = contentLength.toLong()
getInputStream().use {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = it.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
onDownloadProgress?.invoke(bytesCopied, fileSize)
bytes = it.read(buffer)
} }
}
}
}
}
fun getExtSdCardPaths(context: Context) =
ContextCompat.getExternalFilesDirs(context, null).drop(1).map {
it.absolutePath.substringBeforeLast("/Android/data").let { path ->
runCatching {
File(path).canonicalPath
}.getOrElse {
path
} }
} }
} }
const val PRIMARY_VOLUME_NAME = "primary"
fun getVolumePath(context: Context, volumeID: String?): String? {
return runCatching {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val storageVolumeClass = Class.forName("android.os.storage.StorageVolume")
val getVolumeList = storageVolumeClass.javaClass.getMethod("getVolumeList")
val getUUID = storageVolumeClass.getMethod("getUuid")
val getPath = storageVolumeClass.getMethod("getPath")
val isPrimary = storageVolumeClass.getMethod("isPrimary")
val result = getVolumeList.invoke(storageManager)!!
val length = Array.getLength(result)
for (i in 0 until length) {
val storageVolumeElement = Array.get(result, i)
val uuid = getUUID.invoke(storageVolumeElement) as? String
val primary = isPrimary.invoke(storageVolumeElement) as? Boolean
// primary volume?
if (primary == true && volumeID == PRIMARY_VOLUME_NAME)
return@runCatching getPath.invoke(storageVolumeElement) as? String
// other volumes?
if (volumeID == uuid) {
return@runCatching getPath.invoke(storageVolumeElement) as? String
}
}
return@runCatching null
}.getOrNull()
} }
// Credits go to https://stackoverflow.com/questions/34927748/android-5-0-documentfile-from-tree-uri/36162691#36162691
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
fun getVolumeIdFromTreeUri(uri: Uri) =
DocumentsContract.getTreeDocumentId(uri).split(':').let {
if (it.isNotEmpty())
it[0]
else
null
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
fun getDocumentPathFromTreeUri(uri: Uri) =
DocumentsContract.getTreeDocumentId(uri).split(':').let {
if (it.size >= 2)
it[1]
else
File.separator
}
fun getFullPathFromTreeUri(context: Context, uri: Uri) : String? {
val volumePath = getVolumePath(context, getVolumeIdFromTreeUri(uri) ?: return null).let {
it ?: return File.separator
if (it.endsWith(File.separator))
it.dropLast(1)
else
it
}
val documentPath = getDocumentPathFromTreeUri(uri).let {
if (it.endsWith(File.separator))
it.dropLast(1)
else
it
}
return if (documentPath.isNotEmpty()) {
if (documentPath.startsWith(File.separator))
volumePath + documentPath
else
volumePath + File.separator + documentPath
} else
volumePath
}
// Huge thanks to avluis(https://github.com/avluis)
// This code is originated from Hentoid(https://github.com/avluis/Hentoid) under Apache-2.0 license.
fun Uri.toFile(context: Context): File? {
val path = this.path ?: return null
val pathSeparator = path.indexOf(':')
val folderName = path.substring(pathSeparator+1)
// Determine whether the designated file is
// - on a removable media (e.g. SD card, OTG)
// or
// - on the internal phone memory
val removableMediaFolderRoots = getExtSdCardPaths(context)
/* First test is to compare root names with known roots of removable media
* In many cases, the SD card root name is shared between pre-SAF (File) and SAF (DocumentFile) frameworks
* (e.g. /storage/3437-3934 vs. /tree/3437-3934)
* This is what the following block is trying to do
*/
for (s in removableMediaFolderRoots) {
val sRoot = s.substring(s.lastIndexOf(File.separatorChar))
val root = path.substring(0, pathSeparator).let {
it.substring(it.lastIndexOf(File.separatorChar))
}
if (sRoot.equals(root, true)) {
return File(s + File.separatorChar + folderName)
}
}
/* In some other cases, there is no common name (e.g. /storage/sdcard1 vs. /tree/3437-3934)
* We can use a slower method to translate the Uri obtained with SAF into a pre-SAF path
* and compare it to the known removable media volume names
*/
val root = getFullPathFromTreeUri(context, this)
for (s in removableMediaFolderRoots) {
if (root?.startsWith(s) == true) {
return File(root)
}
}
return File(context.getExternalFilesDir(null)?.canonicalPath?.substringBeforeLast("/Android/data") ?: return null, folderName)
}
fun File.isParentOf(another: File) =
another.absolutePath.startsWith(this.absolutePath)

View File

@@ -1,82 +0,0 @@
/*
* 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 kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.list
import kotlinx.serialization.builtins.serializer
import java.io.File
class Histories(private val file: File) : ArrayList<Int>() {
val serializer: KSerializer<List<Int>> = Int.serializer().list
init {
if (!file.exists())
file.parentFile?.mkdirs()
try {
load()
} catch (e: Exception) {
save()
}
}
fun load() : Histories {
return apply {
super.clear()
addAll(
json.parse(
serializer,
file.bufferedReader().use { it.readText() }
)
)
}
}
fun save() {
file.writeText(json.stringify(serializer, this))
}
override fun add(element: Int): Boolean {
load()
if (contains(element))
super.remove(element)
super.add(0, element)
save()
return true
}
override fun remove(element: Int): Boolean {
load()
val retval = super.remove(element)
save()
return retval
}
override fun clear() {
super.clear()
save()
}
}

View File

@@ -22,7 +22,9 @@ import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.list import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
@@ -41,7 +43,7 @@ fun hashWithSalt(password: String): Pair<String, String> {
return Pair(hash(password+salt), salt) return Pair(hash(password+salt), salt)
} }
val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" const val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@Serializable @Serializable
data class Lock(val type: Type, val hash: String, val salt: String) { data class Lock(val type: Type, val hash: String, val salt: String) {
@@ -80,7 +82,7 @@ class LockManager(base: Context): ContextWrapper(base) {
lock.writeText("[]") lock.writeText("[]")
} }
locks = ArrayList(json.parse(Lock.serializer().list, lock.readText())) locks = Json.decodeFromString(lock.readText())
} }
private fun save() { private fun save() {
@@ -89,7 +91,7 @@ class LockManager(base: Context): ContextWrapper(base) {
if (!lock.exists()) if (!lock.exists())
lock.createNewFile() lock.createNewFile()
lock.writeText(json.stringify(Lock.serializer().list, locks?.toList() ?: listOf())) lock.writeText(Json.encodeToString(locks?.toList() ?: listOf()))
} }
fun add(lock: Lock) { fun add(lock: Lock) {

View File

@@ -19,6 +19,13 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.annotation.SuppressLint import android.annotation.SuppressLint
import kotlinx.serialization.json.*
import okhttp3.OkHttpClient
import okhttp3.Request
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.GalleryInfo
import xyz.quaver.hitomi.getReferer
import xyz.quaver.hitomi.imageUrlFromImage
import java.util.* import java.util.*
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
@@ -28,20 +35,20 @@ fun String.wordCapitalize() : String {
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
for (word in this.split(" ")) for (word in this.split(" "))
result.add(word.capitalize(Locale.US)) result.add(word.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.US) else it.toString() })
return result.joinToString(" ") return result.joinToString(" ")
} }
fun byteToString(byte: Long, precision : Int = 1) : String { private val suffix = listOf(
val suffix = listOf(
"B", "B",
"kB", "kB",
"MB", "MB",
"GB", "GB",
"TB" //really? "TB" //really?
) )
fun byteToString(byte: Long, precision : Int = 1) : String {
var size = byte.toDouble(); var suffixIndex = 0 var size = byte.toDouble(); var suffixIndex = 0
while (size >= 1024) { while (size >= 1024) {
@@ -50,5 +57,73 @@ fun byteToString(byte: Long, precision : Int = 1) : String {
} }
return "%.${precision}f ${suffix[suffixIndex]}".format(size) 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 { artists.joinToString() }
// 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)
val GalleryInfo.requestBuilders: List<Request.Builder>
get() {
val galleryID = this.id ?: 0
val lowQuality = Preferences["low_quality", true]
return this.files.map {
Request.Builder()
.url(imageUrlFromImage(galleryID, it, !lowQuality))
.header("Referer", getReferer(galleryID))
}
}
fun String.ellipsize(n: Int): String =
if (this.length > n)
this.slice(0 until n) + ""
else
this
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

View File

@@ -19,8 +19,10 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.content.Context import android.content.Context
import androidx.preference.PreferenceManager
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Authenticator import okhttp3.Authenticator
import okhttp3.Credentials import okhttp3.Credentials
import java.net.InetSocketAddress import java.net.InetSocketAddress
@@ -35,14 +37,15 @@ data class ProxyInfo(
val password: String? = null val password: String? = null
) { ) {
fun proxy() : Proxy { fun proxy() : Proxy {
return if (host == null || port == null) return if (host.isNullOrBlank() || port == null)
return Proxy.NO_PROXY return Proxy.NO_PROXY
else else
Proxy(type, InetSocketAddress.createUnresolved(host, port)) Proxy(type, InetSocketAddress.createUnresolved(host, port))
} }
fun authenticator() = Authenticator { _, response -> fun authenticator(): Authenticator? = if (username.isNullOrBlank() || password.isNullOrBlank()) null else
val credential = Credentials.basic(username ?: "", password ?: "") Authenticator { _, response ->
val credential = Credentials.basic(username, password)
response.request().newBuilder() response.request().newBuilder()
.header("Proxy-Authorization", credential) .header("Proxy-Authorization", credential)
@@ -51,13 +54,5 @@ data class ProxyInfo(
} }
fun getProxy(context: Context) = fun getProxyInfo(): ProxyInfo =
getProxyInfo(context).proxy() Json.decodeFromString(Preferences["proxy", Json.encodeToString(ProxyInfo(Proxy.Type.DIRECT))])
fun getProxyInfo(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context).getString("proxy", null).let {
if (it == null)
ProxyInfo(Proxy.Type.DIRECT)
else
json.parse(ProxyInfo.serializer(), it)
}

View File

@@ -0,0 +1,68 @@
/*
* Pupil, Hitomi.la viewer for Android
* Copyright (C) 2020 tom5079
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package xyz.quaver.pupil.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Request
import xyz.quaver.pupil.client
import java.io.IOException
import java.util.*
private val filesURL = "https://api.github.com/repos/tom5079/Pupil/git/trees/tags"
private val contentURL = "https://raw.githubusercontent.com/tom5079/Pupil/tags/"
var translations: Map<String, String> = run {
updateTranslations()
emptyMap()
}
private set
@Suppress("BlockingMethodInNonBlockingContext")
fun updateTranslations() = CoroutineScope(Dispatchers.IO).launch {
translations = emptyMap()
kotlin.runCatching {
translations = Json.decodeFromString<Map<String, String>>(client.newCall(
Request.Builder()
.url(contentURL + "${Preferences["tag_translation", ""].let { if (it.isEmpty()) Locale.getDefault().language else it }}.json")
.build()
).execute().also { if (it.code() != 200) return@launch }.body()?.use { it.string() } ?: return@launch).filterValues { it.isNotEmpty() }
}
}
fun getAvailableLanguages(): List<String> {
val languages = Locale.getISOLanguages()
val json = Json.parseToJsonElement(client.newCall(
Request.Builder()
.url(filesURL)
.build()
).execute().also { if (it.code() != 200) throw IOException() }.body()?.use { it.string() } ?: return emptyList())
return listOf("en") + (json["tree"]?.jsonArray?.mapNotNull {
val name = it["path"]?.jsonPrimitive?.content?.takeWhile { c -> c != '.' }
languages.firstOrNull { code -> code.equals(name, ignoreCase = true) }
} ?: emptyList())
}

View File

@@ -18,63 +18,51 @@
package xyz.quaver.pupil.util package xyz.quaver.pupil.util
import android.annotation.SuppressLint
import android.app.DownloadManager import android.app.DownloadManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.util.Base64 import android.webkit.URLUtil
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.json.JsonArray import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.JsonObject import kotlinx.coroutines.launch
import kotlinx.serialization.json.boolean import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.content import kotlinx.serialization.json.*
import okhttp3.* import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import ru.noties.markwon.Markwon import ru.noties.markwon.Markwon
import xyz.quaver.hitomi.GalleryBlock
import xyz.quaver.hitomi.Reader
import xyz.quaver.hitomi.getGalleryBlock
import xyz.quaver.hitomi.getReader
import xyz.quaver.proxy
import xyz.quaver.pupil.BroadcastReciever
import xyz.quaver.pupil.BuildConfig import xyz.quaver.pupil.BuildConfig
import xyz.quaver.pupil.R import xyz.quaver.pupil.R
import xyz.quaver.pupil.util.download.Cache import xyz.quaver.pupil.client
import xyz.quaver.pupil.util.download.Metadata import xyz.quaver.pupil.favorites
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.URL import java.net.URL
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
fun getReleases(url: String) : JsonArray { fun getReleases(url: String) : JsonArray {
return try { return try {
URL(url).readText().let { URL(url).readText().let {
json.parse(JsonArray.serializer(), it) Json.parseToJsonElement(it).jsonArray
} }
} catch (e: Exception) { } catch (e: Exception) {
JsonArray(emptyList()) JsonArray(emptyList())
} }
} }
fun checkUpdate(context: Context, url: String) : JsonObject? { fun checkUpdate(url: String) : JsonObject? {
val releases = getReleases(url) val releases = getReleases(url)
if (releases.isEmpty()) if (releases.isEmpty())
return null return null
return releases.firstOrNull { return releases.firstOrNull {
if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean("beta", false)) Preferences["beta"] || it.jsonObject["prerelease"]?.jsonPrimitive?.booleanOrNull == false
true
else
it.jsonObject["prerelease"]?.boolean == false
}?.let { }?.let {
if (it.jsonObject["tag_name"]?.content == BuildConfig.VERSION_NAME) if (it.jsonObject["tag_name"]?.jsonPrimitive?.contentOrNull == BuildConfig.VERSION_NAME)
null null
else else
it.jsonObject it.jsonObject
@@ -83,13 +71,12 @@ fun checkUpdate(context: Context, url: String) : JsonObject? {
fun getApkUrl(releases: JsonObject) : String? { fun getApkUrl(releases: JsonObject) : String? {
return releases["assets"]?.jsonArray?.firstOrNull { return releases["assets"]?.jsonArray?.firstOrNull {
Regex("Pupil-v.+\\.apk").matches(it.jsonObject["name"]?.content ?: "") Regex("Pupil-v.+\\.apk").matches(it.jsonObject["name"]?.jsonPrimitive?.contentOrNull ?: "")
}.let { }.let {
it?.jsonObject?.get("browser_download_url")?.content it?.jsonObject?.get("browser_download_url")?.jsonPrimitive?.contentOrNull
} }
} }
const val UPDATE_NOTIFICATION_ID = 384823
fun checkUpdate(context: Context, force: Boolean = false) { fun checkUpdate(context: Context, force: Boolean = false) {
val preferences = PreferenceManager.getDefaultSharedPreferences(context) val preferences = PreferenceManager.getDefaultSharedPreferences(context)
@@ -99,7 +86,7 @@ fun checkUpdate(context: Context, force: Boolean = false) {
return return
fun extractReleaseNote(update: JsonObject, locale: Locale) : String { fun extractReleaseNote(update: JsonObject, locale: Locale) : String {
val markdown = update["body"]!!.content val markdown = update["body"]!!.jsonPrimitive.content
val target = when(locale.language) { val target = when(locale.language) {
"ko" -> "한국어" "ko" -> "한국어"
@@ -137,12 +124,12 @@ fun checkUpdate(context: Context, force: Boolean = false) {
} }
} }
return context.getString(R.string.update_release_note, update["tag_name"]?.content, result.toString()) return context.getString(R.string.update_release_note, update["tag_name"]?.jsonPrimitive?.contentOrNull, result.toString())
} }
CoroutineScope(Dispatchers.Default).launch { CoroutineScope(Dispatchers.Default).launch {
val update = val update =
checkUpdate(context, context.getString(R.string.release_url)) ?: return@launch checkUpdate(context.getString(R.string.release_url)) ?: return@launch
val url = getApkUrl(update) ?: return@launch val url = getApkUrl(update) ?: return@launch
@@ -150,15 +137,12 @@ fun checkUpdate(context: Context, force: Boolean = false) {
setTitle(R.string.update_title) setTitle(R.string.update_title)
val msg = extractReleaseNote(update, Locale.getDefault()) val msg = extractReleaseNote(update, Locale.getDefault())
setMessage(Markwon.create(context).toMarkdown(msg)) setMessage(Markwon.create(context).toMarkdown(msg))
setPositiveButton(android.R.string.yes) { _, _ -> setPositiveButton(android.R.string.ok) { _, _ ->
val preference = PreferenceManager.getDefaultSharedPreferences(context)
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
//Cancel any download queued before //Cancel any download queued before
val id = preference.getLong("update_download_id", -1) val id: Long = Preferences["update_download_id"]
if (id != -1L) if (id != -1L)
downloadManager.remove(id) downloadManager.remove(id)
@@ -172,10 +156,10 @@ fun checkUpdate(context: Context, force: Boolean = false) {
.setDestinationUri(Uri.fromFile(target)) .setDestinationUri(Uri.fromFile(target))
downloadManager.enqueue(request).also { downloadManager.enqueue(request).also {
preference.edit().putLong("update_download_id", it).apply() Preferences["update_download_id"] = it
} }
} }
setNegativeButton(if (force) android.R.string.no else R.string.ignore_update) { _, _ -> setNegativeButton(if (force) android.R.string.cancel else R.string.ignore) { _, _ ->
if (!force) if (!force)
preferences.edit() preferences.edit()
.putLong("ignore_update_until", System.currentTimeMillis() + 604800000) .putLong("ignore_update_until", System.currentTimeMillis() + 604800000)
@@ -189,147 +173,29 @@ fun checkUpdate(context: Context, force: Boolean = false) {
} }
} }
var cancelImport = false fun restore(url: String, onFailure: ((Throwable) -> Unit)? = null, onSuccess: ((List<Int>) -> Unit)? = null) {
@SuppressLint("RestrictedApi") if (!URLUtil.isValidUrl(url)) {
fun importOldGalleries(context: Context, folder: File) = CoroutineScope(Dispatchers.IO).launch { onFailure?.invoke(IllegalArgumentException())
val client = OkHttpClient.Builder() return
.connectTimeout(0, TimeUnit.SECONDS) }
.readTimeout(0, TimeUnit.SECONDS)
.proxy(proxy) val request = Request.Builder()
.url(url)
.get()
.build() .build()
val cancelIntent = Intent(context, BroadcastReciever::class.java).apply { client.newCall(request).enqueue(object: Callback {
action = BroadcastReciever.ACTION_CANCEL_IMPORT override fun onFailure(call: Call, e: IOException) {
putExtra(BroadcastReciever.EXTRA_IMPORT_NOTIFICATION_ID, 0) onFailure?.invoke(e)
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, cancelIntent, 0)
val notificationManager = NotificationManagerCompat.from(context)
val notificationBuilder = NotificationCompat.Builder(context, "import").apply {
setContentTitle(context.getText(R.string.import_old_galleries_notification))
setProgress(0, 0, true)
setSmallIcon(R.drawable.ic_notification)
addAction(0, context.getText(android.R.string.cancel), pendingIntent)
setOngoing(true)
} }
notificationManager.notify(0, notificationBuilder.build()) override fun onResponse(call: Call, response: Response) {
if (!folder.isDirectory)
return@launch
val galleryRegex = Regex("""[0-9]+$""")
val imageRegex = Regex("""^[0-9]+\..+$""")
var size = 0
fun setProgress(progress: Int) {
notificationBuilder.apply {
setContentText(
context.getString(
R.string.import_old_galleries_notification_text,
progress,
size
)
)
setProgress(size, progress, false)
}
notificationManager.notify(0, notificationBuilder.build())
}
folder.listFiles { _, name ->
galleryRegex.matches(name)
}?.also {
size = it.size
setProgress(0)
}?.forEachIndexed { index, gallery ->
if (cancelImport)
return@forEachIndexed
setProgress(index)
val galleryID = gallery.name.toIntOrNull() ?: return@forEachIndexed
File(getDownloadDirectory(context), galleryID.toString()).mkdirs()
val reader = async {
kotlin.runCatching { kotlin.runCatching {
json.parse(Reader.serializer(), File(gallery, "reader.json").readText()) Json.decodeFromString<List<Int>>(response.also { if (it.code() != 200) throw IOException() }.body().use { it?.string() } ?: "[]").let {
}.getOrElse { favorites.addAll(it)
getReader(galleryID) onSuccess?.invoke(it)
} }
} }.onFailure { onFailure?.invoke(it) }
val galleryBlock = async {
kotlin.runCatching {
json.parse(GalleryBlock.serializer(), File(gallery, "galleryBlock.json").readText())
}.getOrElse {
getGalleryBlock(galleryID)
}
}
@Suppress("NAME_SHADOWING")
val thumbnail = async thumbnail@{
val galleryBlock = galleryBlock.await()
Base64.encodeToString(try {
File(gallery, "thumbnail.jpg").readBytes()
} catch (e: Exception) {
val url = galleryBlock?.thumbnails?.firstOrNull()
if (url == null)
null
else {
val request = Request.Builder().url(url).build()
var done = false
var result: ByteArray? = null
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call?, e: IOException?) {
done = true
}
override fun onResponse(call: Call?, response: Response?) {
result = response?.body()?.use {
it.bytes()
}
done = true
} }
}) })
if (!done)
yield()
result
}
} ?: return@thumbnail null, Base64.DEFAULT)
}
Cache(context).setCachedMetadata(galleryID,
Metadata(
thumbnail.await(),
galleryBlock.await(),
reader.await()
)
)
File(gallery, "images").listFiles { _, name ->
imageRegex.matches(name)
}?.forEach {
if (cancelImport)
return@forEach
@Suppress("NAME_SHADOWING")
val index = it.nameWithoutExtension.toIntOrNull() ?: return@forEach
Cache(context).putImage(galleryID, index, it.extension, it.readBytes())
}
}
notificationBuilder.apply {
setContentText(context.getText(R.string.import_old_galleries_notification_done))
setProgress(0, 0, false)
setOngoing(false)
mActions.clear()
}
notificationManager.notify(0, notificationBuilder.build())
cancelImport = false
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
<!--drawable/clock_end.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#fff" android:pathData="M12 1C8.14 1 5 4.14 5 8a7 7 0 0 0 7 7c3.86 0 7-3.13 7-7 0-3.86-3.14-7-7-7m0 2.15c2.67 0 4.85 2.17 4.85 4.85 0 2.68-2.18 4.85-4.85 4.85A4.85 4.85 0 0 1 7.15 8 4.85 4.85 0 0 1 12 3.15M11 5v3.69l3.19 1.84 0.75-1.3-2.44-1.41V5M15 16v3H3v2h12v3l4-4m0 0v4h2v-8h-2"/>
</vector>

View File

@@ -0,0 +1,4 @@
<!--drawable/clock_start.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#fff" android:pathData="M12 1C8.14 1 5 4.14 5 8a7 7 0 0 0 7 7c3.86 0 7-3.13 7-7 0-3.86-3.14-7-7-7m0 2.15c2.67 0 4.85 2.17 4.85 4.85 0 2.68-2.18 4.85-4.85 4.85A4.85 4.85 0 0 1 7.15 8 4.85 4.85 0 0 1 12 3.15M11 5v3.69l3.19 1.84 0.75-1.3-2.44-1.41V5M4 16v8h2v-3h12v3l4-4-4-4v3H6v-3"/>
</vector>

View File

@@ -0,0 +1,4 @@
<!--drawable/close.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="?attr/colorControlNormal" android:pathData="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</vector>

View File

@@ -0,0 +1,4 @@
<!--drawable/delete.xml-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="?attr/colorControlNormal" android:pathData="M19 4h-3.5l-1-1h-5l-1 1H5v2h14M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12z"/>
</vector>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
~ Pupil, Hitomi.la viewer for Android ~ Pupil, Hitomi.la viewer for Android
~ Copyright (C) 2019 tom5079 ~ Copyright (C) 2020 tom5079
~ ~
~ This program is free software: you can redistribute it and/or modify ~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by ~ it under the terms of the GNU General Public License as published by
@@ -17,12 +17,14 @@
~ along with this program. If not, see <http://www.gnu.org/licenses/>. ~ along with this program. If not, see <http://www.gnu.org/licenses/>.
--> -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" <shape
android:layout_width="match_parent" android:layout_height="wrap_content"> xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<ProgressBar <solid
android:layout_width="wrap_content" android:color="@color/colorAccent"/>
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"/>
</LinearLayout> <size
android:width="24dp"
android:height="24dp"/>
</shape>

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