Compare commits

...

223 Commits

Author SHA1 Message Date
len
d971768056 Release 0.4.0 2016-12-03 16:54:29 +01:00
len
2e39be6625 Image is now the default decoder 2016-12-03 16:12:58 +01:00
len
f514d466a6 Minor changes and fixes 2016-12-03 13:08:26 +01:00
len
d10bf45283 Download next N chapters now excludes the ones enqueued. #556 2016-12-02 20:37:55 +01:00
len
a0064a1699 Don't allow to create categories with the same name 2016-12-01 20:34:30 +01:00
len
907472403d Upgrade okhttp 2016-12-01 19:36:58 +01:00
a9b6db9ee9 Italian language (#551)
* Add italian language

* italian language: fix aapt error

* small edit
2016-11-30 09:55:05 +01:00
len
3e1dc9f400 Add property to get the number of a page 2016-11-29 22:32:44 +01:00
len
d30c019b89 Allow to share images when reading online. Move chapter cache to external cache dir. Dependency updates. 2016-11-29 21:37:35 +01:00
len
86b8712dd1 Update subsampling 2016-11-29 00:18:02 +01:00
len
44241e03da Update preferences lib 2016-11-27 22:02:23 +01:00
len
12dcc2c31f Set share image mimetype with wildcard 2016-11-27 15:44:59 +01:00
len
bb89b72a81 Don't validate the page number and extension when saving a page 2016-11-26 12:34:54 +01:00
len
ea790faeb3 Always cancel library update task 2016-11-26 12:26:40 +01:00
len
4ef7b16925 Minor refactor 2016-11-24 21:50:02 +01:00
len
93e244b4c4 Fix #547 2016-11-24 21:42:01 +01:00
len
87281d34c1 Fix #528 2016-11-24 18:35:27 +01:00
len
20041701cd Handle empty directory. Fix travis 2016-11-24 16:11:01 +01:00
len
f9c5379400 Fix #546 2016-11-24 15:40:34 +01:00
len
2a531f1a1e Fix #545 2016-11-23 21:43:24 +01:00
len
4d4b9c0d6d Dependency updates. Remove some unused strings 2016-11-23 21:09:46 +01:00
dc592e92b5 Added Volume and Title to chapters from MangaHere (#523) 2016-11-22 22:39:27 +01:00
len
0db1a3167d Improve extension discovery. Fix #542 2016-11-22 20:49:57 +01:00
len
830f792824 Fix #541 2016-11-22 16:06:02 +01:00
a13ebc3975 Some improvements for russian catalogs (#540)
* Implemented genre filter for Mangachan
* Fixed search for Mangachan
* Changed url with latest updates for Mangachan
* Updated genres for Readmanga
* Removed duplicate code for Readmanga
2016-11-20 15:14:36 +01:00
b28ef61618 Better recent updates regexp for Mangasee (#539) 2016-11-20 15:13:46 +01:00
6f297161de Download manager rewrite (#535)
* Saving to SD working

* Rename imagePath to uri

* Handle android < 21

* Minor changes

* Separate downloader from the manager. Optimize folder lookups

* Persist downloads across restarts

* Fix for #511

* Updated ReactiveNetwork. Add some documentation

* More documentation and minor fixes

* Handle persistent notifications. Other minor changes

* Improve downloader and add documentation

* Rename pageNumber to index in Page class

* Remove unused methods

* Use chop method

* Make sure dest dir is created

* Reset downloads dir preference

* Use invalidate options menu in download fragment and fix wrong condition

* Fix empty download queue after application restart

* Use addAll method in download queue to avoid too many notifications

* Inform download manager changes
2016-11-20 11:20:57 +01:00
len
59c626b4a8 Add an extension function to limit the number of characters in a string. Dependency updates 2016-11-19 14:46:49 +01:00
len
1d014a5a94 Minor fix 2016-11-19 12:13:09 +01:00
len
2dc8159d96 Fix #517 and a few more crashes 2016-11-17 21:14:50 +01:00
len
453f742732 Trying to fix a crash in settings (again) 2016-11-15 19:12:03 +01:00
len
5e6cf9fb02 #529 2016-11-15 18:11:52 +01:00
len
83349fc72d Trying to fix a crash in settings 2016-11-15 17:48:51 +01:00
979a5c8c16 Merge pull request #526 from Gilfar/mangasee-seasonal
Update for seasonal manga from Mangasee
2016-11-14 20:49:07 +01:00
9f625835ec Added option to download page or set page as cover (#481)
* Added option to download page or set page as cover

* Removed network call now copies from page image

* Format fix + notification feedback

* Added code to prevent OutOfMemory error.  Made notification optional. Can now save image on long press. Bug fixes

* Now uses glide for notification

* Fixed webtoon page

* Fixes + API 16 support

* fixes

* Fixed API 24 FileProvider error

* Added page.ready check

* Indention
2016-11-14 20:48:34 +01:00
5fd379e71b update for seasonal manga from Mangasee 2016-11-14 18:38:10 +01:00
9c5b497751 Changed sort icon from by alpha to by numeric (#525) 2016-11-13 14:25:55 +01:00
4dc5f3e7d9 Indention 2016-11-13 14:09:32 +01:00
13954ffe01 Added page.ready check 2016-11-13 14:07:20 +01:00
36d4e1f7ef update Mangasee due to webpage changes (#521) 2016-11-13 12:33:29 +01:00
len
b716a2f8ac Fix compilation error 2016-11-12 15:28:01 +01:00
len
f98095e6cb Allow to change chapter fields before inserting to database. Update Kotlin to 1.0.5 2016-11-12 14:04:25 +01:00
d183aca810 Update MangaSee URL (#518)
Closes (https://github.com/inorichi/tachiyomi/issues/516)
2016-11-08 17:11:15 +01:00
52f4bddbce Set flex time 2016-11-07 16:23:04 +01:00
len
b837424f29 Fix update notification not allowing installations on some ROMs (like MIUI) 2016-11-06 20:14:13 +01:00
ba2a8c82f8 Fix travis 2016-11-06 18:53:40 +01:00
len
2856d9d6a3 Add product flavors. Switch to evernote's job scheduler 2016-11-06 18:44:14 +01:00
len
71fac76e3d Rename bookmark column val 2016-11-06 13:35:12 +01:00
125f1ae34c Added option to bookmark single chapter (#496)
* Added option to bookmark single chapter

* Fixes
2016-11-06 13:33:00 +01:00
len
b418169c20 Exclude backup empty fields 2016-11-06 13:31:01 +01:00
len
f4d12ba622 Update travis 2016-11-05 20:16:54 +01:00
len
c64d8c8b6b Fix tests 2016-11-05 19:41:52 +01:00
len
10a1ba95d6 Support API 25 again. Bump dependencies 2016-11-05 19:28:47 +01:00
27d3daf918 Add support for latest updates to Readmangatoday (#512) 2016-11-03 16:17:37 +01:00
len
dcbd72e64d Release 0.3.2 2016-10-30 17:39:16 +01:00
len
52e1e93f9d Added another image decoder. It should be faster than Rapid and more reliable than Skia. 2016-10-28 19:26:47 +02:00
7d3d0999f3 Fixed API 24 FileProvider error 2016-10-25 17:34:49 +02:00
93f90b5a62 fixes 2016-10-25 16:08:33 +02:00
c2b113ac0a Fixes + API 16 support 2016-10-25 15:49:27 +02:00
8ff8ab4f27 Fixed webtoon page 2016-10-25 15:49:22 +02:00
414b8c9f21 Now uses glide for notification 2016-10-25 15:49:21 +02:00
4975787afa Added code to prevent OutOfMemory error. Made notification optional. Can now save image on long press. Bug fixes 2016-10-25 15:49:20 +02:00
1210691fdd Format fix + notification feedback 2016-10-25 15:49:19 +02:00
2a4527a8d6 Removed network call now copies from page image 2016-10-25 15:49:18 +02:00
2991906a85 Added option to download page or set page as cover 2016-10-25 15:49:17 +02:00
len
5b1f4f189b Reader fixes 2016-10-24 22:16:50 +02:00
len
d77a1e6925 Change webtoon image callback to onReady 2016-10-24 00:12:32 +02:00
len
19c713ebb2 Minor changes 2016-10-23 22:37:20 +02:00
len
90e0e0b72a Webtoon reader now shows download progress. Keep the progress bar until the image is decoded 2016-10-23 18:59:25 +02:00
len
22bbcaeed0 Remove builtin decoders from Rapid 2016-10-23 16:42:48 +02:00
len
d7b8015df7 Drop support for reencode images 2016-10-23 13:22:14 +02:00
len
c1ac47e1ce Revert support lib 25 (broken as usual), update subsampling lib 2016-10-22 21:43:37 +02:00
len
e375101132 Revert "Support API 25. Use new DividerItemDecoration."
This reverts commit 05b14bae7b.
2016-10-22 21:42:48 +02:00
len
05b14bae7b Support API 25. Use new DividerItemDecoration. 2016-10-22 20:21:25 +02:00
len
eb15fe3898 Remove 2048 bitmap size limit 2016-10-21 21:21:31 +02:00
4f5518bdd8 Fixed wrong chapter recognition for S0 - Chapter 00 (#499) 2016-10-20 16:28:25 +02:00
c9e1e6e020 Release 0.3.1 2016-10-17 08:43:19 +02:00
ade73e6892 Keep project classes 2016-10-17 08:43:19 +02:00
len
ee2aae7e3a Release 0.3.0 2016-10-16 21:00:40 +02:00
len
b6011d4cf5 Minor changes 2016-10-16 20:50:32 +02:00
len
a31c6ff875 Decode notification logo in background thread. Set max bitmap size to 2048 2016-10-16 15:02:55 +02:00
len
69baaac27e Another crash fixed in webtoon reader 2016-10-15 15:27:26 +02:00
b16a90e9d9 Fixed incorrect string for color filter (#493) 2016-10-15 11:50:07 +02:00
len
f31aa622c0 Fix tests 2016-10-15 11:37:28 +02:00
len
4578edf157 Use old refresh icon (but with the app's logo) 2016-10-15 11:31:24 +02:00
len
33df35db1b Multidex debug build 2016-10-15 11:12:16 +02:00
len
093ddd776b Update GCM 2016-10-14 18:17:02 +02:00
len
da10b27219 Dependency udpates, ABI filters 2016-10-14 17:33:58 +02:00
len
5b4ed6f926 Delete old alarm 2016-10-14 17:27:35 +02:00
len
8fc467652d Add app's notification icon 2016-10-13 19:45:10 +02:00
7971b64d57 Update Portuguese(pt_PT) translation. (#492)
-New strings required translation
-Correcting mistakes
2016-10-12 21:24:12 +02:00
len
9c1e2c3c45 Oops.. Fix #489 2016-10-09 14:42:27 +02:00
len
909917e133 Handle individual errors in metadata update 2016-10-09 12:22:21 +02:00
len
3b6c37a30b Increase minimum tile dpi 2016-10-09 11:51:07 +02:00
len
4a6e2a5d99 More crash fixes 2016-10-09 11:34:37 +02:00
len
6cf84256fe Crash fix 2016-10-09 11:10:47 +02:00
len
876831480a Remove unused context from sources 2016-10-08 19:48:55 +02:00
len
aebc9a3b9e Update metadata now ignores only completed manga setting 2016-10-08 15:52:02 +02:00
len
7b28614c37 Ignore chapters with duplicated name. Fixes #483 2016-10-06 20:02:22 +02:00
len
4524c705da Add simple method for preference bindings 2016-10-06 19:39:59 +02:00
len
1f70be688a Allow to refresh the entire library info (fixing empty covers after restoring backups). Closes #462 2016-10-06 19:23:59 +02:00
len
500eedaab7 Explicitly remove read phone permission 2016-10-03 21:15:59 +02:00
2d2ff0a29d Download queue will now be reset if negative. (#485) 2016-10-02 11:59:10 +02:00
len
6d0689fe6c Keep compatibility with YAML sources. Reorder methods 2016-09-30 21:29:03 +02:00
0b3dda18d3 Implement latest updates. (#472) 2016-09-30 21:11:51 +02:00
len
09a8a494a0 Remove unneeded call 2016-09-29 21:46:11 +02:00
len
11ac4df5d7 Bump dependencies, remove unused resources 2016-09-29 19:53:59 +02:00
d352405ba6 Open from homescreen/add shortcut to launcher (#435)
* Add very basic "Add to homescreen" action in manga info fragment.

* Fix open from homescreen opening current manga (if a manga is open).
Code cleanup.

* Improve fix for "Opening from homescreen opens currently open manga if a manga is currently open" and fix "Going back to the main app via a Manga opened through a shortcut repeats the launcher open animation".

* Implement custom icons, add star icon and optimize some things.

* Remove Tachiyomi and custom image icon types.

* Move icon creation task into an observable.
Added some extra error handling.
2016-09-29 18:38:29 +02:00
len
a81609fd2c Fix #480 ? 2016-09-25 23:04:43 +02:00
bf05952582 Gradle custom script 'app/custom.gradle' (#473) 2016-09-23 20:50:01 +02:00
596a24fce8 Added option to share your favorite manga (#477) 2016-09-22 21:36:40 +02:00
len
9f20e40257 Update kotlin and gradle build tools 2016-09-22 19:49:47 +02:00
8be67a4431 Custom color filter for reader (#434)
* [WIP] Custom color filter for reader

* Improvements

* temp image to prevent build error

* Shift all the bits

* Some improvements. Removed DiscreteSeekBar

* Improvements

* API 16 + fixes

* Reduced lag. Fixed brightness value being reset to 0

* Small fixes
2016-09-21 21:26:08 +02:00
58a2f7a874 Hide catalogues (#466)
Hide catalogues
2016-09-18 21:12:12 +02:00
len
cb92143613 Merge anilist backend 2016-09-18 11:50:52 +02:00
len
08e26aa30d Fix library update interval not being updated properly 2016-09-17 11:15:18 +02:00
8e3ffe87b8 Fix broken link (#470) 2016-09-16 11:31:34 +02:00
len
20e2bf9682 Place restrictions above category selection 2016-09-15 18:46:51 +02:00
len
8512f97386 Show default message when no categories selected 2016-09-15 18:39:16 +02:00
len
3ce880bc62 Ignore a random crash when closing the reader 2016-09-15 18:25:10 +02:00
len
72ae243fa2 Remove debug log 2016-09-15 18:04:36 +02:00
len
91829b0e7d Select categories for global update 2016-09-15 18:01:07 +02:00
len
7c3cd10696 Notify first page change 2016-09-11 16:00:06 +02:00
24bdee626f parse manga from the future (#458) 2016-09-09 19:20:24 +02:00
len
6a30a75e3e Upgrade dependencies, use new Timber's overloaded method for errors 2016-09-08 18:30:29 +02:00
ccdc336112 Complete auto updates checker (#449)
* Complete auto updates checker

* Use GcmTaskService for the periodical updates checker

* Persist task across reinstalls

* Hide setting instead of disabling

* Minor refactor
2016-09-07 19:44:55 +02:00
len
a4b71f4d11 Minor UI fixes 2016-09-06 21:22:56 +02:00
len
c3f61e86b7 Improve performance with big images. Feedback is appreciated. 2016-09-06 20:42:24 +02:00
d8d93ee344 Added read filter to chapter select. (#431)
* Added read filter to chapter select.
* Can now select how far back the chapter should be deleted after read.
2016-09-05 11:08:16 +02:00
8ffff44454 Merge pull request #441 from icewind1991/more-eng-filter
Add genre filter support for the remaining English sources
2016-09-03 12:54:20 +02:00
len
568b90d0b4 Fix #446 2016-09-03 11:05:32 +02:00
len
46e09d174b Travis fix. Update gradle 2016-09-01 20:05:11 +02:00
1698a85e99 Add filter support to readmangatoday 2016-08-31 23:19:03 +02:00
c9b62209c2 Add filter support to mangasee 2016-08-31 23:12:25 +02:00
b280d6a76b Add filter support to mangahere 2016-08-31 22:40:12 +02:00
29993e6412 Merge pull request #438 from Taumer/ru_parsers_genre_filter
Implement genre filter for Readmanga and Mintmanga
2016-08-31 19:16:08 +02:00
2a5edf4547 Implement genre filter for Readmanga 2016-08-30 14:23:47 +03:00
d58c517a6c Implement genre filter for Mintmanga 2016-08-30 14:22:55 +03:00
50136c319f MAL switched to SSL/HTTPS (#437)
Changed the URL for myanimelist.net to use HTTPS, as API endpoints are using HTTPS/TLS as of August 25.
2016-08-30 10:28:10 +02:00
2fb3b50535 Add genre filter for catalogue (#428)
* Add genre filter for catalogue

* Implement genre filter for batoto

* hardcode filters for sources

* swtich filter id to string

* reset filters when switching sources

* Add filter support to mangafox

* Catalogue changes

* Indefinite snackbar on error, use plain subscriptions in catalogue presenter
2016-08-28 22:59:00 +02:00
4171e87b4b update Mangasee chapter selector (#429) 2016-08-28 11:38:37 +02:00
len
60b3036037 Rename fragment to view 2016-08-22 12:55:31 +02:00
len
dfb2487640 Library views recycling 2016-08-22 12:54:16 +02:00
len
97454ca162 Disable shared holders for now 2016-08-01 00:09:34 +02:00
len
4200409f79 Fix crashes introduced yesterday 2016-07-31 14:07:12 +02:00
len
b6a06189fb Fix text overlapping, make icons a bit bigger 2016-07-31 01:01:25 +02:00
len
be521804c8 Fix inverted if condition 2016-07-31 00:05:05 +02:00
len
e95fcf6172 Dynamic recyclerview inflation for the library view and better swap handling 2016-07-30 23:54:32 +02:00
len
fbd2235a51 Recycle view holders in library. Format fixes 2016-07-30 20:21:01 +02:00
len
31b1b83606 Fix #408 2016-07-30 17:43:16 +02:00
len
a5d4f63281 Set jdk 8 in travis 2016-07-30 16:40:35 +02:00
len
328f9a70d3 Fix robolectric tests 2016-07-30 16:25:23 +02:00
len
df2b1dbeb1 Update travis 2016-07-30 16:18:51 +02:00
len
f768393a4b Bump dependencies, set target sdk 24 2016-07-30 16:04:43 +02:00
len
c0a0d60c87 Replace page fragments with views 2016-07-30 15:51:49 +02:00
9cf5a4cac0 Minor Improvements (#405) 2016-07-28 01:01:56 +02:00
f21a030cf8 Added the ability to view the library as a list (#394)
* Added in the ability to view the library as a list

* reverted LibraryAdapter and renamed libraryToggleViewEvent to LibraryToggleViewEvent for consistency

* removed LibraryToggleViewEvent and directly subscribed to option change

* fixed the toggleView subscription

* Made the library list item layout more compliant with material design

* Changed unread text style and removed background
2016-07-27 17:37:36 +02:00
len
74e3d387eb Release v0.2.3 2016-07-24 15:41:58 +02:00
len
8f83f497d5 Update history custom put resolver 2016-07-23 15:41:47 +02:00
len
6999fa858e Fix #400 2016-07-23 12:09:06 +02:00
len
8c1bedf796 Back button now returns to start screen. Also fix #356 2016-07-20 19:09:28 +02:00
len
1090c04fe3 Remove deprecated calls and fix a potential race condition 2016-07-18 21:01:51 +02:00
33b04427d5 Added a startup screen preference option (#395)
* Added a startup screen preference option

* changed string and keys to be consistent
2016-07-18 19:58:18 +02:00
len
f7bb356abd Fix exception thrown when Batoto search is empty 2016-07-16 17:25:22 +02:00
len
e16bf0698e Minor fix 2016-07-15 18:22:24 +02:00
len
e6190683dd Observable calls can now be retried, previously all retries were failing 2016-07-10 12:14:30 +02:00
len
e08e41ae0d Remove most unused settings from the reader (keep screen on and page transitions), they are still available in the app's settings. Also lower minimum brightness to -75% 2016-07-08 22:31:46 +02:00
len
5f1a89df63 Remove newThread usages, it probably fixes random crashes 2016-07-08 18:23:03 +02:00
len
f15df40a54 Add an overlay on top of the reader to simulate a lower brightness. Closes #362 2016-07-07 23:18:22 +02:00
len
a32e0e4ec5 Fix #361 2016-07-04 00:27:45 +02:00
len
3e8ac6b2d0 Fix for #361? 2016-07-03 21:48:55 +02:00
len
50a773f456 Fix YAML parser crashing the app on Kitkat and lower 2016-07-03 21:33:07 +02:00
len
42484d718a And a few more crashes fixed in preferences 2016-07-03 21:19:34 +02:00
len
81887000a8 Fix a few crashes 2016-07-03 21:04:09 +02:00
len
987473df44 Minor changes 2016-07-03 18:49:02 +02:00
len
3680eb0bf5 Recently read improvements: Open next chapter if read, local date formatting 2016-07-03 17:58:39 +02:00
len
3dbdc495e7 Minor changes 2016-07-03 14:25:51 +02:00
466515c801 Implement "Wie Manga!" (#379)
* Implement Wie Manga!

* Unnecessary import
2016-07-02 22:16:20 +02:00
len
e198f7e671 Add icons for settings 2016-07-02 22:14:04 +02:00
len
5fe1799dab Fix #333 2016-07-02 14:12:52 +02:00
len
ce7118084a Downloads view now uses a copy of the original queue. Fixes #351 and some crashes while scrolling and removing a download from the queue 2016-07-01 18:30:46 +02:00
len
06786322ca Bump dependencies 2016-07-01 01:52:05 +02:00
len
130b7501d1 Remove no predictive animations. Upgrade Kotlin to 1.0.3 2016-07-01 01:39:57 +02:00
len
864f001c3e Add portuguese translation by @MrAmnesiac 2016-06-30 16:10:30 +02:00
len
1553ce973f Ignore the first spinner selection 2016-06-30 13:02:14 +02:00
72811e59f5 Spanish UI translation (#365)
Added spanish translation
2016-06-29 15:32:05 +02:00
4c1da3575b Cleanup - squid:S1155 - Collection.isEmpty() should be used to test for emptiness (#371) 2016-06-29 15:31:41 +02:00
05c0516a57 New reader menu (#368) 2016-06-27 16:46:31 +02:00
fe6dff9086 Handle a missing page list in MangaHere (#366)
This typically happens when a manga is pulled from their catalog (I tested it on Nisekoi). Previous behavior led to a NullPointerError, now gives an empty page list.

Giving a reason to the user beyond "Empty Page list" would be a good idea in the future (this seems to be one holdup for #220), but there doesn't seem to be an obvious place to put it without touching the base classes.  In the meantime, this is far more informative than null errors.
2016-06-25 13:01:44 +02:00
len
b6df5e6ee6 Reader fixes (MAL not updating in certain scenarios) 2016-06-24 13:39:34 +02:00
3ee5774870 Use Cloudflare client for ReadManga.Today (#363) 2016-06-23 14:05:20 +02:00
c8fbb96f49 Mangasee as image source (#355)
* Mangasee as image source

* revert

* Mangasee source refactoring
2016-06-20 15:37:35 +02:00
len
143303f7df Parser improvements 2016-06-20 00:57:29 +02:00
len
585f7ec17d Remove getAbsoluteUrl method 2016-06-18 17:37:41 +02:00
len
9beeca652f Rewrite preferences with a modified support library v7 2016-06-16 20:52:51 +02:00
len
cd92569355 Restart inject module when the app is created 2016-06-15 17:58:28 +02:00
len
a82e1d0e45 Remove unneeded annotations and some cleanup 2016-06-15 17:47:44 +02:00
len
5ad06df4ac Fix chapters with 1 page not marked as read 2016-06-15 16:47:59 +02:00
len
5cfd5da338 Convert some classes to Kotlin 2016-06-15 16:37:48 +02:00
len
b1d7167112 Bump dependencies 2016-06-15 13:18:27 +02:00
len
a475ecec4d Test package in Kotlin 2016-06-15 12:53:12 +02:00
5c98e020f4 Merge pull request #350 from inorichi/dev
Rewrite DB models, tests and add a chapter loader.
2016-06-15 12:31:42 +02:00
len
eed295587d Fix tests 2016-06-14 15:17:44 +02:00
len
237af4b07d Fix dependency injection and use custom models extending DB ones 2016-06-14 15:17:37 +02:00
len
658860fdff Add chapter loader, drop non seamless mode 2016-06-14 15:15:31 +02:00
len
21ba371a32 Replace Dagger2 with Injekt, reorganize dependencies 2016-06-14 15:13:48 +02:00
len
589160242e Rewrite database models in Kotlin 2016-06-14 15:11:23 +02:00
4de8b6e9a8 Update Mangachan address and fix loading covers after update (#347) 2016-06-11 15:59:04 +02:00
len
e79d536f33 Update readme 2016-06-10 20:48:43 +02:00
len
9e90096328 Match release version 2016-06-10 20:31:33 +02:00
f0a382c21a Improve regex for pages from Readmanga and Mintmanga (#345) 2016-06-09 19:48:23 +02:00
len
682a2c7546 Delete file when exception is thrown 2016-06-09 15:46:08 +02:00
len
2d1e85f280 Fix scroll position with many categories. Closes #332 2016-06-09 14:27:11 +02:00
len
dbec4fc15e Cloudflare fix. Closes #344 2016-06-09 11:32:24 +02:00
95cd77e749 Multiple quality improvements - squid:S1213, squid:S1943, squid:S1066 (#342) 2016-06-08 08:44:12 +02:00
1f8126e2af Use cardBackgroundColor instead of android:Background (#339) 2016-06-07 20:59:43 +02:00
86db7497e9 Small card fixes (#338) 2016-06-07 20:36:40 +02:00
172305fc6a Wrong card background fix + bump gradle version (#337) 2016-06-07 20:14:36 +02:00
dad9dcd742 Improve getAbsolutUrl method (#336)
Also fix Mangachan most popular pages
2016-06-07 20:04:50 +02:00
len
59b90a94d0 Remove covers on error. #334 2016-06-06 20:45:22 +02:00
len
93fc5944f3 Remove unneeded casts 2016-06-06 16:53:58 +02:00
len
7039216eae Manual mappings. Code generation on java classes (better compilation times) 2016-06-06 16:27:24 +02:00
7ba898f701 Added recently read tab (#316) 2016-06-06 15:26:56 +02:00
338 changed files with 14361 additions and 8020 deletions

View File

@ -5,17 +5,20 @@ android:
- tools - tools
# The BuildTools version used by your project # The BuildTools version used by your project
- build-tools-23.0.3 - build-tools-25.0.1
- android-23 - android-25
- extra-android-m2repository - extra-android-m2repository
- extra-google-m2repository - extra-google-m2repository
- extra-android-support - extra-android-support
- extra-google-google_play_services - extra-google-google_play_services
jdk:
- oraclejdk8
before_script: before_script:
- chmod +x gradlew - chmod +x gradlew
#Build, and run tests #Build, and run tests
script: "./gradlew clean buildDebug" script: "./gradlew clean buildStandardDebug"
sudo: false sudo: false
before_cache: before_cache:

View File

@ -1,6 +1,6 @@
| Build | Download | Auto Update | | Build | Download | Auto Update |
|-------|----------|-------------| |-------|----------|-------------|
| [![TeamCity (simple build status)](https://img.shields.io/teamcity/https/teamcity.kanade.eu/s/tachiyomi_Build.svg)](https://teamcity.kanade.eu/project.html?projectId=tachiyomi) [![Travis](https://img.shields.io/travis/inorichi/tachiyomi.svg)](https://travis-ci.org/inorichi/tachiyomi) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=2592000&label=stable)](https://github.com/inorichi/tachiyomi/releases) [![latest dev build](https://img.shields.io/badge/dev-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest/app-debug.apk) | [![fdroid release](https://img.shields.io/badge/stable-F--Droid-blue.svg)](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [![fdroid debug](https://img.shields.io/badge/dev-F--Droid-blue.svg)](//github.com/inorichi/tachiyomi/wiki/FDroid-for-debug-versions) | | [![TeamCity (simple build status)](https://img.shields.io/teamcity/https/teamcity.kanade.eu/s/tachiyomi_Build.svg)](https://teamcity.kanade.eu/project.html?projectId=tachiyomi) [![Travis](https://img.shields.io/travis/inorichi/tachiyomi.svg)](https://travis-ci.org/inorichi/tachiyomi) | [![stable release](https://img.shields.io/github/release/inorichi/tachiyomi.svg?maxAge=3600&label=stable)](https://github.com/inorichi/tachiyomi/releases) [![latest dev build](https://img.shields.io/badge/dev-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest/app-debug.apk) | [![fdroid release](https://img.shields.io/badge/stable-F--Droid-blue.svg)](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [![fdroid debug](https://img.shields.io/badge/dev-F--Droid-blue.svg)](//github.com/inorichi/tachiyomi/wiki/FDroid-for-dev-versions) |
## [Report an issue](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md) ## [Report an issue](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md)

2
app/.gitignore vendored
View File

@ -1,4 +1,4 @@
/build /build
*iml *iml
*.iml *.iml
.idea custom.gradle

View File

@ -4,6 +4,10 @@ apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-android-extensions'
if (file("custom.gradle").exists()) {
apply from: "custom.gradle"
}
ext { ext {
// Git is needed in your system PATH for these commands to work. // Git is needed in your system PATH for these commands to work.
// If it's not installed, you can return a random value as a workaround // If it's not installed, you can return a random value as a workaround
@ -24,44 +28,54 @@ ext {
} }
} }
def includeUpdater() {
return hasProperty("include_updater")
}
android { android {
compileSdkVersion 23 compileSdkVersion 25
buildToolsVersion "23.0.3" buildToolsVersion "25.0.1"
publishNonDefault true publishNonDefault true
defaultConfig { defaultConfig {
applicationId "eu.kanade.tachiyomi" applicationId "eu.kanade.tachiyomi"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 23 targetSdkVersion 25
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 8 versionCode 16
versionCode project.findProperty('versionCode')?.toInteger() ?: 8 versionName "0.4.0"
versionName "0.2.2"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\"" buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\"" buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\"" buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
buildConfigField "boolean", "INCLUDE_UPDATER", "${includeUpdater()}"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
ndk {
abiFilters "armeabi", "armeabi-v7a", "x86"
}
} }
buildTypes { buildTypes {
debug { debug {
versionNameSuffix ".${getCommitCount()}" versionNameSuffix "-${getCommitCount()}"
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
multiDexEnabled true
} }
release { release {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
multiDexEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
} }
productFlavors {
standard {
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
}
fdroid {
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
}
}
packagingOptions { packagingOptions {
exclude 'META-INF/DEPENDENCIES' exclude 'META-INF/DEPENDENCIES'
exclude 'LICENSE.txt' exclude 'LICENSE.txt'
@ -79,119 +93,116 @@ android {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
} }
// http://stackoverflow.com/questions/32759529/androidhttpclient-not-found-when-running-robolectric
useLibrary 'org.apache.http.legacy'
}
kapt {
generateStubs = true
} }
dependencies { dependencies {
final SUPPORT_LIBRARY_VERSION = '23.4.0'
final DAGGER_VERSION = '2.4'
final RETROFIT_VERSION = '2.0.2'
final NUCLEUS_VERSION = '3.0.0'
final STORIO_VERSION = '1.8.0'
final MOCKITO_VERSION = '1.10.19'
// Modified dependencies // Modified dependencies
compile 'com.github.inorichi:subsampling-scale-image-view:421fb81' compile 'com.github.inorichi:subsampling-scale-image-view:f687b74'
compile 'com.github.inorichi:ReactiveNetwork:69092ed'
// Android support library // Android support library
compile "com.android.support:support-v4:$SUPPORT_LIBRARY_VERSION" final support_library_version = '25.0.1'
compile "com.android.support:appcompat-v7:$SUPPORT_LIBRARY_VERSION" compile "com.android.support:support-v4:$support_library_version"
compile "com.android.support:cardview-v7:$SUPPORT_LIBRARY_VERSION" compile "com.android.support:appcompat-v7:$support_library_version"
compile "com.android.support:design:$SUPPORT_LIBRARY_VERSION" compile "com.android.support:cardview-v7:$support_library_version"
compile "com.android.support:recyclerview-v7:$SUPPORT_LIBRARY_VERSION" compile "com.android.support:design:$support_library_version"
compile "com.android.support:support-annotations:$SUPPORT_LIBRARY_VERSION" compile "com.android.support:recyclerview-v7:$support_library_version"
compile "com.android.support:preference-v7:$SUPPORT_LIBRARY_VERSION" compile "com.android.support:support-annotations:$support_library_version"
compile "com.android.support:preference-v14:$SUPPORT_LIBRARY_VERSION" compile "com.android.support:customtabs:$support_library_version"
compile "com.android.support:customtabs:$SUPPORT_LIBRARY_VERSION"
compile 'com.android.support:multidex:1.0.1'
// ReactiveX // ReactiveX
compile 'io.reactivex:rxandroid:1.2.0' compile 'io.reactivex:rxandroid:1.2.1'
compile 'io.reactivex:rxjava:1.1.5' compile 'io.reactivex:rxjava:1.2.3'
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.1' compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
compile 'com.github.pwittchen:reactivenetwork:0.6.0'
// Network client // Network client
compile "com.squareup.okhttp3:okhttp:3.3.1" compile "com.squareup.okhttp3:okhttp:3.5.0"
compile 'com.squareup.okio:okio:1.11.0'
// REST // REST
compile "com.squareup.retrofit2:retrofit:$RETROFIT_VERSION" final retrofit_version = '2.1.0'
compile "com.squareup.retrofit2:converter-gson:$RETROFIT_VERSION" compile "com.squareup.retrofit2:retrofit:$retrofit_version"
compile "com.squareup.retrofit2:adapter-rxjava:$RETROFIT_VERSION" compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
// IO
compile 'com.squareup.okio:okio:1.8.0'
// JSON // JSON
compile 'com.google.code.gson:gson:2.6.2' compile 'com.google.code.gson:gson:2.8.0'
compile 'com.github.salomonbrys.kotson:kotson:2.4.0'
// YAML // YAML
compile 'org.yaml:snakeyaml:1.17' compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
// JavaScript engine // JavaScript engine
compile 'com.squareup.duktape:duktape-android:0.9.5' compile 'com.squareup.duktape:duktape-android:1.1.0'
// Disk cache // Disk
compile 'com.jakewharton:disklrucache:2.0.2' compile 'com.jakewharton:disklrucache:2.0.2'
compile 'com.github.seven332:unifile:1.0.0'
// Parse HTML // HTML parser
compile 'org.jsoup:jsoup:1.9.2' compile 'org.jsoup:jsoup:1.10.1'
// Job scheduling
compile 'com.evernote:android-job:1.1.3'
compile 'com.google.android.gms:play-services-gcm:10.0.1'
// Changelog // Changelog
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// Database // Database
compile "com.pushtorefresh.storio:sqlite:$STORIO_VERSION" compile "com.pushtorefresh.storio:sqlite:1.11.0"
compile "com.pushtorefresh.storio:sqlite-annotations:$STORIO_VERSION"
kapt "com.pushtorefresh.storio:sqlite-annotations-processor:$STORIO_VERSION"
// Model View Presenter // Model View Presenter
compile "info.android15.nucleus:nucleus:$NUCLEUS_VERSION" final nucleus_version = '3.0.0'
compile "info.android15.nucleus:nucleus-support-v4:$NUCLEUS_VERSION" compile "info.android15.nucleus:nucleus:$nucleus_version"
compile "info.android15.nucleus:nucleus-support-v7:$NUCLEUS_VERSION" compile "info.android15.nucleus:nucleus-support-v4:$nucleus_version"
compile "info.android15.nucleus:nucleus-support-v7:$nucleus_version"
// Dependency injection // Dependency injection
compile "com.google.dagger:dagger:$DAGGER_VERSION" compile "uy.kohesive.injekt:injekt-core:1.16.1"
kapt "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
provided 'org.glassfish:javax.annotation:10.0-b28'
// Image library // Image library
compile 'com.github.bumptech.glide:glide:3.7.0' compile 'com.github.bumptech.glide:glide:3.7.0'
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar' compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
// Transformations
compile 'jp.wasabeef:glide-transformations:2.0.1'
// Logging // Logging
compile 'com.jakewharton.timber:timber:4.1.2' compile 'com.jakewharton.timber:timber:4.3.1'
// Crash reports // Crash reports
compile 'ch.acra:acra:4.8.5' compile 'ch.acra:acra:4.9.1'
// UI // UI
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
compile 'eu.davidea:flexible-adapter:4.2.0' compile 'eu.davidea:flexible-adapter:4.2.0'
compile 'com.nononsenseapps:filepicker:2.5.2' compile 'com.nononsenseapps:filepicker:2.5.2'
compile 'com.github.amulyakhare:TextDrawable:558677e' compile 'com.github.amulyakhare:TextDrawable:558677e'
compile 'com.afollestad.material-dialogs:core:0.8.5.9' compile 'com.afollestad.material-dialogs:core:0.9.1.0'
compile 'net.xpece.android:support-preference:1.2.0'
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
compile 'de.hdodenhof:circleimageview:2.1.0'
// Tests // Tests
testCompile 'junit:junit:4.12' testCompile 'junit:junit:4.12'
testCompile 'org.assertj:assertj-core:1.7.1' testCompile 'org.assertj:assertj-core:1.7.1'
testCompile "org.mockito:mockito-core:$MOCKITO_VERSION" testCompile 'org.mockito:mockito-core:1.10.19'
testCompile('org.robolectric:robolectric:3.0') {
exclude group: 'commons-logging', module: 'commons-logging' final robolectric_version = '3.1.4'
exclude group: 'org.apache.httpcomponents', module: 'httpclient' testCompile "org.robolectric:robolectric:$robolectric_version"
} testCompile "org.robolectric:shadows-multidex:$robolectric_version"
testCompile "org.robolectric:shadows-play-services:$robolectric_version"
kaptTest "com.google.dagger:dagger-compiler:$DAGGER_VERSION"
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
} }
buildscript { buildscript {
ext.kotlin_version = '1.0.2' ext.kotlin_version = '1.0.5-2'
repositories { repositories {
mavenCentral() mavenCentral()
} }

View File

@ -1,6 +1,9 @@
-dontobfuscate -dontobfuscate
-keep class eu.kanade.tachiyomi.injection.** { *; } -keep class eu.kanade.tachiyomi.**
-keep class com.hippo.image.** { *; }
-keep interface com.hippo.image.** { *; }
# OkHttp # OkHttp
-keepattributes Signature -keepattributes Signature
@ -61,7 +64,10 @@
public <init>(android.content.Context); public <init>(android.content.Context);
} }
## GSON 2.2.4 specific rules ## # ReactiveNetwork
-dontwarn com.github.pwittchen.reactivenetwork.**
## GSON ##
# Gson uses generic type information stored in a class file when working with fields. Proguard # Gson uses generic type information stored in a class file when working with fields. Proguard
# removes such information by default, so configure it to keep all of it. # removes such information by default, so configure it to keep all of it.
@ -70,55 +76,18 @@
# For using GSON @Expose annotation # For using GSON @Expose annotation
-keepattributes *Annotation* -keepattributes *Annotation*
-keepattributes EnclosingMethod
# Gson specific classes # Gson specific classes
-keep class sun.misc.Unsafe { *; } -keep class sun.misc.Unsafe { *; }
-keep class com.google.gson.stream.** { *; } #-keep class com.google.gson.stream.** { *; }
## ACRA 4.5.0 specific rules ## # Application classes that will be serialized/deserialized over Gson
-keep class com.google.gson.examples.android.model.** { *; }
# we need line numbers in our stack traces otherwise they are pretty useless # Prevent proguard from stripping interface information from TypeAdapterFactory,
-renamesourcefileattribute SourceFile # JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keepattributes SourceFile,LineNumberTable -keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
# ACRA needs "annotations" so add this... -keep class * implements com.google.gson.JsonDeserializer
-keepattributes *Annotation*
# keep this class so that logging will show 'ACRA' and not a obfuscated name like 'a'.
# Note: if you are removing log messages elsewhere in this file then this isn't necessary
-keep class org.acra.ACRA {
*;
}
# keep this around for some enums that ACRA needs
-keep class org.acra.ReportingInteractionMode {
*;
}
-keepnames class org.acra.sender.HttpSender$** {
*;
}
-keepnames class org.acra.ReportField {
*;
}
# keep this otherwise it is removed by ProGuard
-keep public class org.acra.ErrorReporter {
public void addCustomData(java.lang.String,java.lang.String);
public void putCustomData(java.lang.String,java.lang.String);
public void removeCustomData(java.lang.String);
}
# keep this otherwise it is removed by ProGuard
-keep public class org.acra.ErrorReporter {
public void handleSilentException(java.lang.Throwable);
}
# Keep the support library
-keep class org.acra.** { *; }
-keep interface org.acra.** { *; }
# SnakeYaml # SnakeYaml
-keep class org.yaml.snakeyaml.** { public protected private *; } -keep class org.yaml.snakeyaml.** { public protected private *; }

View File

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest
package="eu.kanade.tachiyomi"> xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="eu.kanade.tachiyomi">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@ -8,6 +10,8 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<application <application
android:name=".App" android:name=".App"
@ -18,8 +22,7 @@
android:largeHeap="true" android:largeHeap="true"
android:theme="@style/Theme.Tachiyomi" > android:theme="@style/Theme.Tachiyomi" >
<activity <activity
android:name=".ui.main.MainActivity" android:name=".ui.main.MainActivity">
android:theme="@style/Theme.BrandedLaunch">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -28,7 +31,8 @@
</activity> </activity>
<activity <activity
android:name=".ui.manga.MangaActivity" android:name=".ui.manga.MangaActivity"
android:parentActivityName=".ui.main.MainActivity" > android:parentActivityName=".ui.main.MainActivity"
android:exported="true">
</activity> </activity>
<activity <activity
android:name=".ui.reader.ReaderActivity" android:name=".ui.reader.ReaderActivity"
@ -50,6 +54,16 @@
android:theme="@style/FilePickerTheme"> android:theme="@style/FilePickerTheme">
</activity> </activity>
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths"/>
</provider>
<service android:name=".data.library.LibraryUpdateService" <service android:name=".data.library.LibraryUpdateService"
android:exported="false"/> android:exported="false"/>
@ -59,46 +73,14 @@
<service android:name=".data.mangasync.UpdateMangaSyncService" <service android:name=".data.mangasync.UpdateMangaSyncService"
android:exported="false"/> android:exported="false"/>
<receiver <service android:name=".data.updater.UpdateDownloaderService"
android:name=".data.library.LibraryUpdateService$SyncOnConnectionAvailable" android:exported="false"/>
android:enabled="false">
<intent-filter>
<action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
</intent-filter>
</receiver>
<receiver <receiver android:name=".data.updater.UpdateNotificationReceiver"/>
android:name=".data.library.LibraryUpdateService$SyncOnPowerConnected"
android:enabled="false">
<intent-filter>
<action android:name="android.intent.action.ACTION_POWER_CONNECTED" />
</intent-filter>
</receiver>
<receiver <receiver android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver" />
android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver">
</receiver>
<receiver
android:name=".data.updater.UpdateDownloader$InstallOnReceived">
</receiver>
<receiver
android:name=".data.library.LibraryUpdateAlarm">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="eu.kanade.UPDATE_LIBRARY" />
</intent-filter>
</receiver>
<receiver
android:name=".data.updater.UpdateDownloaderAlarm">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="eu.kanade.CHECK_UPDATE"/>
</intent-filter>
</receiver>
<receiver android:name=".ui.reader.notification.ImageNotificationReceiver" />
<meta-data <meta-data
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule" android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"

View File

@ -2,14 +2,16 @@ package eu.kanade.tachiyomi
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import android.support.multidex.MultiDex
import eu.kanade.tachiyomi.injection.ComponentReflectionInjector import com.evernote.android.job.JobManager
import eu.kanade.tachiyomi.injection.component.AppComponent import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.injection.component.DaggerAppComponent import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import eu.kanade.tachiyomi.injection.module.AppModule
import org.acra.ACRA import org.acra.ACRA
import org.acra.annotation.ReportsCrashes import org.acra.annotation.ReportsCrashes
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.InjektScope
import uy.kohesive.injekt.registry.default.DefaultRegistrar
@ReportsCrashes( @ReportsCrashes(
formUri = "http://tachiyomi.kanade.eu/crash_report", formUri = "http://tachiyomi.kanade.eu/crash_report",
@ -20,43 +22,36 @@ import timber.log.Timber
) )
open class App : Application() { open class App : Application() {
lateinit var component: AppComponent
private set
lateinit var componentReflection: ComponentReflectionInjector<AppComponent>
private set
var appTheme = 0
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Injekt = InjektScope(DefaultRegistrar())
Injekt.importModule(AppModule(this))
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
component = prepareAppComponent().build()
componentReflection = ComponentReflectionInjector(AppComponent::class.java, component)
setupTheme()
setupAcra() setupAcra()
setupJobManager()
} }
private fun setupTheme() { override fun attachBaseContext(base: Context) {
appTheme = PreferencesHelper.getTheme(this) super.attachBaseContext(base)
} if (BuildConfig.DEBUG) {
MultiDex.install(this)
protected open fun prepareAppComponent(): DaggerAppComponent.Builder { }
return DaggerAppComponent.builder()
.appModule(AppModule(this))
} }
protected open fun setupAcra() { protected open fun setupAcra() {
ACRA.init(this) ACRA.init(this)
} }
companion object { protected open fun setupJobManager() {
@JvmStatic JobManager.create(this).addJobCreator { tag ->
fun get(context: Context): App { when (tag) {
return context.applicationContext as App LibraryUpdateJob.TAG -> LibraryUpdateJob()
UpdateCheckerJob.TAG -> UpdateCheckerJob()
else -> null
}
} }
} }
} }

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi
import android.app.Application
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.SourceManager
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingletonFactory
class AppModule(val app: Application) : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingletonFactory { PreferencesHelper(app) }
addSingletonFactory { DatabaseHelper(app) }
addSingletonFactory { ChapterCache(app) }
addSingletonFactory { CoverCache(app) }
addSingletonFactory { NetworkHelper(app) }
addSingletonFactory { SourceManager(app) }
addSingletonFactory { DownloadManager(app) }
addSingletonFactory { MangaSyncManager(app) }
addSingletonFactory { Gson() }
}
}

View File

@ -5,4 +5,5 @@ object Constants {
const val NOTIFICATION_UPDATER_ID = 2 const val NOTIFICATION_UPDATER_ID = 2
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3 const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4 const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 5
} }

View File

@ -1,14 +1,15 @@
package eu.kanade.tachiyomi.data.backup package eu.kanade.tachiyomi.data.backup
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.* import com.google.gson.*
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.* import eu.kanade.tachiyomi.data.database.models.*
import java.io.* import java.io.*
import java.lang.reflect.Type
import java.util.* import java.util.*
/** /**
@ -43,7 +44,9 @@ class BackupManager(private val db: DatabaseHelper) {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val gson = GsonBuilder() private val gson = GsonBuilder()
.registerTypeAdapter(Integer::class.java, IntegerSerializer()) .registerTypeAdapter(java.lang.Integer::class.java, IntegerSerializer())
.registerTypeAdapter(java.lang.Boolean::class.java, BooleanSerializer())
.registerTypeAdapter(java.lang.Long::class.java, LongSerializer())
.setExclusionStrategies(IdExclusion()) .setExclusionStrategies(IdExclusion())
.create() .create()
@ -191,8 +194,7 @@ class BackupManager(private val db: DatabaseHelper) {
private fun restoreCategories(jsonCategories: JsonArray) { private fun restoreCategories(jsonCategories: JsonArray) {
// Get categories from file and from db // Get categories from file and from db
val dbCategories = db.getCategories().executeAsBlocking() val dbCategories = db.getCategories().executeAsBlocking()
val backupCategories = getArrayOrEmpty<Category>(jsonCategories, val backupCategories = gson.fromJson<List<CategoryImpl>>(jsonCategories)
object : TypeToken<List<Category>>() {}.type)
// Iterate over them // Iterate over them
for (category in backupCategories) { for (category in backupCategories) {
@ -224,17 +226,13 @@ class BackupManager(private val db: DatabaseHelper) {
* @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json. * @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json.
*/ */
private fun restoreMangas(jsonMangas: JsonArray) { private fun restoreMangas(jsonMangas: JsonArray) {
val chapterToken = object : TypeToken<List<Chapter>>() {}.type
val mangaSyncToken = object : TypeToken<List<MangaSync>>() {}.type
val categoriesNamesToken = object : TypeToken<List<String>>() {}.type
for (backupManga in jsonMangas) { for (backupManga in jsonMangas) {
// Map every entry to objects // Map every entry to objects
val element = backupManga.asJsonObject val element = backupManga.asJsonObject
val manga = gson.fromJson(element.get(MANGA), Manga::class.java) val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java)
val chapters = getArrayOrEmpty<Chapter>(element.get(CHAPTERS), chapterToken) val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray())
val sync = getArrayOrEmpty<MangaSync>(element.get(MANGA_SYNC), mangaSyncToken) val sync = gson.fromJson<List<MangaSyncImpl>>(element.get(MANGA_SYNC) ?: JsonArray())
val categories = getArrayOrEmpty<String>(element.get(CATEGORIES), categoriesNamesToken) val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
// Restore everything related to this manga // Restore everything related to this manga
restoreManga(manga) restoreManga(manga)
@ -340,7 +338,7 @@ class BackupManager(private val db: DatabaseHelper) {
private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) { private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) {
// Fix foreign keys with the current manga id // Fix foreign keys with the current manga id
for (mangaSync in sync) { for (mangaSync in sync) {
mangaSync.manga_id = manga.id mangaSync.manga_id = manga.id!!
} }
val dbSyncs = db.getMangasSync(manga).executeAsBlocking() val dbSyncs = db.getMangasSync(manga).executeAsBlocking()
@ -367,15 +365,4 @@ class BackupManager(private val db: DatabaseHelper) {
} }
} }
/**
* Returns a list of items from a json element, or an empty list if the element is null.
*
* @param element the json to be mapped to a list of items.
* @param type the gson mapping to restore the list.
* @return a list of items.
*/
private fun <T> getArrayOrEmpty(element: JsonElement?, type: Type): List<T> {
return gson.fromJson<List<T>>(element, type) ?: ArrayList<T>()
}
} }

View File

@ -0,0 +1,16 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.google.gson.JsonElement
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
class BooleanSerializer : JsonSerializer<Boolean> {
override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? {
if (value != null && value != false)
return JsonPrimitive(value)
return null
}
}

View File

@ -2,10 +2,10 @@ package eu.kanade.tachiyomi.data.backup.serializer
import com.google.gson.ExclusionStrategy import com.google.gson.ExclusionStrategy
import com.google.gson.FieldAttributes import com.google.gson.FieldAttributes
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.CategoryImpl
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.MangaSync import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl
class IdExclusion : ExclusionStrategy { class IdExclusion : ExclusionStrategy {
@ -15,10 +15,10 @@ class IdExclusion : ExclusionStrategy {
private val syncExclusions = listOf("id", "manga_id", "update") private val syncExclusions = listOf("id", "manga_id", "update")
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) { override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
Manga::class.java -> mangaExclusions.contains(f.name) MangaImpl::class.java -> mangaExclusions.contains(f.name)
Chapter::class.java -> chapterExclusions.contains(f.name) ChapterImpl::class.java -> chapterExclusions.contains(f.name)
MangaSync::class.java -> syncExclusions.contains(f.name) MangaSyncImpl::class.java -> syncExclusions.contains(f.name)
Category::class.java -> categoryExclusions.contains(f.name) CategoryImpl::class.java -> categoryExclusions.contains(f.name)
else -> false else -> false
} }

View File

@ -0,0 +1,16 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.google.gson.JsonElement
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
class LongSerializer : JsonSerializer<Long> {
override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? {
if (value != null && value !== 0L)
return JsonPrimitive(value)
return null
}
}

View File

@ -2,18 +2,18 @@ package eu.kanade.tachiyomi.data.cache
import android.content.Context import android.content.Context
import android.text.format.Formatter import android.text.format.Formatter
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.jakewharton.disklrucache.DiskLruCache import com.jakewharton.disklrucache.DiskLruCache
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.DiskUtils import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.saveImageTo import eu.kanade.tachiyomi.util.saveTo
import okhttp3.Response import okhttp3.Response
import okio.Okio import okio.Okio
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.lang.reflect.Type
/** /**
* Class used to create chapter cache * Class used to create chapter cache
@ -26,15 +26,6 @@ import java.lang.reflect.Type
*/ */
class ChapterCache(private val context: Context) { class ChapterCache(private val context: Context) {
/** Google Json class used for parsing JSON files. */
private val gson: Gson = Gson()
/** Cache class used for cache management. */
private val diskCache: DiskLruCache
/** Page list collection used for deserializing from JSON. */
private val pageListCollection: Type = object : TypeToken<List<Page>>() {}.type
companion object { companion object {
/** Name of cache directory. */ /** Name of cache directory. */
const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache" const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache"
@ -49,38 +40,37 @@ class ChapterCache(private val context: Context) {
const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024 const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024
} }
init { /** Google Json class used for parsing JSON files. */
// Open cache in default cache directory. private val gson: Gson by injectLazy()
diskCache = DiskLruCache.open(
File(context.cacheDir, PARAMETER_CACHE_DIRECTORY), /** Cache class used for cache management. */
PARAMETER_APP_VERSION, private val diskCache = DiskLruCache.open(
PARAMETER_VALUE_COUNT, File(context.externalCacheDir, PARAMETER_CACHE_DIRECTORY),
PARAMETER_CACHE_SIZE) PARAMETER_APP_VERSION,
} PARAMETER_VALUE_COUNT,
PARAMETER_CACHE_SIZE)
/** /**
* Returns directory of cache. * Returns directory of cache.
* @return directory of cache.
*/ */
val cacheDir: File val cacheDir: File
get() = diskCache.directory get() = diskCache.directory
/** /**
* Returns real size of directory. * Returns real size of directory.
* @return real size of directory.
*/ */
private val realSize: Long private val realSize: Long
get() = DiskUtils.getDirectorySize(cacheDir) get() = DiskUtil.getDirectorySize(cacheDir)
/** /**
* Returns real size of directory in human readable format. * Returns real size of directory in human readable format.
* @return real size of directory.
*/ */
val readableSize: String val readableSize: String
get() = Formatter.formatFileSize(context, realSize) get() = Formatter.formatFileSize(context, realSize)
/** /**
* Remove file from cache. * Remove file from cache.
*
* @param file name of file "md5.0". * @param file name of file "md5.0".
* @return status of deletion for the file. * @return status of deletion for the file.
*/ */
@ -101,23 +91,25 @@ class ChapterCache(private val context: Context) {
/** /**
* Get page list from cache. * Get page list from cache.
*
* @param chapterUrl the url of the chapter. * @param chapterUrl the url of the chapter.
* @return an observable of the list of pages. * @return an observable of the list of pages.
*/ */
fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> { fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> {
return Observable.fromCallable<List<Page>> { return Observable.fromCallable<List<Page>> {
// Get the key for the chapter. // Get the key for the chapter.
val key = DiskUtils.hashKeyForDisk(chapterUrl) val key = DiskUtil.hashKeyForDisk(chapterUrl)
// Convert JSON string to list of objects. Throws an exception if snapshot is null // Convert JSON string to list of objects. Throws an exception if snapshot is null
diskCache.get(key).use { diskCache.get(key).use {
gson.fromJson(it.getString(0), pageListCollection) gson.fromJson<List<Page>>(it.getString(0))
} }
} }
} }
/** /**
* Add page list to disk cache. * Add page list to disk cache.
*
* @param chapterUrl the url of the chapter. * @param chapterUrl the url of the chapter.
* @param pages list of pages. * @param pages list of pages.
*/ */
@ -130,7 +122,7 @@ class ChapterCache(private val context: Context) {
try { try {
// Get editor from md5 key. // Get editor from md5 key.
val key = DiskUtils.hashKeyForDisk(chapterUrl) val key = DiskUtil.hashKeyForDisk(chapterUrl)
editor = diskCache.edit(key) ?: return editor = diskCache.edit(key) ?: return
// Write chapter urls to cache. // Write chapter urls to cache.
@ -151,51 +143,50 @@ class ChapterCache(private val context: Context) {
} }
/** /**
* Check if image is in cache. * Returns true if image is in cache.
*
* @param imageUrl url of image. * @param imageUrl url of image.
* @return true if in cache otherwise false. * @return true if in cache otherwise false.
*/ */
fun isImageInCache(imageUrl: String): Boolean { fun isImageInCache(imageUrl: String): Boolean {
try { try {
return diskCache.get(DiskUtils.hashKeyForDisk(imageUrl)) != null return diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null
} catch (e: IOException) { } catch (e: IOException) {
return false return false
} }
} }
/** /**
* Get image path from url. * Get image file from url.
*
* @param imageUrl url of image. * @param imageUrl url of image.
* @return path of image. * @return path of image.
*/ */
fun getImagePath(imageUrl: String): String? { fun getImageFile(imageUrl: String): File {
try { // Get file from md5 key.
// Get file from md5 key. val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0"
val imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0" return File(diskCache.directory, imageName)
return File(diskCache.directory, imageName).canonicalPath
} catch (e: IOException) {
return null
}
} }
/** /**
* Add image to cache. * Add image to cache.
*
* @param imageUrl url of image. * @param imageUrl url of image.
* @param response http response from page. * @param response http response from page.
* @throws IOException image error. * @throws IOException image error.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun putImageToCache(imageUrl: String, response: Response, reencode: Boolean) { fun putImageToCache(imageUrl: String, response: Response) {
// Initialize editor (edits the values for an entry). // Initialize editor (edits the values for an entry).
var editor: DiskLruCache.Editor? = null var editor: DiskLruCache.Editor? = null
try { try {
// Get editor from md5 key. // Get editor from md5 key.
val key = DiskUtils.hashKeyForDisk(imageUrl) val key = DiskUtil.hashKeyForDisk(imageUrl)
editor = diskCache.edit(key) ?: throw IOException("Unable to edit key") editor = diskCache.edit(key) ?: throw IOException("Unable to edit key")
// Get OutputStream and write image with Okio. // Get OutputStream and write image with Okio.
response.body().source().saveImageTo(editor.newOutputStream(0), reencode) response.body().source().saveTo(editor.newOutputStream(0))
diskCache.flush() diskCache.flush()
editor.commit() editor.commit()

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.cache package eu.kanade.tachiyomi.data.cache
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.util.DiskUtils import eu.kanade.tachiyomi.util.DiskUtil
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@ -29,7 +29,7 @@ class CoverCache(private val context: Context) {
* @return cover image. * @return cover image.
*/ */
fun getCoverFile(thumbnailUrl: String): File { fun getCoverFile(thumbnailUrl: String): File {
return File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl)) return File(cacheDir, DiskUtil.hashKeyForDisk(thumbnailUrl))
} }
/** /**

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database
import android.content.Context import android.content.Context
import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
import eu.kanade.tachiyomi.data.database.mappers.*
import eu.kanade.tachiyomi.data.database.models.* import eu.kanade.tachiyomi.data.database.models.*
import eu.kanade.tachiyomi.data.database.queries.* import eu.kanade.tachiyomi.data.database.queries.*
@ -9,15 +10,16 @@ import eu.kanade.tachiyomi.data.database.queries.*
* This class provides operations to manage the database through its interfaces. * This class provides operations to manage the database through its interfaces.
*/ */
open class DatabaseHelper(context: Context) open class DatabaseHelper(context: Context)
: MangaQueries, ChapterQueries, MangaSyncQueries, CategoryQueries, MangaCategoryQueries { : MangaQueries, ChapterQueries, MangaSyncQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
override val db = DefaultStorIOSQLite.builder() override val db = DefaultStorIOSQLite.builder()
.sqliteOpenHelper(DbOpenHelper(context)) .sqliteOpenHelper(DbOpenHelper(context))
.addTypeMapping(Manga::class.java, MangaSQLiteTypeMapping()) .addTypeMapping(Manga::class.java, MangaTypeMapping())
.addTypeMapping(Chapter::class.java, ChapterSQLiteTypeMapping()) .addTypeMapping(Chapter::class.java, ChapterTypeMapping())
.addTypeMapping(MangaSync::class.java, MangaSyncSQLiteTypeMapping()) .addTypeMapping(MangaSync::class.java, MangaSyncTypeMapping())
.addTypeMapping(Category::class.java, CategorySQLiteTypeMapping()) .addTypeMapping(Category::class.java, CategoryTypeMapping())
.addTypeMapping(MangaCategory::class.java, MangaCategorySQLiteTypeMapping()) .addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping())
.addTypeMapping(History::class.java, HistoryTypeMapping())
.build() .build()
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block) inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)

View File

@ -3,23 +3,23 @@ package eu.kanade.tachiyomi.data.database
import com.pushtorefresh.storio.sqlite.StorIOSQLite import com.pushtorefresh.storio.sqlite.StorIOSQLite
inline fun StorIOSQLite.inTransaction(block: () -> Unit) { inline fun StorIOSQLite.inTransaction(block: () -> Unit) {
internal().beginTransaction() lowLevel().beginTransaction()
try { try {
block() block()
internal().setTransactionSuccessful() lowLevel().setTransactionSuccessful()
} finally { } finally {
internal().endTransaction() lowLevel().endTransaction()
} }
} }
inline fun <T> StorIOSQLite.inTransactionReturn(block: () -> T): T { inline fun <T> StorIOSQLite.inTransactionReturn(block: () -> T): T {
internal().beginTransaction() lowLevel().beginTransaction()
try { try {
val result = block() val result = block()
internal().setTransactionSuccessful() lowLevel().setTransactionSuccessful()
return result return result
} finally { } finally {
internal().endTransaction() lowLevel().endTransaction()
} }
} }

View File

@ -17,7 +17,7 @@ class DbOpenHelper(context: Context)
/** /**
* Version of the database. * Version of the database.
*/ */
const val DATABASE_VERSION = 2 const val DATABASE_VERSION = 4
} }
override fun onCreate(db: SQLiteDatabase) = with(db) { override fun onCreate(db: SQLiteDatabase) = with(db) {
@ -26,11 +26,13 @@ class DbOpenHelper(context: Context)
execSQL(MangaSyncTable.createTableQuery) execSQL(MangaSyncTable.createTableQuery)
execSQL(CategoryTable.createTableQuery) execSQL(CategoryTable.createTableQuery)
execSQL(MangaCategoryTable.createTableQuery) execSQL(MangaCategoryTable.createTableQuery)
execSQL(HistoryTable.createTableQuery)
// DB indexes // DB indexes
execSQL(MangaTable.createUrlIndexQuery) execSQL(MangaTable.createUrlIndexQuery)
execSQL(MangaTable.createFavoriteIndexQuery) execSQL(MangaTable.createFavoriteIndexQuery)
execSQL(ChapterTable.createMangaIdIndexQuery) execSQL(ChapterTable.createMangaIdIndexQuery)
execSQL(HistoryTable.createChapterIdIndexQuery)
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@ -41,10 +43,18 @@ class DbOpenHelper(context: Context)
db.execSQL("""UPDATE mangas SET thumbnail_url = db.execSQL("""UPDATE mangas SET thumbnail_url =
REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""") REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""")
} }
if (oldVersion < 3) {
// Initialize history tables
db.execSQL(HistoryTable.createTableQuery)
db.execSQL(HistoryTable.createChapterIdIndexQuery)
}
if (oldVersion < 4) {
db.execSQL(ChapterTable.bookmarkUpdateQuery)
}
} }
override fun onConfigure(db: SQLiteDatabase) { override fun onConfigure(db: SQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true) db.setForeignKeyConstraintsEnabled(true)
} }
} }

View File

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
import eu.kanade.tachiyomi.data.database.tables.CategoryTable.COL_FLAGS
import eu.kanade.tachiyomi.data.database.tables.CategoryTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.CategoryTable.COL_NAME
import eu.kanade.tachiyomi.data.database.tables.CategoryTable.COL_ORDER
import eu.kanade.tachiyomi.data.database.tables.CategoryTable.TABLE
class CategoryTypeMapping : SQLiteTypeMapping<Category>(
CategoryPutResolver(),
CategoryGetResolver(),
CategoryDeleteResolver()
)
class CategoryPutResolver : DefaultPutResolver<Category>() {
override fun mapToInsertQuery(obj: Category) = InsertQuery.builder()
.table(TABLE)
.build()
override fun mapToUpdateQuery(obj: Category) = UpdateQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Category) = ContentValues(4).apply {
put(COL_ID, obj.id)
put(COL_NAME, obj.name)
put(COL_ORDER, obj.order)
put(COL_FLAGS, obj.flags)
}
}
class CategoryGetResolver : DefaultGetResolver<Category>() {
override fun mapFromCursor(cursor: Cursor): Category = CategoryImpl().apply {
id = cursor.getInt(cursor.getColumnIndex(COL_ID))
name = cursor.getString(cursor.getColumnIndex(COL_NAME))
order = cursor.getInt(cursor.getColumnIndex(COL_ORDER))
flags = cursor.getInt(cursor.getColumnIndex(COL_FLAGS))
}
}
class CategoryDeleteResolver : DefaultDeleteResolver<Category>() {
override fun mapToDeleteQuery(obj: Category) = DeleteQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
}

View File

@ -0,0 +1,85 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_BOOKMARK
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_CHAPTER_NUMBER
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_FETCH
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_UPLOAD
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_LAST_PAGE_READ
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_MANGA_ID
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_NAME
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_READ
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_SOURCE_ORDER
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_URL
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.TABLE
class ChapterTypeMapping : SQLiteTypeMapping<Chapter>(
ChapterPutResolver(),
ChapterGetResolver(),
ChapterDeleteResolver()
)
class ChapterPutResolver : DefaultPutResolver<Chapter>() {
override fun mapToInsertQuery(obj: Chapter) = InsertQuery.builder()
.table(TABLE)
.build()
override fun mapToUpdateQuery(obj: Chapter) = UpdateQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Chapter) = ContentValues(11).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_URL, obj.url)
put(COL_NAME, obj.name)
put(COL_READ, obj.read)
put(COL_BOOKMARK, obj.bookmark)
put(COL_DATE_FETCH, obj.date_fetch)
put(COL_DATE_UPLOAD, obj.date_upload)
put(COL_LAST_PAGE_READ, obj.last_page_read)
put(COL_CHAPTER_NUMBER, obj.chapter_number)
put(COL_SOURCE_ORDER, obj.source_order)
}
}
class ChapterGetResolver : DefaultGetResolver<Chapter>() {
override fun mapFromCursor(cursor: Cursor): Chapter = ChapterImpl().apply {
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
url = cursor.getString(cursor.getColumnIndex(COL_URL))
name = cursor.getString(cursor.getColumnIndex(COL_NAME))
read = cursor.getInt(cursor.getColumnIndex(COL_READ)) == 1
bookmark = cursor.getInt(cursor.getColumnIndex(COL_BOOKMARK)) == 1
date_fetch = cursor.getLong(cursor.getColumnIndex(COL_DATE_FETCH))
date_upload = cursor.getLong(cursor.getColumnIndex(COL_DATE_UPLOAD))
last_page_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_PAGE_READ))
chapter_number = cursor.getFloat(cursor.getColumnIndex(COL_CHAPTER_NUMBER))
source_order = cursor.getInt(cursor.getColumnIndex(COL_SOURCE_ORDER))
}
}
class ChapterDeleteResolver : DefaultDeleteResolver<Chapter>() {
override fun mapToDeleteQuery(obj: Chapter) = DeleteQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
}

View File

@ -0,0 +1,62 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_CHAPTER_ID
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_LAST_READ
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_TIME_READ
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.TABLE
class HistoryTypeMapping : SQLiteTypeMapping<History>(
HistoryPutResolver(),
HistoryGetResolver(),
HistoryDeleteResolver()
)
open class HistoryPutResolver : DefaultPutResolver<History>() {
override fun mapToInsertQuery(obj: History) = InsertQuery.builder()
.table(TABLE)
.build()
override fun mapToUpdateQuery(obj: History) = UpdateQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: History) = ContentValues(4).apply {
put(COL_ID, obj.id)
put(COL_CHAPTER_ID, obj.chapter_id)
put(COL_LAST_READ, obj.last_read)
put(COL_TIME_READ, obj.time_read)
}
}
class HistoryGetResolver : DefaultGetResolver<History>() {
override fun mapFromCursor(cursor: Cursor): History = History().apply {
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
chapter_id = cursor.getLong(cursor.getColumnIndex(COL_CHAPTER_ID))
last_read = cursor.getLong(cursor.getColumnIndex(COL_LAST_READ))
time_read = cursor.getLong(cursor.getColumnIndex(COL_TIME_READ))
}
}
class HistoryDeleteResolver : DefaultDeleteResolver<History>() {
override fun mapToDeleteQuery(obj: History) = DeleteQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
}

View File

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable.COL_CATEGORY_ID
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable.COL_MANGA_ID
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable.TABLE
class MangaCategoryTypeMapping : SQLiteTypeMapping<MangaCategory>(
MangaCategoryPutResolver(),
MangaCategoryGetResolver(),
MangaCategoryDeleteResolver()
)
class MangaCategoryPutResolver : DefaultPutResolver<MangaCategory>() {
override fun mapToInsertQuery(obj: MangaCategory) = InsertQuery.builder()
.table(TABLE)
.build()
override fun mapToUpdateQuery(obj: MangaCategory) = UpdateQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: MangaCategory) = ContentValues(3).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_CATEGORY_ID, obj.category_id)
}
}
class MangaCategoryGetResolver : DefaultGetResolver<MangaCategory>() {
override fun mapFromCursor(cursor: Cursor): MangaCategory = MangaCategory().apply {
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
category_id = cursor.getInt(cursor.getColumnIndex(COL_CATEGORY_ID))
}
}
class MangaCategoryDeleteResolver : DefaultDeleteResolver<MangaCategory>() {
override fun mapToDeleteQuery(obj: MangaCategory) = DeleteQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
}

View File

@ -0,0 +1,78 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_LAST_CHAPTER_READ
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_MANGA_ID
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_REMOTE_ID
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SCORE
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_STATUS
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SYNC_ID
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TITLE
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TOTAL_CHAPTERS
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.TABLE
class MangaSyncTypeMapping : SQLiteTypeMapping<MangaSync>(
MangaSyncPutResolver(),
MangaSyncGetResolver(),
MangaSyncDeleteResolver()
)
class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() {
override fun mapToInsertQuery(obj: MangaSync) = InsertQuery.builder()
.table(TABLE)
.build()
override fun mapToUpdateQuery(obj: MangaSync) = UpdateQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: MangaSync) = ContentValues(9).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_SYNC_ID, obj.sync_id)
put(COL_REMOTE_ID, obj.remote_id)
put(COL_TITLE, obj.title)
put(COL_LAST_CHAPTER_READ, obj.last_chapter_read)
put(COL_TOTAL_CHAPTERS, obj.total_chapters)
put(COL_STATUS, obj.status)
put(COL_SCORE, obj.score)
}
}
class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() {
override fun mapFromCursor(cursor: Cursor): MangaSync = MangaSyncImpl().apply {
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID))
remote_id = cursor.getInt(cursor.getColumnIndex(COL_REMOTE_ID))
title = cursor.getString(cursor.getColumnIndex(COL_TITLE))
last_chapter_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_CHAPTER_READ))
total_chapters = cursor.getInt(cursor.getColumnIndex(COL_TOTAL_CHAPTERS))
status = cursor.getInt(cursor.getColumnIndex(COL_STATUS))
score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE))
}
}
class MangaSyncDeleteResolver : DefaultDeleteResolver<MangaSync>() {
override fun mapToDeleteQuery(obj: MangaSync) = DeleteQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
}

View File

@ -0,0 +1,96 @@
package eu.kanade.tachiyomi.data.database.mappers
import android.content.ContentValues
import android.database.Cursor
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_ARTIST
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_AUTHOR
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_CHAPTER_FLAGS
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_DESCRIPTION
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_FAVORITE
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_GENRE
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_INITIALIZED
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_LAST_UPDATE
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_SOURCE
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_STATUS
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_THUMBNAIL_URL
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_TITLE
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_URL
import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_VIEWER
import eu.kanade.tachiyomi.data.database.tables.MangaTable.TABLE
class MangaTypeMapping : SQLiteTypeMapping<Manga>(
MangaPutResolver(),
MangaGetResolver(),
MangaDeleteResolver()
)
class MangaPutResolver : DefaultPutResolver<Manga>() {
override fun mapToInsertQuery(obj: Manga) = InsertQuery.builder()
.table(TABLE)
.build()
override fun mapToUpdateQuery(obj: Manga) = UpdateQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Manga) = ContentValues(15).apply {
put(COL_ID, obj.id)
put(COL_SOURCE, obj.source)
put(COL_URL, obj.url)
put(COL_ARTIST, obj.artist)
put(COL_AUTHOR, obj.author)
put(COL_DESCRIPTION, obj.description)
put(COL_GENRE, obj.genre)
put(COL_TITLE, obj.title)
put(COL_STATUS, obj.status)
put(COL_THUMBNAIL_URL, obj.thumbnail_url)
put(COL_FAVORITE, obj.favorite)
put(COL_LAST_UPDATE, obj.last_update)
put(COL_INITIALIZED, obj.initialized)
put(COL_VIEWER, obj.viewer)
put(COL_CHAPTER_FLAGS, obj.chapter_flags)
}
}
open class MangaGetResolver : DefaultGetResolver<Manga>() {
override fun mapFromCursor(cursor: Cursor): Manga = MangaImpl().apply {
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
source = cursor.getInt(cursor.getColumnIndex(COL_SOURCE))
url = cursor.getString(cursor.getColumnIndex(COL_URL))
artist = cursor.getString(cursor.getColumnIndex(COL_ARTIST))
author = cursor.getString(cursor.getColumnIndex(COL_AUTHOR))
description = cursor.getString(cursor.getColumnIndex(COL_DESCRIPTION))
genre = cursor.getString(cursor.getColumnIndex(COL_GENRE))
title = cursor.getString(cursor.getColumnIndex(COL_TITLE))
status = cursor.getInt(cursor.getColumnIndex(COL_STATUS))
thumbnail_url = cursor.getString(cursor.getColumnIndex(COL_THUMBNAIL_URL))
favorite = cursor.getInt(cursor.getColumnIndex(COL_FAVORITE)) == 1
last_update = cursor.getLong(cursor.getColumnIndex(COL_LAST_UPDATE))
initialized = cursor.getInt(cursor.getColumnIndex(COL_INITIALIZED)) == 1
viewer = cursor.getInt(cursor.getColumnIndex(COL_VIEWER))
chapter_flags = cursor.getInt(cursor.getColumnIndex(COL_CHAPTER_FLAGS))
}
}
class MangaDeleteResolver : DefaultDeleteResolver<Manga>() {
override fun mapToDeleteQuery(obj: Manga) = DeleteQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
}

View File

@ -1,57 +0,0 @@
package eu.kanade.tachiyomi.data.database.models;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteColumn;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteType;
import java.io.Serializable;
import eu.kanade.tachiyomi.data.database.tables.CategoryTable;
@StorIOSQLiteType(table = CategoryTable.TABLE)
public class Category implements Serializable {
@StorIOSQLiteColumn(name = CategoryTable.COL_ID, key = true)
public Integer id;
@StorIOSQLiteColumn(name = CategoryTable.COL_NAME)
public String name;
@StorIOSQLiteColumn(name = CategoryTable.COL_ORDER)
public int order;
@StorIOSQLiteColumn(name = CategoryTable.COL_FLAGS)
public int flags;
public Category() {}
public static Category create(String name) {
Category c = new Category();
c.name = name;
return c;
}
public static Category createDefault() {
Category c = create("Default");
c.id = 0;
return c;
}
public String getNameLower() {
return name.toLowerCase();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Category category = (Category) o;
return name.equals(category.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
}

View File

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
interface Category : Serializable {
var id: Int?
var name: String
var order: Int
var flags: Int
val nameLower: String
get() = name.toLowerCase()
companion object {
fun create(name: String): Category = CategoryImpl().apply {
this.name = name
}
fun createDefault(): Category = create("Default").apply { id = 0 }
}
}

View File

@ -0,0 +1,26 @@
package eu.kanade.tachiyomi.data.database.models
class CategoryImpl : Category {
override var id: Int? = null
override lateinit var name: String
override var order: Int = 0
override var flags: Int = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val category = other as Category
return name == category.name
}
override fun hashCode(): Int {
return name.hashCode()
}
}

View File

@ -1,94 +0,0 @@
package eu.kanade.tachiyomi.data.database.models;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteColumn;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteType;
import java.io.Serializable;
import java.util.List;
import eu.kanade.tachiyomi.data.database.tables.ChapterTable;
import eu.kanade.tachiyomi.data.download.model.Download;
import eu.kanade.tachiyomi.data.source.model.Page;
import eu.kanade.tachiyomi.util.UrlUtil;
@StorIOSQLiteType(table = ChapterTable.TABLE)
public class Chapter implements Serializable {
@StorIOSQLiteColumn(name = ChapterTable.COL_ID, key = true)
public Long id;
@StorIOSQLiteColumn(name = ChapterTable.COL_MANGA_ID)
public Long manga_id;
@StorIOSQLiteColumn(name = ChapterTable.COL_URL)
public String url;
@StorIOSQLiteColumn(name = ChapterTable.COL_NAME)
public String name;
@StorIOSQLiteColumn(name = ChapterTable.COL_READ)
public boolean read;
@StorIOSQLiteColumn(name = ChapterTable.COL_LAST_PAGE_READ)
public int last_page_read;
@StorIOSQLiteColumn(name = ChapterTable.COL_DATE_FETCH)
public long date_fetch;
@StorIOSQLiteColumn(name = ChapterTable.COL_DATE_UPLOAD)
public long date_upload;
@StorIOSQLiteColumn(name = ChapterTable.COL_CHAPTER_NUMBER)
public float chapter_number;
@StorIOSQLiteColumn(name = ChapterTable.COL_SOURCE_ORDER)
public int source_order;
public int status;
private transient List<Page> pages;
public Chapter() {}
public void setUrl(String url) {
this.url = UrlUtil.getPath(url);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Chapter chapter = (Chapter) o;
return url.equals(chapter.url);
}
@Override
public int hashCode() {
return url.hashCode();
}
public static Chapter create() {
Chapter chapter = new Chapter();
chapter.chapter_number = -1;
return chapter;
}
public List<Page> getPages() {
return pages;
}
public void setPages(List<Page> pages) {
this.pages = pages;
}
public boolean isDownloaded() {
return status == Download.DOWNLOADED;
}
public boolean isRecognizedNumber() {
return chapter_number >= 0f;
}
}

View File

@ -0,0 +1,38 @@
package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
interface Chapter : Serializable {
var id: Long?
var manga_id: Long?
var url: String
var name: String
var read: Boolean
var bookmark: Boolean
var last_page_read: Int
var date_fetch: Long
var date_upload: Long
var chapter_number: Float
var source_order: Int
val isRecognizedNumber: Boolean
get() = chapter_number >= 0f
companion object {
fun create(): Chapter = ChapterImpl().apply {
chapter_number = -1f
}
}
}

View File

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.data.database.models
class ChapterImpl : Chapter {
override var id: Long? = null
override var manga_id: Long? = null
override lateinit var url: String
override lateinit var name: String
override var read: Boolean = false
override var bookmark: Boolean = false
override var last_page_read: Int = 0
override var date_fetch: Long = 0
override var date_upload: Long = 0
override var chapter_number: Float = 0f
override var source_order: Int = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val chapter = other as Chapter
return url == chapter.url
}
override fun hashCode(): Int {
return url.hashCode()
}
}

View File

@ -0,0 +1,44 @@
package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
/**
* Object containing the history statistics of a chapter
*/
class History : Serializable {
/**
* Id of history object.
*/
var id: Long? = null
/**
* Chapter id of history object.
*/
var chapter_id: Long = 0
/**
* Last time chapter was read in time long format
*/
var last_read: Long = 0
/**
* Total time chapter was read - todo not yet implemented
*/
var time_read: Long = 0
companion object {
/**
* History constructor
*
* @param chapter chapter object
* @return history object
*/
fun create(chapter: Chapter): History {
val history = History()
history.chapter_id = chapter.id!!
return history
}
}
}

View File

@ -1,213 +0,0 @@
package eu.kanade.tachiyomi.data.database.models;
import android.content.Context;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteColumn;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteType;
import java.io.Serializable;
import eu.kanade.tachiyomi.R;
import eu.kanade.tachiyomi.data.database.tables.MangaTable;
import eu.kanade.tachiyomi.util.UrlUtil;
@StorIOSQLiteType(table = MangaTable.TABLE)
public class Manga implements Serializable {
@StorIOSQLiteColumn(name = MangaTable.COL_ID, key = true)
public Long id;
@StorIOSQLiteColumn(name = MangaTable.COL_SOURCE)
public int source;
@StorIOSQLiteColumn(name = MangaTable.COL_URL)
public String url;
@StorIOSQLiteColumn(name = MangaTable.COL_ARTIST)
public String artist;
@StorIOSQLiteColumn(name = MangaTable.COL_AUTHOR)
public String author;
@StorIOSQLiteColumn(name = MangaTable.COL_DESCRIPTION)
public String description;
@StorIOSQLiteColumn(name = MangaTable.COL_GENRE)
public String genre;
@StorIOSQLiteColumn(name = MangaTable.COL_TITLE)
public String title;
@StorIOSQLiteColumn(name = MangaTable.COL_STATUS)
public int status;
@StorIOSQLiteColumn(name = MangaTable.COL_THUMBNAIL_URL)
public String thumbnail_url;
@StorIOSQLiteColumn(name = MangaTable.COL_FAVORITE)
public boolean favorite;
@StorIOSQLiteColumn(name = MangaTable.COL_LAST_UPDATE)
public long last_update;
@StorIOSQLiteColumn(name = MangaTable.COL_INITIALIZED)
public boolean initialized;
@StorIOSQLiteColumn(name = MangaTable.COL_VIEWER)
public int viewer;
@StorIOSQLiteColumn(name = MangaTable.COL_CHAPTER_FLAGS)
public int chapter_flags;
public transient int unread;
public transient int category;
public static final int UNKNOWN = 0;
public static final int ONGOING = 1;
public static final int COMPLETED = 2;
public static final int LICENSED = 3;
public static final int SORT_DESC = 0x00000000;
public static final int SORT_ASC = 0x00000001;
public static final int SORT_MASK = 0x00000001;
// Generic filter that does not filter anything
public static final int SHOW_ALL = 0x00000000;
public static final int SHOW_UNREAD = 0x00000002;
public static final int SHOW_READ = 0x00000004;
public static final int READ_MASK = 0x00000006;
public static final int SHOW_DOWNLOADED = 0x00000008;
public static final int SHOW_NOT_DOWNLOADED = 0x00000010;
public static final int DOWNLOADED_MASK = 0x00000018;
public static final int SORTING_SOURCE = 0x00000000;
public static final int SORTING_NUMBER = 0x00000100;
public static final int SORTING_MASK = 0x00000100;
public static final int DISPLAY_NAME = 0x00000000;
public static final int DISPLAY_NUMBER = 0x00100000;
public static final int DISPLAY_MASK = 0x00100000;
public Manga() {}
public static Manga create(String pathUrl) {
Manga m = new Manga();
m.url = pathUrl;
return m;
}
public static Manga create(String pathUrl, int source) {
Manga m = new Manga();
m.url = pathUrl;
m.source = source;
return m;
}
public void setUrl(String url) {
this.url = UrlUtil.getPath(url);
}
public void copyFrom(Manga other) {
if (other.title != null)
title = other.title;
if (other.author != null)
author = other.author;
if (other.artist != null)
artist = other.artist;
if (other.url != null)
url = other.url;
if (other.description != null)
description = other.description;
if (other.genre != null)
genre = other.genre;
if (other.thumbnail_url != null)
thumbnail_url = other.thumbnail_url;
status = other.status;
initialized = true;
}
public String getStatus(Context context) {
switch (status) {
case ONGOING:
return context.getString(R.string.ongoing);
case COMPLETED:
return context.getString(R.string.completed);
case LICENSED:
return context.getString(R.string.licensed);
default:
return context.getString(R.string.unknown);
}
}
public void setChapterOrder(int order) {
setFlags(order, SORT_MASK);
}
public void setDisplayMode(int mode) {
setFlags(mode, DISPLAY_MASK);
}
public void setReadFilter(int filter) {
setFlags(filter, READ_MASK);
}
public void setDownloadedFilter(int filter) {
setFlags(filter, DOWNLOADED_MASK);
}
public void setSorting(int sort) {
setFlags(sort, SORTING_MASK);
}
private void setFlags(int flag, int mask) {
chapter_flags = (chapter_flags & ~mask) | (flag & mask);
}
public boolean sortDescending() {
return (chapter_flags & SORT_MASK) == SORT_DESC;
}
// Used to display the chapter's title one way or another
public int getDisplayMode() {
return chapter_flags & DISPLAY_MASK;
}
public int getReadFilter() {
return chapter_flags & READ_MASK;
}
public int getDownloadedFilter() {
return chapter_flags & DOWNLOADED_MASK;
}
public int getSorting() {
return chapter_flags & SORTING_MASK;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Manga manga = (Manga) o;
return url.equals(manga.url);
}
@Override
public int hashCode() {
return url.hashCode();
}
}

View File

@ -0,0 +1,139 @@
package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
interface Manga : Serializable {
var id: Long?
var source: Int
var url: String
var title: String
var artist: String?
var author: String?
var description: String?
var genre: String?
var status: Int
var thumbnail_url: String?
var favorite: Boolean
var last_update: Long
var initialized: Boolean
var viewer: Int
var chapter_flags: Int
var unread: Int
var category: Int
fun copyFrom(other: Manga) {
if (other.author != null)
author = other.author
if (other.artist != null)
artist = other.artist
if (other.description != null)
description = other.description
if (other.genre != null)
genre = other.genre
if (other.thumbnail_url != null)
thumbnail_url = other.thumbnail_url
status = other.status
initialized = true
}
fun setChapterOrder(order: Int) {
setFlags(order, SORT_MASK)
}
private fun setFlags(flag: Int, mask: Int) {
chapter_flags = chapter_flags and mask.inv() or (flag and mask)
}
fun sortDescending(): Boolean {
return chapter_flags and SORT_MASK == SORT_DESC
}
// Used to display the chapter's title one way or another
var displayMode: Int
get() = chapter_flags and DISPLAY_MASK
set(mode) = setFlags(mode, DISPLAY_MASK)
var readFilter: Int
get() = chapter_flags and READ_MASK
set(filter) = setFlags(filter, READ_MASK)
var downloadedFilter: Int
get() = chapter_flags and DOWNLOADED_MASK
set(filter) = setFlags(filter, DOWNLOADED_MASK)
var bookmarkedFilter: Int
get() = chapter_flags and BOOKMARKED_MASK
set(filter) = setFlags(filter, BOOKMARKED_MASK)
var sorting: Int
get() = chapter_flags and SORTING_MASK
set(sort) = setFlags(sort, SORTING_MASK)
companion object {
const val UNKNOWN = 0
const val ONGOING = 1
const val COMPLETED = 2
const val LICENSED = 3
const val SORT_DESC = 0x00000000
const val SORT_ASC = 0x00000001
const val SORT_MASK = 0x00000001
// Generic filter that does not filter anything
const val SHOW_ALL = 0x00000000
const val SHOW_UNREAD = 0x00000002
const val SHOW_READ = 0x00000004
const val READ_MASK = 0x00000006
const val SHOW_DOWNLOADED = 0x00000008
const val SHOW_NOT_DOWNLOADED = 0x00000010
const val DOWNLOADED_MASK = 0x00000018
const val SHOW_BOOKMARKED = 0x00000020
const val SHOW_NOT_BOOKMARKED = 0x00000040
const val BOOKMARKED_MASK = 0x00000060
const val SORTING_SOURCE = 0x00000000
const val SORTING_NUMBER = 0x00000100
const val SORTING_MASK = 0x00000100
const val DISPLAY_NAME = 0x00000000
const val DISPLAY_NUMBER = 0x00100000
const val DISPLAY_MASK = 0x00100000
fun create(source: Int): Manga = MangaImpl().apply {
this.source = source
}
fun create(pathUrl: String, source: Int = 0): Manga = MangaImpl().apply {
url = pathUrl
this.source = source
}
}
}

View File

@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.data.database.models;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteColumn;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteType;
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable;
@StorIOSQLiteType(table = MangaCategoryTable.TABLE)
public class MangaCategory {
@StorIOSQLiteColumn(name = MangaCategoryTable.COL_ID, key = true)
public Long id;
@StorIOSQLiteColumn(name = MangaCategoryTable.COL_MANGA_ID)
public long manga_id;
@StorIOSQLiteColumn(name = MangaCategoryTable.COL_CATEGORY_ID)
public int category_id;
public MangaCategory() {}
public static MangaCategory create(Manga manga, Category category) {
MangaCategory mc = new MangaCategory();
mc.manga_id = manga.id;
mc.category_id = category.id;
return mc;
}
}

View File

@ -0,0 +1,21 @@
package eu.kanade.tachiyomi.data.database.models
class MangaCategory {
var id: Long? = null
var manga_id: Long = 0
var category_id: Int = 0
companion object {
fun create(manga: Manga, category: Category): MangaCategory {
val mc = MangaCategory()
mc.manga_id = manga.id!!
mc.category_id = category.id!!
return mc
}
}
}

View File

@ -1,12 +0,0 @@
package eu.kanade.tachiyomi.data.database.models;
public class MangaChapter {
public Manga manga;
public Chapter chapter;
public MangaChapter(Manga manga, Chapter chapter) {
this.manga = manga;
this.chapter = chapter;
}
}

View File

@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.data.database.models
class MangaChapter(val manga: Manga, val chapter: Chapter)

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.database.models
/**
* Object containing manga, chapter and history
*
* @param manga object containing manga
* @param chapter object containing chater
* @param history object containing history
*/
class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History)

View File

@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.data.database.models
class MangaImpl : Manga {
override var id: Long? = null
override var source: Int = 0
override lateinit var url: String
override lateinit var title: String
override var artist: String? = null
override var author: String? = null
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var favorite: Boolean = false
override var last_update: Long = 0
override var initialized: Boolean = false
override var viewer: Int = 0
override var chapter_flags: Int = 0
@Transient override var unread: Int = 0
@Transient override var category: Int = 0
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val manga = other as Manga
return url == manga.url
}
override fun hashCode(): Int {
return url.hashCode()
}
}

View File

@ -1,78 +0,0 @@
package eu.kanade.tachiyomi.data.database.models;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteColumn;
import com.pushtorefresh.storio.sqlite.annotations.StorIOSQLiteType;
import java.io.Serializable;
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable;
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService;
@StorIOSQLiteType(table = MangaSyncTable.TABLE)
public class MangaSync implements Serializable {
@StorIOSQLiteColumn(name = MangaSyncTable.COL_ID, key = true)
public Long id;
@StorIOSQLiteColumn(name = MangaSyncTable.COL_MANGA_ID)
public long manga_id;
@StorIOSQLiteColumn(name = MangaSyncTable.COL_SYNC_ID)
public int sync_id;
@StorIOSQLiteColumn(name = MangaSyncTable.COL_REMOTE_ID)
public int remote_id;
@StorIOSQLiteColumn(name = MangaSyncTable.COL_TITLE)
public String title;
@StorIOSQLiteColumn(name = MangaSyncTable.COL_LAST_CHAPTER_READ)
public int last_chapter_read;
@StorIOSQLiteColumn(name = MangaSyncTable.COL_TOTAL_CHAPTERS)
public int total_chapters;
@StorIOSQLiteColumn(name = MangaSyncTable.COL_SCORE)
public float score;
@StorIOSQLiteColumn(name = MangaSyncTable.COL_STATUS)
public int status;
public boolean update;
public static MangaSync create() {
return new MangaSync();
}
public static MangaSync create(MangaSyncService service) {
MangaSync mangasync = new MangaSync();
mangasync.sync_id = service.getId();
return mangasync;
}
public void copyPersonalFrom(MangaSync other) {
last_chapter_read = other.last_chapter_read;
score = other.score;
status = other.status;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MangaSync mangaSync = (MangaSync) o;
if (manga_id != mangaSync.manga_id) return false;
if (sync_id != mangaSync.sync_id) return false;
return remote_id == mangaSync.remote_id;
}
@Override
public int hashCode() {
int result = (int) (manga_id ^ (manga_id >>> 32));
result = 31 * result + sync_id;
result = 31 * result + remote_id;
return result;
}
}

View File

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
interface MangaSync : Serializable {
var id: Long?
var manga_id: Long
var sync_id: Int
var remote_id: Int
var title: String
var last_chapter_read: Int
var total_chapters: Int
var score: Float
var status: Int
var update: Boolean
fun copyPersonalFrom(other: MangaSync) {
last_chapter_read = other.last_chapter_read
score = other.score
status = other.status
}
companion object {
fun create(serviceId: Int): MangaSync = MangaSyncImpl().apply {
sync_id = serviceId
}
}
}

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.data.database.models
class MangaSyncImpl : MangaSync {
override var id: Long? = null
override var manga_id: Long = 0
override var sync_id: Int = 0
override var remote_id: Int = 0
override lateinit var title: String
override var last_chapter_read: Int = 0
override var total_chapters: Int = 0
override var score: Float = 0f
override var status: Int = 0
override var update: Boolean = false
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val mangaSync = other as MangaSync
if (manga_id != mangaSync.manga_id) return false
if (sync_id != mangaSync.sync_id) return false
return remote_id == mangaSync.remote_id
}
override fun hashCode(): Int {
var result = (manga_id xor manga_id.ushr(32)).toInt()
result = 31 * result + sync_id
result = 31 * result + remote_id
return result
}
}

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.database.queries package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.operations.get.PreparedGetObject
import com.pushtorefresh.storio.sqlite.queries.Query import com.pushtorefresh.storio.sqlite.queries.Query
import com.pushtorefresh.storio.sqlite.queries.RawQuery import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.DbProvider
@ -34,77 +33,12 @@ interface ChapterQueries : DbProvider {
.withGetResolver(MangaChapterGetResolver.INSTANCE) .withGetResolver(MangaChapterGetResolver.INSTANCE)
.prepare() .prepare()
fun getNextChapter(chapter: Chapter): PreparedGetObject<Chapter> { fun getChapter(id: Long) = db.get()
// Add a delta to the chapter number, because binary decimal representation
// can retrieve the same chapter again
val chapterNumber = chapter.chapter_number + 0.00001
return db.get()
.`object`(Chapter::class.java)
.withQuery(Query.builder()
.table(ChapterTable.TABLE)
.where("${ChapterTable.COL_MANGA_ID} = ? AND " +
"${ChapterTable.COL_CHAPTER_NUMBER} > ? AND " +
"${ChapterTable.COL_CHAPTER_NUMBER} <= ?")
.whereArgs(chapter.manga_id, chapterNumber, chapterNumber + 1)
.orderBy(ChapterTable.COL_CHAPTER_NUMBER)
.limit(1)
.build())
.prepare()
}
fun getNextChapterBySource(chapter: Chapter) = db.get()
.`object`(Chapter::class.java) .`object`(Chapter::class.java)
.withQuery(Query.builder() .withQuery(Query.builder()
.table(ChapterTable.TABLE) .table(ChapterTable.TABLE)
.where("""${ChapterTable.COL_MANGA_ID} = ? AND .where("${ChapterTable.COL_ID} = ?")
${ChapterTable.COL_SOURCE_ORDER} < ?""") .whereArgs(id)
.whereArgs(chapter.manga_id, chapter.source_order)
.orderBy("${ChapterTable.COL_SOURCE_ORDER} DESC")
.limit(1)
.build())
.prepare()
fun getPreviousChapter(chapter: Chapter): PreparedGetObject<Chapter> {
// Add a delta to the chapter number, because binary decimal representation
// can retrieve the same chapter again
val chapterNumber = chapter.chapter_number - 0.00001
return db.get()
.`object`(Chapter::class.java)
.withQuery(Query.builder().table(ChapterTable.TABLE)
.where("${ChapterTable.COL_MANGA_ID} = ? AND " +
"${ChapterTable.COL_CHAPTER_NUMBER} < ? AND " +
"${ChapterTable.COL_CHAPTER_NUMBER} >= ?")
.whereArgs(chapter.manga_id, chapterNumber, chapterNumber - 1)
.orderBy("${ChapterTable.COL_CHAPTER_NUMBER} DESC")
.limit(1)
.build())
.prepare()
}
fun getPreviousChapterBySource(chapter: Chapter) = db.get()
.`object`(Chapter::class.java)
.withQuery(Query.builder()
.table(ChapterTable.TABLE)
.where("""${ChapterTable.COL_MANGA_ID} = ? AND
${ChapterTable.COL_SOURCE_ORDER} > ?""")
.whereArgs(chapter.manga_id, chapter.source_order)
.orderBy(ChapterTable.COL_SOURCE_ORDER)
.limit(1)
.build())
.prepare()
fun getNextUnreadChapter(manga: Manga) = db.get()
.`object`(Chapter::class.java)
.withQuery(Query.builder()
.table(ChapterTable.TABLE)
.where("${ChapterTable.COL_MANGA_ID} = ? AND " +
"${ChapterTable.COL_READ} = ? AND " +
"${ChapterTable.COL_CHAPTER_NUMBER} >= ?")
.whereArgs(manga.id, 0, 0)
.orderBy(ChapterTable.COL_CHAPTER_NUMBER)
.limit(1)
.build()) .build())
.prepare() .prepare()

View File

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
import eu.kanade.tachiyomi.data.database.resolvers.HistoryLastReadPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver
import eu.kanade.tachiyomi.data.database.tables.HistoryTable
import java.util.*
interface HistoryQueries : DbProvider {
/**
* Insert history into database
* @param history object containing history information
*/
fun insertHistory(history: History) = db.put().`object`(history).prepare()
/**
* Returns history of recent manga containing last read chapter
* @param date recent date range
*/
fun getRecentManga(date: Date) = db.get()
.listOfObjects(MangaChapterHistory::class.java)
.withQuery(RawQuery.builder()
.query(getRecentMangasQuery())
.args(date.time)
.observesTables(HistoryTable.TABLE)
.build())
.withGetResolver(MangaChapterHistoryGetResolver.INSTANCE)
.prepare()
fun getHistoryByMangaId(mangaId: Long) = db.get()
.listOfObjects(History::class.java)
.withQuery(RawQuery.builder()
.query(getHistoryByMangaId())
.args(mangaId)
.observesTables(HistoryTable.TABLE)
.build())
.prepare()
/**
* Updates the history last read.
* Inserts history object if not yet in database
* @param history history object
*/
fun updateHistoryLastRead(history: History) = db.put()
.`object`(history)
.withPutResolver(HistoryLastReadPutResolver())
.prepare()
/**
* Updates the history last read.
* Inserts history object if not yet in database
* @param historyList history object list
*/
fun updateHistoryLastRead(historyList: List<History>) = db.put()
.objects(historyList)
.withPutResolver(HistoryLastReadPutResolver())
.prepare()
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.database.queries
import eu.kanade.tachiyomi.data.database.tables.CategoryTable as Category import eu.kanade.tachiyomi.data.database.tables.CategoryTable as Category
import eu.kanade.tachiyomi.data.database.tables.ChapterTable as Chapter import eu.kanade.tachiyomi.data.database.tables.ChapterTable as Chapter
import eu.kanade.tachiyomi.data.database.tables.HistoryTable as History
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable as MangaCategory import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable as MangaCategory
import eu.kanade.tachiyomi.data.database.tables.MangaTable as Manga import eu.kanade.tachiyomi.data.database.tables.MangaTable as Manga
@ -39,6 +40,38 @@ fun getRecentsQuery() = """
ORDER BY ${Chapter.COL_DATE_UPLOAD} DESC ORDER BY ${Chapter.COL_DATE_UPLOAD} DESC
""" """
/**
* Query to get the recently read chapters of manga from the library up to a date.
* The max_last_read table contains the most recent chapters grouped by manga
* The select statement returns all information of chapters that have the same id as the chapter in max_last_read
* and are read after the given time period
* @return return limit is 25
*/
fun getRecentMangasQuery() = """
SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, ${Manga.TABLE}.*, ${Chapter.TABLE}.*, ${History.TABLE}.*
FROM ${Manga.TABLE}
JOIN ${Chapter.TABLE}
ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}
JOIN ${History.TABLE}
ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID}
JOIN (
SELECT ${Chapter.TABLE}.${Chapter.COL_MANGA_ID},${Chapter.TABLE}.${Chapter.COL_ID} as ${History.COL_CHAPTER_ID}, MAX(${History.TABLE}.${History.COL_LAST_READ}) as ${History.COL_LAST_READ}
FROM ${Chapter.TABLE} JOIN ${History.TABLE}
ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID}
GROUP BY ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}) AS max_last_read
ON ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = max_last_read.${Chapter.COL_MANGA_ID}
WHERE ${History.TABLE}.${History.COL_LAST_READ} > ? AND max_last_read.${History.COL_CHAPTER_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID}
ORDER BY max_last_read.${History.COL_LAST_READ} DESC
LIMIT 25
"""
fun getHistoryByMangaId() = """
SELECT ${History.TABLE}.*
FROM ${History.TABLE}
JOIN ${Chapter.TABLE}
ON ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
"""
/** /**
* Query to get the categories for a manga. * Query to get the categories for a manga.

View File

@ -15,7 +15,7 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() {
val updateQuery = mapToUpdateQuery(chapter) val updateQuery = mapToUpdateQuery(chapter)
val contentValues = mapToContentValues(chapter) val contentValues = mapToContentValues(chapter)
val numberOfRowsUpdated = db.internal().update(updateQuery, contentValues) val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
} }
@ -25,8 +25,9 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() {
.whereArgs(chapter.id) .whereArgs(chapter.id)
.build() .build()
fun mapToContentValues(chapter: Chapter) = ContentValues(2).apply { fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
put(ChapterTable.COL_READ, chapter.read) put(ChapterTable.COL_READ, chapter.read)
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read) put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
} }

View File

@ -15,7 +15,7 @@ class ChapterSourceOrderPutResolver : PutResolver<Chapter>() {
val updateQuery = mapToUpdateQuery(chapter) val updateQuery = mapToUpdateQuery(chapter)
val contentValues = mapToContentValues(chapter) val contentValues = mapToContentValues(chapter)
val numberOfRowsUpdated = db.internal().update(updateQuery, contentValues) val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
} }

View File

@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import android.support.annotation.NonNull
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
import com.pushtorefresh.storio.sqlite.queries.Query
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.inTransactionReturn
import eu.kanade.tachiyomi.data.database.mappers.HistoryPutResolver
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.tables.HistoryTable
class HistoryLastReadPutResolver : HistoryPutResolver() {
/**
* Updates last_read time of chapter
*/
override fun performPut(@NonNull db: StorIOSQLite, @NonNull history: History): PutResult = db.inTransactionReturn {
val updateQuery = mapToUpdateQuery(history)
val cursor = db.lowLevel().query(Query.builder()
.table(updateQuery.table())
.where(updateQuery.where())
.whereArgs(updateQuery.whereArgs())
.build())
val putResult: PutResult
try {
if (cursor.count == 0) {
val insertQuery = mapToInsertQuery(history)
val insertedId = db.lowLevel().insert(insertQuery, mapToContentValues(history))
putResult = PutResult.newInsertResult(insertedId, insertQuery.table())
} else {
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, mapToUpdateContentValues(history))
putResult = PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
}
} finally {
cursor.close()
}
putResult
}
/**
* Creates update query
* @param obj history object
*/
override fun mapToUpdateQuery(obj: History) = UpdateQuery.builder()
.table(HistoryTable.TABLE)
.where("${HistoryTable.COL_CHAPTER_ID} = ?")
.whereArgs(obj.chapter_id)
.build()
/**
* Create content query
* @param history object
*/
fun mapToUpdateContentValues(history: History) = ContentValues(1).apply {
put(HistoryTable.COL_LAST_READ, history.last_read)
}
}

View File

@ -1,12 +1,11 @@
package eu.kanade.tachiyomi.data.database.resolvers package eu.kanade.tachiyomi.data.database.resolvers
import android.database.Cursor import android.database.Cursor
import eu.kanade.tachiyomi.data.database.mappers.MangaGetResolver
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaStorIOSQLiteGetResolver
import eu.kanade.tachiyomi.data.database.tables.MangaTable import eu.kanade.tachiyomi.data.database.tables.MangaTable
class LibraryMangaGetResolver : MangaStorIOSQLiteGetResolver() { class LibraryMangaGetResolver : MangaGetResolver() {
companion object { companion object {
val INSTANCE = LibraryMangaGetResolver() val INSTANCE = LibraryMangaGetResolver()

View File

@ -2,9 +2,9 @@ package eu.kanade.tachiyomi.data.database.resolvers
import android.database.Cursor import android.database.Cursor
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import eu.kanade.tachiyomi.data.database.models.ChapterStorIOSQLiteGetResolver import eu.kanade.tachiyomi.data.database.mappers.ChapterGetResolver
import eu.kanade.tachiyomi.data.database.mappers.MangaGetResolver
import eu.kanade.tachiyomi.data.database.models.MangaChapter import eu.kanade.tachiyomi.data.database.models.MangaChapter
import eu.kanade.tachiyomi.data.database.models.MangaStorIOSQLiteGetResolver
class MangaChapterGetResolver : DefaultGetResolver<MangaChapter>() { class MangaChapterGetResolver : DefaultGetResolver<MangaChapter>() {
@ -12,15 +12,15 @@ class MangaChapterGetResolver : DefaultGetResolver<MangaChapter>() {
val INSTANCE = MangaChapterGetResolver() val INSTANCE = MangaChapterGetResolver()
} }
private val mangaGetResolver = MangaStorIOSQLiteGetResolver() private val mangaGetResolver = MangaGetResolver()
private val chapterGetResolver = ChapterStorIOSQLiteGetResolver() private val chapterGetResolver = ChapterGetResolver()
override fun mapFromCursor(cursor: Cursor): MangaChapter { override fun mapFromCursor(cursor: Cursor): MangaChapter {
val manga = mangaGetResolver.mapFromCursor(cursor) val manga = mangaGetResolver.mapFromCursor(cursor)
val chapter = chapterGetResolver.mapFromCursor(cursor) val chapter = chapterGetResolver.mapFromCursor(cursor)
manga.id = chapter.manga_id manga.id = chapter.manga_id
manga.url = cursor.getString(cursor.getColumnIndex("mangaUrl")); manga.url = cursor.getString(cursor.getColumnIndex("mangaUrl"))
return MangaChapter(manga, chapter) return MangaChapter(manga, chapter)
} }

View File

@ -0,0 +1,51 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.database.Cursor
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
import eu.kanade.tachiyomi.data.database.mappers.ChapterGetResolver
import eu.kanade.tachiyomi.data.database.mappers.HistoryGetResolver
import eu.kanade.tachiyomi.data.database.mappers.MangaGetResolver
import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
class MangaChapterHistoryGetResolver : DefaultGetResolver<MangaChapterHistory>() {
companion object {
val INSTANCE = MangaChapterHistoryGetResolver()
}
/**
* Manga get resolver
*/
private val mangaGetResolver = MangaGetResolver()
/**
* Chapter get resolver
*/
private val chapterResolver = ChapterGetResolver()
/**
* History get resolver
*/
private val historyGetResolver = HistoryGetResolver()
/**
* Map correct objects from cursor result
*/
override fun mapFromCursor(cursor: Cursor): MangaChapterHistory {
// Get manga object
val manga = mangaGetResolver.mapFromCursor(cursor)
// Get chapter object
val chapter = chapterResolver.mapFromCursor(cursor)
// Get history object
val history = historyGetResolver.mapFromCursor(cursor)
// Make certain column conflicts are dealt with
manga.id = chapter.manga_id
manga.url = cursor.getString(cursor.getColumnIndex("mangaUrl"))
chapter.id = history.chapter_id
// Return result
return MangaChapterHistory(manga, chapter, history)
}
}

View File

@ -15,7 +15,7 @@ class MangaFlagsPutResolver : PutResolver<Manga>() {
val updateQuery = mapToUpdateQuery(manga) val updateQuery = mapToUpdateQuery(manga)
val contentValues = mapToContentValues(manga) val contentValues = mapToContentValues(manga)
val numberOfRowsUpdated = db.internal().update(updateQuery, contentValues) val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
} }

View File

@ -14,6 +14,8 @@ object ChapterTable {
const val COL_READ = "read" const val COL_READ = "read"
const val COL_BOOKMARK = "bookmark"
const val COL_DATE_FETCH = "date_fetch" const val COL_DATE_FETCH = "date_fetch"
const val COL_DATE_UPLOAD = "date_upload" const val COL_DATE_UPLOAD = "date_upload"
@ -31,6 +33,7 @@ object ChapterTable {
$COL_URL TEXT NOT NULL, $COL_URL TEXT NOT NULL,
$COL_NAME TEXT NOT NULL, $COL_NAME TEXT NOT NULL,
$COL_READ BOOLEAN NOT NULL, $COL_READ BOOLEAN NOT NULL,
$COL_BOOKMARK BOOLEAN NOT NULL,
$COL_LAST_PAGE_READ INT NOT NULL, $COL_LAST_PAGE_READ INT NOT NULL,
$COL_CHAPTER_NUMBER FLOAT NOT NULL, $COL_CHAPTER_NUMBER FLOAT NOT NULL,
$COL_SOURCE_ORDER INTEGER NOT NULL, $COL_SOURCE_ORDER INTEGER NOT NULL,
@ -46,4 +49,7 @@ object ChapterTable {
val sourceOrderUpdateQuery: String val sourceOrderUpdateQuery: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0" get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
val bookmarkUpdateQuery: String
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_BOOKMARK BOOLEAN DEFAULT FALSE"
} }

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.data.database.tables
object HistoryTable {
/**
* Table name
*/
const val TABLE = "history"
/**
* Id column name
*/
const val COL_ID = "${TABLE}_id"
/**
* Chapter id column name
*/
const val COL_CHAPTER_ID = "${TABLE}_chapter_id"
/**
* Last read column name
*/
const val COL_LAST_READ = "${TABLE}_last_read"
/**
* Time read column name
*/
const val COL_TIME_READ = "${TABLE}_time_read"
/**
* query to create history table
*/
val createTableQuery: String
get() = """CREATE TABLE $TABLE(
$COL_ID INTEGER NOT NULL PRIMARY KEY,
$COL_CHAPTER_ID INTEGER NOT NULL UNIQUE,
$COL_LAST_READ LONG,
$COL_TIME_READ LONG,
FOREIGN KEY($COL_CHAPTER_ID) REFERENCES ${ChapterTable.TABLE} (${ChapterTable.COL_ID})
ON DELETE CASCADE
)"""
/**
* query to index history chapter id
*/
val createChapterIdIndexQuery: String
get() = "CREATE INDEX ${TABLE}_${COL_CHAPTER_ID}_index ON $TABLE($COL_CHAPTER_ID)"
}

View File

@ -1,449 +1,154 @@
package eu.kanade.tachiyomi.data.download package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import android.net.Uri import com.hippo.unifile.UniFile
import com.google.gson.Gson import com.jakewharton.rxrelay.BehaviorRelay
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.Source import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.util.DiskUtils
import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator
import eu.kanade.tachiyomi.util.UrlUtil
import eu.kanade.tachiyomi.util.saveImageTo
import rx.Observable import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.BehaviorSubject
import rx.subjects.PublishSubject
import timber.log.Timber
import java.io.File
import java.io.FileReader
import java.util.*
import java.util.concurrent.TimeUnit
class DownloadManager(private val context: Context, private val sourceManager: SourceManager, private val preferences: PreferencesHelper) {
private val gson = Gson()
private val downloadsQueueSubject = PublishSubject.create<List<Download>>()
val runningSubject = BehaviorSubject.create<Boolean>()
private var downloadsSubscription: Subscription? = null
val downloadNotifier by lazy { DownloadNotifier(context) }
private val threadsSubject = BehaviorSubject.create<Int>()
private var threadsSubscription: Subscription? = null
val queue = DownloadQueue()
val imageFilenameRegex = "[^\\sa-zA-Z0-9.-]".toRegex()
val PAGE_LIST_FILE = "index.json"
@Volatile var isRunning: Boolean = false
private set
private fun initializeSubscriptions() {
downloadsSubscription?.unsubscribe()
threadsSubscription = preferences.downloadThreads().asObservable()
.subscribe {
threadsSubject.onNext(it)
downloadNotifier.multipleDownloadThreads = it > 1
}
downloadsSubscription = downloadsQueueSubject.flatMap { Observable.from(it) }
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
// Delete successful downloads from queue
if (it.status == Download.DOWNLOADED) {
// remove downloaded chapter from queue
queue.del(it)
downloadNotifier.onProgressChange(queue)
}
if (areAllDownloadsFinished()) {
DownloadService.stop(context)
}
}, { e ->
DownloadService.stop(context)
Timber.e(e, e.message)
downloadNotifier.onError(e.message)
})
if (!isRunning) {
isRunning = true
runningSubject.onNext(true)
}
}
fun destroySubscriptions() {
if (isRunning) {
isRunning = false
runningSubject.onNext(false)
}
if (downloadsSubscription != null) {
downloadsSubscription?.unsubscribe()
downloadsSubscription = null
}
if (threadsSubscription != null) {
threadsSubscription?.unsubscribe()
}
}
// Create a download object for every chapter and add them to the downloads queue
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
// Add chapters to queue from the start
val sortedChapters = chapters.sortedByDescending { it.source_order }
// Used to avoid downloading chapters with the same name
val addedChapters = ArrayList<String>()
val pending = ArrayList<Download>()
for (chapter in sortedChapters) {
if (addedChapters.contains(chapter.name))
continue
addedChapters.add(chapter.name)
val download = Download(source, manga, chapter)
if (!prepareDownload(download)) {
queue.add(download)
pending.add(download)
}
}
// Initialize queue size
downloadNotifier.initialQueueSize = queue.size
// Show notification
downloadNotifier.onProgressChange(queue)
if (isRunning) downloadsQueueSubject.onNext(pending)
}
// Public method to check if a chapter is downloaded
fun isChapterDownloaded(source: Source, manga: Manga, chapter: Chapter): Boolean {
val directory = getAbsoluteChapterDirectory(source, manga, chapter)
if (!directory.exists())
return false
val pages = getSavedPageList(source, manga, chapter)
return isChapterDownloaded(directory, pages)
}
// Prepare the download. Returns true if the chapter is already downloaded
private fun prepareDownload(download: Download): Boolean {
// If the chapter is already queued, don't add it again
for (queuedDownload in queue) {
if (download.chapter.id == queuedDownload.chapter.id)
return true
}
// Add the directory to the download object for future access
download.directory = getAbsoluteChapterDirectory(download)
// If the directory doesn't exist, the chapter isn't downloaded.
if (!download.directory.exists()) {
return false
}
// If the page list doesn't exist, the chapter isn't downloaded
val savedPages = getSavedPageList(download) ?: return false
// Add the page list to the download object for future access
download.pages = savedPages
// If the number of files matches the number of pages, the chapter is downloaded.
// We have the index file, so we check one file more
return isChapterDownloaded(download.directory, download.pages)
}
// Check that all the images are downloaded
private fun isChapterDownloaded(directory: File, pages: List<Page>?): Boolean {
return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size
}
// Download the entire chapter
private fun downloadChapter(download: Download): Observable<Download> {
DiskUtils.createDirectory(download.directory)
val pageListObservable = if (download.pages == null)
// Pull page list from network and add them to download object
download.source.fetchPageListFromNetwork(download.chapter)
.doOnNext { pages ->
download.pages = pages
savePageList(download)
}
else
// Or if the page list already exists, start from the file
Observable.just(download.pages)
return Observable.defer {
pageListObservable
.doOnNext { pages ->
download.downloadedImages = 0
download.status = Download.DOWNLOADING
}
// Get all the URLs to the source images, fetch pages if necessary
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
// Start downloading images, consider we can have downloaded images already
.concatMap { page -> getOrDownloadImage(page, download) }
// Do when page is downloaded.
.doOnNext {
downloadNotifier.onProgressChange(download, queue)
}
// Do after download completes
.doOnCompleted { onDownloadCompleted(download) }
.toList()
.map { pages -> download }
// If the page list threw, it will resume here
.onErrorResumeNext { error ->
download.status = Download.ERROR
downloadNotifier.onError(error.message, download.chapter.name)
Observable.just(download)
}
}.subscribeOn(Schedulers.io())
}
// Get the image from the filesystem if it exists or download from network
private fun getOrDownloadImage(page: Page, download: Download): Observable<Page> {
// If the image URL is empty, do nothing
if (page.imageUrl == null)
return Observable.just(page)
val filename = getImageFilename(page)
val imagePath = File(download.directory, filename)
// If the image is already downloaded, do nothing. Otherwise download from network
val pageObservable = if (isImageDownloaded(imagePath))
Observable.just(page)
else
downloadImage(page, download.source, download.directory, filename)
return pageObservable
// When the image is ready, set image path, progress (just in case) and status
.doOnNext {
page.imagePath = imagePath.absolutePath
page.progress = 100
download.downloadedImages++
page.status = Page.READY
}
// Mark this page as error and allow to download the remaining
.onErrorResumeNext {
page.progress = 0
page.status = Page.ERROR
Observable.just(page)
}
}
// Save image on disk
private fun downloadImage(page: Page, source: OnlineSource, directory: File, filename: String): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE
return source.imageResponse(page)
.flatMap {
try {
val file = File(directory, filename)
file.parentFile.mkdirs()
it.body().source().saveImageTo(file.outputStream(), preferences.reencodeImage())
} catch (e: Exception) {
it.body().close()
throw e
}
Observable.just(page)
}
.retryWhen {
it.zipWith(Observable.range(1, 3)) { errors, retries -> retries }
.flatMap { retries -> Observable.timer((retries * 2).toLong(), TimeUnit.SECONDS) }
}
}
// Public method to get the image from the filesystem. It does NOT provide any way to download the image
fun getDownloadedImage(page: Page, chapterDir: File): Observable<Page> {
if (page.imageUrl == null) {
page.status = Page.ERROR
return Observable.just(page)
}
val imagePath = File(chapterDir, getImageFilename(page))
// When the image is ready, set image path, progress (just in case) and status
if (isImageDownloaded(imagePath)) {
page.imagePath = imagePath.absolutePath
page.progress = 100
page.status = Page.READY
} else {
page.status = Page.ERROR
}
return Observable.just(page)
}
// Get the filename for an image given the page
private fun getImageFilename(page: Page): String {
val url = page.imageUrl
val number = String.format("%03d", page.pageNumber + 1)
// Try to preserve file extension
return when {
UrlUtil.isJpg(url) -> "$number.jpg"
UrlUtil.isPng(url) -> "$number.png"
UrlUtil.isGif(url) -> "$number.gif"
else -> Uri.parse(url).lastPathSegment.replace(imageFilenameRegex, "_")
}
}
private fun isImageDownloaded(imagePath: File): Boolean {
return imagePath.exists()
}
// Called when a download finishes. This doesn't mean the download was successful, so we check it
private fun onDownloadCompleted(download: Download) {
checkDownloadIsSuccessful(download)
savePageList(download)
}
private fun checkDownloadIsSuccessful(download: Download) {
var actualProgress = 0
var status = Download.DOWNLOADED
// If any page has an error, the download result will be error
for (page in download.pages) {
actualProgress += page.progress
if (page.status != Page.READY) {
status = Download.ERROR
downloadNotifier.onError(context.getString(R.string.download_notifier_page_ready_error), download.chapter.name)
}
}
// Ensure that the chapter folder has all the images
if (!isChapterDownloaded(download.directory, download.pages)) {
status = Download.ERROR
downloadNotifier.onError(context.getString(R.string.download_notifier_page_error), download.chapter.name)
}
download.totalProgress = actualProgress
download.status = status
}
// Return the page list from the chapter's directory if it exists, null otherwise
fun getSavedPageList(source: Source, manga: Manga, chapter: Chapter): List<Page>? {
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
return try {
JsonReader(FileReader(pagesFile)).use {
val collectionType = object : TypeToken<List<Page>>() {}.type
gson.fromJson(it, collectionType)
}
} catch (e: Exception) {
null
}
}
// Shortcut for the method above
private fun getSavedPageList(download: Download): List<Page>? {
return getSavedPageList(download.source, download.manga, download.chapter)
}
// Save the page list to the chapter's directory
fun savePageList(source: Source, manga: Manga, chapter: Chapter, pages: List<Page>) {
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
pagesFile.outputStream().use {
try {
it.write(gson.toJson(pages).toByteArray())
it.flush()
} catch (e: Exception) {
Timber.e(e, e.message)
}
}
}
// Shortcut for the method above
private fun savePageList(download: Download) {
savePageList(download.source, download.manga, download.chapter, download.pages)
}
fun getAbsoluteMangaDirectory(source: Source, manga: Manga): File {
val mangaRelativePath = source.toString() +
File.separator +
manga.title.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
return File(preferences.downloadsDirectory().getOrDefault(), mangaRelativePath)
}
// Get the absolute path to the chapter directory
fun getAbsoluteChapterDirectory(source: Source, manga: Manga, chapter: Chapter): File {
val chapterRelativePath = chapter.name.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
return File(getAbsoluteMangaDirectory(source, manga), chapterRelativePath)
}
// Shortcut for the method above
private fun getAbsoluteChapterDirectory(download: Download): File {
return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter)
}
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
val path = getAbsoluteChapterDirectory(source, manga, chapter)
DiskUtils.deleteFiles(path)
}
fun areAllDownloadsFinished(): Boolean {
for (download in queue) {
if (download.status <= Download.DOWNLOADING)
return false
}
return true
}
/**
* This class is used to manage chapter downloads in the application. It must be instantiated once
* and retrieved through dependency injection. You can use this class to queue new chapters or query
* downloaded chapters.
*
* @param context the application context.
*/
class DownloadManager(context: Context) {
/**
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
*/
private val provider = DownloadProvider(context)
/**
* Downloader whose only task is to download chapters.
*/
private val downloader = Downloader(context, provider)
/**
* Downloads queue, where the pending chapters are stored.
*/
val queue: DownloadQueue
get() = downloader.queue
/**
* Subject for subscribing to downloader status.
*/
val runningRelay: BehaviorRelay<Boolean>
get() = downloader.runningRelay
/**
* Tells the downloader to begin downloads.
*
* @return true if it's started, false otherwise (empty queue).
*/
fun startDownloads(): Boolean { fun startDownloads(): Boolean {
if (queue.isEmpty()) return downloader.start()
return false
if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed)
initializeSubscriptions()
val pending = ArrayList<Download>()
for (download in queue) {
if (download.status != Download.DOWNLOADED) {
if (download.status != Download.QUEUE) download.status = Download.QUEUE
pending.add(download)
}
}
downloadsQueueSubject.onNext(pending)
return !pending.isEmpty()
} }
fun stopDownloads(errorMessage: String? = null) { /**
destroySubscriptions() * Tells the downloader to stop downloads.
for (download in queue) { *
if (download.status == Download.DOWNLOADING) { * @param reason an optional reason for being stopped, used to notify the user.
download.status = Download.ERROR */
} fun stopDownloads(reason: String? = null) {
} downloader.stop(reason)
errorMessage?.let { downloadNotifier.onError(it) }
} }
/**
* Empties the download queue.
*/
fun clearQueue() { fun clearQueue() {
queue.clear() downloader.clearQueue()
downloadNotifier.onClear() }
/**
* Tells the downloader to enqueue the given list of chapters.
*
* @param manga the manga of the chapters.
* @param chapters the list of chapters to enqueue.
*/
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
downloader.queueChapters(manga, chapters)
}
/**
* Builds the page list of a downloaded chapter.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the downloaded chapter.
* @return an observable containing the list of pages from the chapter.
*/
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): Observable<List<Page>> {
return buildPageList(provider.findChapterDir(source, manga, chapter))
}
/**
* Builds the page list of a downloaded chapter.
*
* @param chapterDir the file where the chapter is downloaded.
* @return an observable containing the list of pages from the chapter.
*/
private fun buildPageList(chapterDir: UniFile?): Observable<List<Page>> {
return Observable.fromCallable {
val files = chapterDir?.listFiles().orEmpty()
.filter { "image" in it.type.orEmpty() }
if (files.isEmpty()) {
throw Exception("Page list is empty")
}
files.sortedBy { it.name }
.mapIndexed { i, file ->
Page(i, uri = file.uri).apply { status = Page.READY }
}
}
}
/**
* Returns the directory name for the given chapter.
*
* @param chapter the chapter to query.
*/
fun getChapterDirName(chapter: Chapter): String {
return provider.getChapterDirName(chapter)
}
/**
* Returns the directory for the given manga, if it exists.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
fun findMangaDir(source: Source, manga: Manga): UniFile? {
return provider.findMangaDir(source, manga)
}
/**
* Returns the directory for the given chapter, if it exists.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to query.
*/
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
return provider.findChapterDir(source, manga, chapter)
}
/**
* Deletes the directory of a downloaded chapter.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to delete.
*/
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
provider.findChapterDir(source, manga, chapter)?.delete()
} }
} }

View File

@ -1,11 +1,13 @@
package eu.kanade.tachiyomi.data.download package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
import eu.kanade.tachiyomi.Constants import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.util.chop
import eu.kanade.tachiyomi.util.notificationManager import eu.kanade.tachiyomi.util.notificationManager
/** /**
@ -13,17 +15,14 @@ import eu.kanade.tachiyomi.util.notificationManager
* *
* @param context context of application * @param context context of application
*/ */
class DownloadNotifier(private val context: Context) { internal class DownloadNotifier(private val context: Context) {
/** /**
* Notification builder. * Notification builder.
*/ */
private val notificationBuilder = NotificationCompat.Builder(context) private val notification by lazy {
NotificationCompat.Builder(context)
/** .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
* Id of the notification. }
*/
private val notificationId: Int
get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
/** /**
* Status of download. Used for correct notification icon. * Status of download. Used for correct notification icon.
@ -33,12 +32,29 @@ class DownloadNotifier(private val context: Context) {
/** /**
* The size of queue on start download. * The size of queue on start download.
*/ */
internal var initialQueueSize = 0 var initialQueueSize = 0
/** /**
* Simultaneous download setting > 1. * Simultaneous download setting > 1.
*/ */
internal var multipleDownloadThreads = false var multipleDownloadThreads = false
/**
* Shows a notification from this builder.
*
* @param id the id of the notification.
*/
private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID) {
context.notificationManager.notify(id, build())
}
/**
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
* those can only be dismissed by the user.
*/
fun dismiss() {
context.notificationManager.cancel(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID)
}
/** /**
* Called when download progress changes. * Called when download progress changes.
@ -46,47 +62,47 @@ class DownloadNotifier(private val context: Context) {
* *
* @param queue the queue containing downloads. * @param queue the queue containing downloads.
*/ */
internal fun onProgressChange(queue: DownloadQueue) { fun onProgressChange(queue: DownloadQueue) {
if (multipleDownloadThreads) { if (multipleDownloadThreads) {
doOnProgressChange(null, queue) doOnProgressChange(null, queue)
} }
} }
/** /**
* Called when download progress changes * Called when download progress changes.
* Note: Only accepted when single download active * Note: Only accepted when single download active.
* *
* @param download download object containing download information * @param download download object containing download information.
* @param queue the queue containing downloads * @param queue the queue containing downloads.
*/ */
internal fun onProgressChange(download: Download, queue: DownloadQueue) { fun onProgressChange(download: Download, queue: DownloadQueue) {
if (!multipleDownloadThreads) { if (!multipleDownloadThreads) {
doOnProgressChange(download, queue) doOnProgressChange(download, queue)
} }
} }
/** /**
* Show notification progress of chapter * Show notification progress of chapter.
* *
* @param download download object containing download information * @param download download object containing download information.
* @param queue the queue containing downloads * @param queue the queue containing downloads.
*/ */
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) { private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
// Check if download is completed // Check if download is completed
if (multipleDownloadThreads) { if (multipleDownloadThreads) {
if (queue.isEmpty()) { if (queue.isEmpty()) {
onComplete(null) onChapterCompleted(null)
return return
} }
} else { } else {
if (download != null && download.pages.size == download.downloadedImages) { if (download != null && download.pages!!.size == download.downloadedImages) {
onComplete(download) onChapterCompleted(download)
return return
} }
} }
// Create notification // Create notification
with (notificationBuilder) { with(notification) {
// Check if icon needs refresh // Check if icon needs refresh
if (!isDownloading) { if (!isDownloading) {
setSmallIcon(android.R.drawable.stat_sys_download) setSmallIcon(android.R.drawable.stat_sys_download)
@ -96,35 +112,35 @@ class DownloadNotifier(private val context: Context) {
if (multipleDownloadThreads) { if (multipleDownloadThreads) {
setContentTitle(context.getString(R.string.app_name)) setContentTitle(context.getString(R.string.app_name))
// Reset the queue size if the download progress is negative
if ((initialQueueSize - queue.size) < 0)
initialQueueSize = queue.size
setContentText(context.getString(R.string.chapter_downloading_progress) setContentText(context.getString(R.string.chapter_downloading_progress)
.format(initialQueueSize - queue.size, initialQueueSize)) .format(initialQueueSize - queue.size, initialQueueSize))
setProgress(initialQueueSize, initialQueueSize - queue.size, false) setProgress(initialQueueSize, initialQueueSize - queue.size, false)
} else { } else {
download?.let { download?.let {
if (it.chapter.name.length >= 33) setContentTitle(it.chapter.name.chop(30))
setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
else
setContentTitle(it.chapter.name)
setContentText(context.getString(R.string.chapter_downloading_progress) setContentText(context.getString(R.string.chapter_downloading_progress)
.format(it.downloadedImages, it.pages.size)) .format(it.downloadedImages, it.pages!!.size))
setProgress(it.pages.size, it.downloadedImages, false) setProgress(it.pages!!.size, it.downloadedImages, false)
} }
} }
} }
// Displays the progress bar on notification // Displays the progress bar on notification
context.notificationManager.notify(notificationId, notificationBuilder.build()) notification.show()
} }
/** /**
* Called when chapter is downloaded * Called when chapter is downloaded.
* *
* @param download download object containing download information * @param download download object containing download information.
*/ */
private fun onComplete(download: Download?) { private fun onChapterCompleted(download: Download?) {
// Create notification. // Create notification.
with(notificationBuilder) { with(notification) {
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name)) setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
setContentText(context.getString(R.string.update_check_notification_download_complete)) setContentText(context.getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done) setSmallIcon(android.R.drawable.stat_sys_download_done)
@ -132,7 +148,7 @@ class DownloadNotifier(private val context: Context) {
} }
// Show notification. // Show notification.
context.notificationManager.notify(notificationId, notificationBuilder.build()) notification.show()
// Reset initial values // Reset initial values
isDownloading = false isDownloading = false
@ -140,27 +156,38 @@ class DownloadNotifier(private val context: Context) {
} }
/** /**
* Clears the notification message * Called when the downloader receives a warning.
*
* @param reason the text to show.
*/ */
internal fun onClear() { fun onWarning(reason: String) {
context.notificationManager.cancel(notificationId) with(notification) {
setContentTitle(context.getString(R.string.download_notifier_downloader_title))
setContentText(reason)
setSmallIcon(android.R.drawable.stat_sys_warning)
setProgress(0, 0, false)
}
notification.show()
} }
/** /**
* Called on error while downloading chapter * Called when the downloader receives an error. It's shown as a separate notification to avoid
* being overwritten.
* *
* @param error string containing error information * @param error string containing error information.
* @param chapter string containing chapter title * @param chapter string containing chapter title.
*/ */
internal fun onError(error: String? = null, chapter: String? = null) { fun onError(error: String? = null, chapter: String? = null) {
// Create notification // Create notification
with(notificationBuilder) { with(notification) {
setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error)) setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title))
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error)) setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
setSmallIcon(android.R.drawable.stat_sys_warning) setSmallIcon(android.R.drawable.stat_sys_warning)
setProgress(0, 0, false) setProgress(0, 0, false)
} }
context.notificationManager.notify(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID, notificationBuilder.build()) notification.show(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID)
// Reset download information
isDownloading = false isDownloading = false
} }
} }

View File

@ -0,0 +1,98 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.net.Uri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.util.DiskUtil
import uy.kohesive.injekt.injectLazy
/**
* This class is used to provide the directories where the downloads should be saved.
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
*
* @param context the application context.
*/
class DownloadProvider(private val context: Context) {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* The root directory for downloads.
*/
private lateinit var downloadsDir: UniFile
init {
preferences.downloadsDirectory().asObservable()
.subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) }
}
/**
* Returns the download directory for a manga. For internal use only.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
internal fun getMangaDir(source: Source, manga: Manga): UniFile {
return downloadsDir
.createDirectory(getSourceDirName(source))
.createDirectory(getMangaDirName(manga))
}
/**
* Returns the download directory for a manga if it exists.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
fun findMangaDir(source: Source, manga: Manga): UniFile? {
val sourceDir = downloadsDir.findFile(getSourceDirName(source))
return sourceDir?.findFile(getMangaDirName(manga))
}
/**
* Returns the download directory for a chapter if it exists.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to query.
*/
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
val mangaDir = findMangaDir(source, manga)
return mangaDir?.findFile(getChapterDirName(chapter))
}
/**
* Returns the download directory name for a source.
*
* @param source the source to query.
*/
fun getSourceDirName(source: Source): String {
return source.toString()
}
/**
* Returns the download directory name for a manga.
*
* @param manga the manga to query.
*/
fun getMangaDirName(manga: Manga): String {
return DiskUtil.buildValidFilename(manga.title)
}
/**
* Returns the chapter directory name for a chapter.
*
* @param chapter the chapter to query.
*/
fun getChapterDirName(chapter: Chapter): String {
return DiskUtil.buildValidFilename(chapter.name)
}
}

View File

@ -3,132 +3,177 @@ package eu.kanade.tachiyomi.data.download
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.NetworkInfo.State.CONNECTED
import android.net.NetworkInfo.State.DISCONNECTED
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus import com.github.pwittchen.reactivenetwork.library.Connectivity
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
import eu.kanade.tachiyomi.App import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.connectivityManager
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.util.powerManager
import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.util.toast
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import javax.inject.Inject import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
/**
* This service is used to manage the downloader. The system can decide to stop the service, in
* which case the downloader is also stopped. It's also stopped while there's no network available.
* While the downloader is running, a wake lock will be held.
*/
class DownloadService : Service() { class DownloadService : Service() {
companion object { companion object {
/**
* Relay used to know when the service is running.
*/
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
/**
* Starts this service.
*
* @param context the application context.
*/
fun start(context: Context) { fun start(context: Context) {
context.startService(Intent(context, DownloadService::class.java)) context.startService(Intent(context, DownloadService::class.java))
} }
/**
* Stops this service.
*
* @param context the application context.
*/
fun stop(context: Context) { fun stop(context: Context) {
context.stopService(Intent(context, DownloadService::class.java)) context.stopService(Intent(context, DownloadService::class.java))
} }
} }
@Inject lateinit var downloadManager: DownloadManager /**
@Inject lateinit var preferences: PreferencesHelper * Download manager.
*/
private val downloadManager: DownloadManager by injectLazy()
private var wakeLock: PowerManager.WakeLock? = null /**
private var networkChangeSubscription: Subscription? = null * Preferences helper.
private var queueRunningSubscription: Subscription? = null */
private var isRunning: Boolean = false private val preferences: PreferencesHelper by injectLazy()
/**
* Wake lock to prevent the device to enter sleep mode.
*/
private val wakeLock by lazy {
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
}
/**
* Subscriptions to store while the service is running.
*/
private lateinit var subscriptions: CompositeSubscription
/**
* Called when the service is created.
*/
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
App.get(this).component.inject(this) runningRelay.call(true)
subscriptions = CompositeSubscription()
createWakeLock() listenDownloaderState()
listenQueueRunningChanges()
listenNetworkChanges() listenNetworkChanges()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { /**
return Service.START_STICKY * Called when the service is destroyed.
} */
override fun onDestroy() { override fun onDestroy() {
queueRunningSubscription?.unsubscribe() runningRelay.call(false)
networkChangeSubscription?.unsubscribe() subscriptions.unsubscribe()
downloadManager.destroySubscriptions() downloadManager.stopDownloads()
destroyWakeLock() wakeLock.releaseIfNeeded()
super.onDestroy() super.onDestroy()
} }
/**
* Not used.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return Service.START_NOT_STICKY
}
/**
* Not used.
*/
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
return null return null
} }
/**
* Listens to network changes.
*
* @see onNetworkStateChanged
*/
private fun listenNetworkChanges() { private fun listenNetworkChanges() {
networkChangeSubscription = ReactiveNetwork().enableInternetCheck() subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
.observeConnectivity(applicationContext)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ state -> .subscribe({ state -> onNetworkStateChanged(state)
when (state) {
ConnectivityStatus.WIFI_CONNECTED_HAS_INTERNET -> {
// If there are no remaining downloads, destroy the service
if (!isRunning && !downloadManager.startDownloads()) {
stopSelf()
}
}
ConnectivityStatus.MOBILE_CONNECTED -> {
if (!preferences.downloadOnlyOverWifi()) {
if (!isRunning && !downloadManager.startDownloads()) {
stopSelf()
}
} else if (isRunning) {
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
}
}
else -> {
if (isRunning) {
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
}
}
}
}, { error -> }, { error ->
toast(R.string.download_queue_error) toast(R.string.download_queue_error)
stopSelf() stopSelf()
}) })
} }
private fun listenQueueRunningChanges() { /**
queueRunningSubscription = downloadManager.runningSubject.subscribe { running -> * Called when the network state changes.
isRunning = running *
* @param connectivity the new network state.
*/
private fun onNetworkStateChanged(connectivity: Connectivity) {
when (connectivity.state) {
CONNECTED -> {
if (preferences.downloadOnlyOverWifi() && connectivityManager.isActiveNetworkMetered) {
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
} else {
val started = downloadManager.startDownloads()
if (!started) stopSelf()
}
}
DISCONNECTED -> {
downloadManager.stopDownloads(getString(R.string.download_notifier_no_network))
}
else -> { /* Do nothing */ }
}
}
/**
* Listens to downloader status. Enables or disables the wake lock depending on the status.
*/
private fun listenDownloaderState() {
subscriptions += downloadManager.runningRelay.subscribe { running ->
if (running) if (running)
acquireWakeLock() wakeLock.acquireIfNeeded()
else else
releaseWakeLock() wakeLock.releaseIfNeeded()
} }
} }
private fun createWakeLock() { /**
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( * Releases the wake lock if it's held.
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock") */
fun PowerManager.WakeLock.releaseIfNeeded() {
if (isHeld) release()
} }
private fun destroyWakeLock() { /**
if (wakeLock != null && wakeLock!!.isHeld) { * Acquires the wake lock if it's not held.
wakeLock!!.release() */
wakeLock = null fun PowerManager.WakeLock.acquireIfNeeded() {
} if (!isHeld) acquire()
}
fun acquireWakeLock() {
if (wakeLock != null && !wakeLock!!.isHeld) {
wakeLock!!.acquire()
}
}
fun releaseWakeLock() {
if (wakeLock != null && wakeLock!!.isHeld) {
wakeLock!!.release()
}
} }
} }

View File

@ -0,0 +1,135 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import uy.kohesive.injekt.injectLazy
/**
* This class is used to persist active downloads across application restarts.
*
* @param context the application context.
*/
class DownloadStore(context: Context) {
/**
* Preference file where active downloads are stored.
*/
private val preferences = context.getSharedPreferences("active_downloads", Context.MODE_PRIVATE)
/**
* Gson instance to serialize/deserialize downloads.
*/
private val gson: Gson by injectLazy()
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Database helper.
*/
private val db: DatabaseHelper by injectLazy()
/**
* Counter used to keep the queue order.
*/
private var counter = 0
/**
* Adds a list of downloads to the store.
*
* @param downloads the list of downloads to add.
*/
fun addAll(downloads: List<Download>) {
val editor = preferences.edit()
downloads.forEach { editor.putString(getKey(it), serialize(it)) }
editor.apply()
}
/**
* Removes a download from the store.
*
* @param download the download to remove.
*/
fun remove(download: Download) {
preferences.edit().remove(getKey(download)).apply()
}
/**
* Removes all the downloads from the store.
*/
fun clear() {
preferences.edit().clear().apply()
}
/**
* Returns the preference's key for the given download.
*
* @param download the download.
*/
private fun getKey(download: Download): String {
return download.chapter.id!!.toString()
}
/**
* Returns the list of downloads to restore. It should be called in a background thread.
*/
fun restore(): List<Download> {
val objs = preferences.all
.mapNotNull { it.value as? String }
.map { deserialize(it) }
.sortedBy { it.order }
val downloads = mutableListOf<Download>()
if (objs.isNotEmpty()) {
val cachedManga = mutableMapOf<Long, Manga?>()
for ((mangaId, chapterId) in objs) {
val manga = cachedManga.getOrPut(mangaId) {
db.getManga(mangaId).executeAsBlocking()
} ?: continue
val source = sourceManager.get(manga.source) as? OnlineSource ?: continue
val chapter = db.getChapter(chapterId).executeAsBlocking() ?: continue
downloads.add(Download(source, manga, chapter))
}
}
// Clear the store, downloads will be added again immediately.
clear()
return downloads
}
/**
* Converts a download to a string.
*
* @param download the download to serialize.
*/
private fun serialize(download: Download): String {
val obj = DownloadObject(download.manga.id!!, download.chapter.id!!, counter++)
return gson.toJson(obj)
}
/**
* Restore a download from a string.
*
* @param string the download as string.
*/
private fun deserialize(string: String): DownloadObject {
return gson.fromJson(string, DownloadObject::class.java)
}
/**
* Class used for download serialization
*
* @param mangaId the id of the manga.
* @param chapterId the id of the chapter.
* @param order the order of the download in the queue.
*/
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
}

View File

@ -0,0 +1,431 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.webkit.MimeTypeMap
import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator
import eu.kanade.tachiyomi.util.RetryWithDelay
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.util.saveTo
import okhttp3.Response
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.BehaviorSubject
import rx.subscriptions.CompositeSubscription
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.net.URLConnection
/**
* This class is the one in charge of downloading chapters.
*
* Its [queue] contains the list of chapters to download. In order to download them, the downloader
* subscriptions must be running and the list of chapters must be sent to them by [downloadsRelay].
*
* The queue manipulation must be done in one thread (currently the main thread) to avoid unexpected
* behavior, but it's safe to read it from multiple threads.
*
* @param context the application context.
* @param provider the downloads directory provider.
*/
class Downloader(private val context: Context, private val provider: DownloadProvider) {
/**
* Store for persisting downloads across restarts.
*/
private val store = DownloadStore(context)
/**
* Queue where active downloads are kept.
*/
val queue = DownloadQueue(store)
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Preferences.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* Notifier for the downloader state and progress.
*/
private val notifier by lazy { DownloadNotifier(context) }
/**
* Downloader subscriptions.
*/
private val subscriptions = CompositeSubscription()
/**
* Subject to do a live update of the number of simultaneous downloads.
*/
private val threadsSubject = BehaviorSubject.create<Int>()
/**
* Relay to send a list of downloads to the downloader.
*/
private val downloadsRelay = PublishRelay.create<List<Download>>()
/**
* Relay to subscribe to the downloader status.
*/
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
/**
* Whether the downloader is running.
*/
@Volatile private var isRunning: Boolean = false
init {
Observable.fromCallable { store.restore() }
.map { downloads -> downloads.filter { isDownloadAllowed(it) } }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ downloads -> queue.addAll(downloads)
}, { error -> Timber.e(error) })
}
/**
* Starts the downloader. It doesn't do anything if it's already running or there isn't anything
* to download.
*
* @return true if the downloader is started, false otherwise.
*/
fun start(): Boolean {
if (isRunning || queue.isEmpty())
return false
if (!subscriptions.hasSubscriptions())
initializeSubscriptions()
val pending = queue.filter { it.status != Download.DOWNLOADED }
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
downloadsRelay.call(pending)
return !pending.isEmpty()
}
/**
* Stops the downloader.
*/
fun stop(reason: String? = null) {
destroySubscriptions()
queue
.filter { it.status == Download.DOWNLOADING }
.forEach { it.status = Download.ERROR }
if (reason != null) {
notifier.onWarning(reason)
} else {
notifier.dismiss()
}
}
/**
* Removes everything from the queue.
*/
fun clearQueue() {
destroySubscriptions()
queue.clear()
notifier.dismiss()
}
/**
* Prepares the subscriptions to start downloading.
*/
private fun initializeSubscriptions() {
if (isRunning) return
isRunning = true
runningRelay.call(true)
subscriptions.clear()
subscriptions += preferences.downloadThreads().asObservable()
.subscribe {
threadsSubject.onNext(it)
notifier.multipleDownloadThreads = it > 1
}
subscriptions += downloadsRelay.flatMap { Observable.from(it) }
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ completeDownload(it)
}, { error ->
DownloadService.stop(context)
Timber.e(error)
notifier.onError(error.message)
})
}
/**
* Destroys the downloader subscriptions.
*/
private fun destroySubscriptions() {
if (!isRunning) return
isRunning = false
runningRelay.call(false)
subscriptions.clear()
}
/**
* Creates a download object for every chapter and adds them to the downloads queue. This method
* must be called in the main thread.
*
* @param manga the manga of the chapters to download.
* @param chapters the list of chapters to download.
*/
fun queueChapters(manga: Manga, chapters: List<Chapter>) {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
val chaptersToQueue = chapters
// Avoid downloading chapters with the same name.
.distinctBy { it.name }
// Add chapters to queue from the start.
.sortedByDescending { it.source_order }
// Create a downloader for each one.
.map { Download(source, manga, it) }
// Filter out those already queued or downloaded.
.filter { isDownloadAllowed(it) }
// Return if there's nothing to queue.
if (chaptersToQueue.isEmpty())
return
queue.addAll(chaptersToQueue)
// Initialize queue size.
notifier.initialQueueSize = queue.size
if (isRunning) {
// Send the list of downloads to the downloader.
downloadsRelay.call(chaptersToQueue)
} else {
// Show initial notification.
notifier.onProgressChange(queue)
}
}
/**
* Returns true if the given download can be queued and downloaded.
*
* @param download the download to be checked.
*/
private fun isDownloadAllowed(download: Download): Boolean {
// If the chapter is already queued, don't add it again
if (queue.any { it.chapter.id == download.chapter.id })
return false
val dir = provider.findChapterDir(download.source, download.manga, download.chapter)
if (dir != null && dir.exists())
return false
return true
}
/**
* Returns the observable which downloads a chapter.
*
* @param download the chapter to be downloaded.
*/
private fun downloadChapter(download: Download): Observable<Download> {
val chapterDirname = provider.getChapterDirName(download.chapter)
val mangaDir = provider.getMangaDir(download.source, download.manga)
val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp")
val pageListObservable = if (download.pages == null) {
// Pull page list from network and add them to download object
download.source.fetchPageListFromNetwork(download.chapter)
.doOnNext { pages ->
download.pages = pages
}
} else {
// Or if the page list already exists, start from the file
Observable.just(download.pages!!)
}
return pageListObservable
.doOnNext { pages ->
// Delete all temporary (unfinished) files
tmpDir.listFiles()
?.filter { it.name!!.endsWith(".tmp") }
?.forEach { it.delete() }
download.downloadedImages = 0
download.status = Download.DOWNLOADING
}
// Get all the URLs to the source images, fetch pages if necessary
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
// Start downloading images, consider we can have downloaded images already
.concatMap { page -> getOrDownloadImage(page, download, tmpDir) }
// Do when page is downloaded.
.doOnNext { notifier.onProgressChange(download, queue) }
.toList()
.map { pages -> download }
// Do after download completes
.doOnNext { ensureSuccessfulDownload(download, tmpDir, chapterDirname) }
// If the page list threw, it will resume here
.onErrorReturn { error ->
download.status = Download.ERROR
notifier.onError(error.message, download.chapter.name)
download
}
.subscribeOn(Schedulers.io())
}
/**
* Returns the observable which gets the image from the filesystem if it exists or downloads it
* otherwise.
*
* @param page the page to download.
* @param download the download of the page.
* @param tmpDir the temporary directory of the download.
*/
private fun getOrDownloadImage(page: Page, download: Download, tmpDir: UniFile): Observable<Page> {
// If the image URL is empty, do nothing
if (page.imageUrl == null)
return Observable.just(page)
val filename = String.format("%03d", page.number)
val tmpFile = tmpDir.findFile("$filename.tmp")
// Delete temp file if it exists.
tmpFile?.delete()
// Try to find the image file.
val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.")}
// If the image is already downloaded, do nothing. Otherwise download from network
val pageObservable = if (imageFile != null)
Observable.just(imageFile)
else
downloadImage(page, download.source, tmpDir, filename)
return pageObservable
// When the image is ready, set image path, progress (just in case) and status
.doOnNext { file ->
page.uri = file.uri
page.progress = 100
download.downloadedImages++
page.status = Page.READY
}
.map { page }
// Mark this page as error and allow to download the remaining
.onErrorReturn {
page.progress = 0
page.status = Page.ERROR
page
}
}
/**
* Returns the observable which downloads the image from network.
*
* @param page the page to download.
* @param source the source of the page.
* @param tmpDir the temporary directory of the download.
* @param filename the filename of the image.
*/
private fun downloadImage(page: Page, source: OnlineSource, tmpDir: UniFile, filename: String): Observable<UniFile> {
page.status = Page.DOWNLOAD_IMAGE
page.progress = 0
return source.imageResponse(page)
.map { response ->
val file = tmpDir.createFile("$filename.tmp")
try {
response.body().source().saveTo(file.openOutputStream())
val extension = getImageExtension(response, file)
file.renameTo("$filename.$extension")
} catch (e: Exception) {
response.close()
file.delete()
throw e
}
file
}
// Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
.retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
}
/**
* Returns the extension of the downloaded image from the network response, or if it's null,
* analyze the file. If everything fails, assume it's a jpg.
*
* @param response the network response of the image.
* @param file the file where the image is already downloaded.
*/
private fun getImageExtension(response: Response, file: UniFile): String {
// Read content type if available.
val mime = response.body().contentType()?.let { ct -> "${ct.type()}/${ct.subtype()}" }
// Else guess from the uri.
?: context.contentResolver.getType(file.uri)
// Else read magic numbers.
?: file.openInputStream().buffered().use {
URLConnection.guessContentTypeFromStream(it)
}
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
}
/**
* Checks if the download was successful.
*
* @param download the download to check.
* @param tmpDir the directory where the download is currently stored.
* @param dirname the real (non temporary) directory name of the download.
*/
private fun ensureSuccessfulDownload(download: Download, tmpDir: UniFile, dirname: String) {
// Ensure that the chapter folder has all the images.
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
download.status = if (downloadedImages.size == download.pages!!.size) {
Download.DOWNLOADED
} else {
Download.ERROR
}
// Only rename the directory if it's downloaded.
if (download.status == Download.DOWNLOADED) {
tmpDir.renameTo(dirname)
}
}
/**
* Completes a download. This method is called in the main thread.
*/
private fun completeDownload(download: Download) {
// Delete successful downloads from queue
if (download.status == Download.DOWNLOADED) {
// remove downloaded chapter from queue
queue.remove(download)
notifier.onProgressChange(queue)
}
if (areAllDownloadsFinished()) {
DownloadService.stop(context)
}
}
/**
* Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
*/
private fun areAllDownloadsFinished(): Boolean {
return queue.none { it.status <= Download.DOWNLOADING }
}
}

View File

@ -1,55 +0,0 @@
package eu.kanade.tachiyomi.data.download.model;
import java.io.File;
import java.util.List;
import eu.kanade.tachiyomi.data.database.models.Chapter;
import eu.kanade.tachiyomi.data.database.models.Manga;
import eu.kanade.tachiyomi.data.source.model.Page;
import eu.kanade.tachiyomi.data.source.online.OnlineSource;
import rx.subjects.PublishSubject;
public class Download {
public OnlineSource source;
public Manga manga;
public Chapter chapter;
public List<Page> pages;
public File directory;
public transient volatile int totalProgress;
public transient volatile int downloadedImages;
private transient volatile int status;
private transient PublishSubject<Download> statusSubject;
public static final int NOT_DOWNLOADED = 0;
public static final int QUEUE = 1;
public static final int DOWNLOADING = 2;
public static final int DOWNLOADED = 3;
public static final int ERROR = 4;
public Download(OnlineSource source, Manga manga, Chapter chapter) {
this.source = source;
this.manga = manga;
this.chapter = chapter;
}
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
notifyStatus();
}
public void setStatusSubject(PublishSubject<Download> subject) {
this.statusSubject = subject;
}
private void notifyStatus() {
if (statusSubject != null)
statusSubject.onNext(this);
}
}

View File

@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.data.download.model
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import rx.subjects.PublishSubject
class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
var pages: List<Page>? = null
@Volatile @Transient var totalProgress: Int = 0
@Volatile @Transient var downloadedImages: Int = 0
@Volatile @Transient var status: Int = 0
set(status) {
field = status
statusSubject?.onNext(this)
}
@Transient private var statusSubject: PublishSubject<Download>? = null
fun setStatusSubject(subject: PublishSubject<Download>?) {
statusSubject = subject
}
companion object {
const val NOT_DOWNLOADED = 0
const val QUEUE = 1
const val DOWNLOADING = 2
const val DOWNLOADED = 3
const val ERROR = 4
}
}

View File

@ -1,34 +1,62 @@
package eu.kanade.tachiyomi.data.download.model package eu.kanade.tachiyomi.data.download.model
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.download.DownloadStore
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import rx.Observable import rx.Observable
import rx.subjects.PublishSubject import rx.subjects.PublishSubject
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
class DownloadQueue : CopyOnWriteArrayList<Download>() { class DownloadQueue(
private val store: DownloadStore,
private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
: List<Download> by queue {
private val statusSubject = PublishSubject.create<Download>() private val statusSubject = PublishSubject.create<Download>()
override fun add(download: Download): Boolean { private val updatedRelay = PublishRelay.create<Unit>()
download.setStatusSubject(statusSubject)
download.status = Download.QUEUE fun addAll(downloads: List<Download>) {
return super.add(download) downloads.forEach { download ->
download.setStatusSubject(statusSubject)
download.status = Download.QUEUE
}
queue.addAll(downloads)
store.addAll(downloads)
updatedRelay.call(Unit)
} }
fun del(download: Download) { fun remove(download: Download) {
super.remove(download) val removed = queue.remove(download)
store.remove(download)
download.setStatusSubject(null) download.setStatusSubject(null)
if (removed) {
updatedRelay.call(Unit)
}
} }
fun del(chapter: Chapter) { fun remove(chapter: Chapter) {
find { it.chapter.id == chapter.id }?.let { del(it) } find { it.chapter.id == chapter.id }?.let { remove(it) }
} }
fun getActiveDownloads() = fun clear() {
queue.forEach { download ->
download.setStatusSubject(null)
}
queue.clear()
store.clear()
updatedRelay.call(Unit)
}
fun getActiveDownloads(): Observable<Download> =
Observable.from(this).filter { download -> download.status == Download.DOWNLOADING } Observable.from(this).filter { download -> download.status == Download.DOWNLOADING }
fun getStatusObservable() = statusSubject.onBackpressureBuffer() fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
fun getUpdatedObservable(): Observable<List<Download>> = updatedRelay.onBackpressureBuffer()
.startWith(Unit)
.map { this }
fun getProgressObservable(): Observable<Download> { fun getProgressObservable(): Observable<Download> {
return statusSubject.onBackpressureBuffer() return statusSubject.onBackpressureBuffer()

View File

@ -7,28 +7,26 @@ import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.GlideModule import com.bumptech.glide.module.GlideModule
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.NetworkHelper import eu.kanade.tachiyomi.data.network.NetworkHelper
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject
/** /**
* Class used to update Glide module settings * Class used to update Glide module settings
*/ */
class AppGlideModule : GlideModule { class AppGlideModule : GlideModule {
@Inject lateinit var networkHelper: NetworkHelper
override fun applyOptions(context: Context, builder: GlideBuilder) { override fun applyOptions(context: Context, builder: GlideBuilder) {
// Set the cache size of Glide to 15 MiB // Set the cache size of Glide to 15 MiB
builder.setDiskCache(InternalCacheDiskCacheFactory(context, 15 * 1024 * 1024)) builder.setDiskCache(InternalCacheDiskCacheFactory(context, 15 * 1024 * 1024))
} }
override fun registerComponents(context: Context, glide: Glide) { override fun registerComponents(context: Context, glide: Glide) {
App.get(context).component.inject(this) val networkFactory = OkHttpUrlLoader.Factory(Injekt.get<NetworkHelper>().client)
glide.register(GlideUrl::class.java, InputStream::class.java,
OkHttpUrlLoader.Factory(networkHelper.client)) glide.register(GlideUrl::class.java, InputStream::class.java, networkFactory)
glide.register(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory()) glide.register(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory())
} }
} }

View File

@ -4,7 +4,7 @@ import com.bumptech.glide.Priority
import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.data.DataFetcher
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileNotFoundException
import java.io.InputStream import java.io.InputStream
/** /**
@ -26,17 +26,32 @@ class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
@Throws(Exception::class) @Throws(Exception::class)
override fun loadData(priority: Priority): InputStream? { override fun loadData(priority: Priority): InputStream? {
if (manga.favorite) { if (manga.favorite) {
if (!file.exists()) { synchronized(file) {
file.parentFile.mkdirs() if (!file.exists()) {
networkFetcher.loadData(priority)?.let { val tmpFile = File(file.path + ".tmp")
it.use { input -> try {
file.outputStream().use { output -> // Retrieve source stream.
input.copyTo(output) val input = networkFetcher.loadData(priority)
?: throw Exception("Couldn't open source stream")
// Retrieve destination stream, create parent folders if needed.
val output = try {
tmpFile.outputStream()
} catch (e: FileNotFoundException) {
tmpFile.parentFile.mkdirs()
tmpFile.outputStream()
} }
// Copy the file and rename to the original.
input.use { output.use { input.copyTo(output) } }
tmpFile.renameTo(file)
} catch (e: Exception) {
tmpFile.delete()
throw e
} }
} }
} }
return FileInputStream(file) return file.inputStream()
} else { } else {
if (file.exists()) { if (file.exists()) {
file.delete() file.delete()

View File

@ -1,18 +1,18 @@
package eu.kanade.tachiyomi.data.glide package eu.kanade.tachiyomi.data.glide
import android.content.Context import android.content.Context
import android.util.LruCache
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.data.DataFetcher import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.* import com.bumptech.glide.load.model.*
import com.bumptech.glide.load.model.stream.StreamModelLoader import com.bumptech.glide.load.model.stream.StreamModelLoader
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.SourceManager import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.online.OnlineSource import eu.kanade.tachiyomi.data.source.online.OnlineSource
import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject
/** /**
* A class for loading a cover associated with a [Manga] that can be present in our own cache. * A class for loading a cover associated with a [Manga] that can be present in our own cache.
@ -30,12 +30,12 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
/** /**
* Cover cache where persistent covers are stored. * Cover cache where persistent covers are stored.
*/ */
@Inject lateinit var coverCache: CoverCache val coverCache: CoverCache by injectLazy()
/** /**
* Source manager. * Source manager.
*/ */
@Inject lateinit var sourceManager: SourceManager val sourceManager: SourceManager by injectLazy()
/** /**
* Base network loader. * Base network loader.
@ -47,17 +47,13 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
* LRU cache whose key is the thumbnail url of the manga, and the value contains the request url * LRU cache whose key is the thumbnail url of the manga, and the value contains the request url
* and the file where it should be stored in case the manga is a favorite. * and the file where it should be stored in case the manga is a favorite.
*/ */
private val modelCache = ModelCache<String, Pair<GlideUrl, File>>(100) private val lruCache = LruCache<String, Pair<GlideUrl, File>>(100)
/** /**
* Map where request headers are stored for a source. * Map where request headers are stored for a source.
*/ */
private val cachedHeaders = hashMapOf<Int, LazyHeaders>() private val cachedHeaders = hashMapOf<Int, LazyHeaders>()
init {
App.get(context).component.inject(this)
}
/** /**
* Factory class for creating [MangaModelLoader] instances. * Factory class for creating [MangaModelLoader] instances.
*/ */
@ -79,6 +75,7 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
override fun getResourceFetcher(manga: Manga, override fun getResourceFetcher(manga: Manga,
width: Int, width: Int,
height: Int): DataFetcher<InputStream>? { height: Int): DataFetcher<InputStream>? {
// Check thumbnail is not null or empty // Check thumbnail is not null or empty
val url = manga.thumbnail_url val url = manga.thumbnail_url
if (url.isNullOrEmpty()) { if (url.isNullOrEmpty()) {
@ -87,9 +84,9 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
// Obtain the request url and the file for this url from the LRU cache, or calculate it // Obtain the request url and the file for this url from the LRU cache, or calculate it
// and add them to the cache. // and add them to the cache.
val (glideUrl, file) = modelCache.get(url, width, height) ?: val (glideUrl, file) = lruCache.get(url) ?:
Pair(GlideUrl(url, getHeaders(manga)), coverCache.getCoverFile(url)).apply { Pair(GlideUrl(url, getHeaders(manga)), coverCache.getCoverFile(url!!)).apply {
modelCache.put(url, width, height, this) lruCache.put(url, this)
} }
// Get the network fetcher for this request url. // Get the network fetcher for this request url.
@ -108,7 +105,8 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return LazyHeaders.DEFAULT val source = sourceManager.get(manga.source) as? OnlineSource ?: return LazyHeaders.DEFAULT
return cachedHeaders.getOrPut(manga.source) { return cachedHeaders.getOrPut(manga.source) {
LazyHeaders.Builder().apply { LazyHeaders.Builder().apply {
setHeader("User-Agent", null as? String) val nullStr: String? = null
setHeader("User-Agent", nullStr)
for ((key, value) in source.headers.toMultimap()) { for ((key, value) in source.headers.toMultimap()) {
addHeader(key, value[0]) addHeader(key, value[0])
} }

View File

@ -1,82 +0,0 @@
package eu.kanade.tachiyomi.data.library
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.alarmManager
/**
* This class is used to update the library by firing an alarm after a specified time.
* It has a receiver reacting to system's boot and the intent fired by this alarm.
* See [onReceive] for more information.
*/
class LibraryUpdateAlarm : BroadcastReceiver() {
companion object {
const val LIBRARY_UPDATE_ACTION = "eu.kanade.UPDATE_LIBRARY"
/**
* Sets the alarm to run the intent that updates the library.
* @param context the application context.
* @param intervalInHours the time in hours when it will be executed. Defaults to the
* value stored in preferences.
*/
@JvmStatic
@JvmOverloads
fun startAlarm(context: Context,
intervalInHours: Int = PreferencesHelper.getLibraryUpdateInterval(context)) {
// Stop previous running alarms if needed, and do not restart it if the interval is 0.
stopAlarm(context)
if (intervalInHours == 0)
return
// Get the time the alarm should fire the event to update.
val intervalInMillis = intervalInHours * 60 * 60 * 1000
val nextRun = SystemClock.elapsedRealtime() + intervalInMillis
// Start the alarm.
val pendingIntent = getPendingIntent(context)
context.alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
nextRun, intervalInMillis.toLong(), pendingIntent)
}
/**
* Stops the alarm if it's running.
* @param context the application context.
*/
fun stopAlarm(context: Context) {
val pendingIntent = getPendingIntent(context)
context.alarmManager.cancel(pendingIntent)
}
/**
* Get the intent the alarm should run when it's fired.
* @param context the application context.
* @return the intent that will run when the alarm is fired.
*/
private fun getPendingIntent(context: Context): PendingIntent {
val intent = Intent(context, LibraryUpdateAlarm::class.java)
intent.action = LIBRARY_UPDATE_ACTION
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
}
/**
* Handle the intents received by this [BroadcastReceiver].
* @param context the application context.
* @param intent the intent to process.
*/
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
// Start the alarm when the system is booted.
Intent.ACTION_BOOT_COMPLETED -> startAlarm(context)
// Update the library when the alarm fires an event.
LIBRARY_UPDATE_ACTION -> LibraryUpdateService.start(context)
}
}
}

View File

@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.data.library
import com.evernote.android.job.Job
import com.evernote.android.job.JobManager
import com.evernote.android.job.JobRequest
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class LibraryUpdateJob : Job() {
override fun onRunJob(params: Params): Result {
LibraryUpdateService.start(context)
return Job.Result.SUCCESS
}
companion object {
const val TAG = "LibraryUpdate"
fun setupTask(prefInterval: Int? = null) {
val preferences = Injekt.get<PreferencesHelper>()
val interval = prefInterval ?: preferences.libraryUpdateInterval().getOrDefault()
if (interval > 0) {
val restrictions = preferences.libraryUpdateRestriction()
val acRestriction = "ac" in restrictions
val wifiRestriction = if ("wifi" in restrictions)
JobRequest.NetworkType.UNMETERED
else
JobRequest.NetworkType.CONNECTED
JobRequest.Builder(TAG)
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
.setRequiredNetworkType(wifiRestriction)
.setRequiresCharging(acRestriction)
.setRequirementsEnforced(true)
.setPersisted(true)
.setUpdateCurrent(true)
.build()
.schedule()
}
}
fun cancelTask() {
JobManager.instance().cancelAllForTag(TAG)
}
}
}

View File

@ -5,18 +5,18 @@ import android.app.Service
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.Constants import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.SourceManager import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.online.OnlineSource import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
@ -24,9 +24,9 @@ import eu.kanade.tachiyomi.util.*
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
/** /**
* This class will take care of updating the chapters of the manga from the library. It can be * This class will take care of updating the chapters of the manga from the library. It can be
@ -41,17 +41,17 @@ class LibraryUpdateService : Service() {
/** /**
* Database helper. * Database helper.
*/ */
@Inject lateinit var db: DatabaseHelper val db: DatabaseHelper by injectLazy()
/** /**
* Source manager. * Source manager.
*/ */
@Inject lateinit var sourceManager: SourceManager val sourceManager: SourceManager by injectLazy()
/** /**
* Preferences. * Preferences.
*/ */
@Inject lateinit var preferences: PreferencesHelper val preferences: PreferencesHelper by injectLazy()
/** /**
* Wake lock that will be held until the service is destroyed. * Wake lock that will be held until the service is destroyed.
@ -69,18 +69,22 @@ class LibraryUpdateService : Service() {
private val notificationId: Int private val notificationId: Int
get() = Constants.NOTIFICATION_LIBRARY_ID get() = Constants.NOTIFICATION_LIBRARY_ID
private val notificationBitmap by lazy {
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
companion object { companion object {
/**
* Key for manual library update.
*/
const val UPDATE_IS_MANUAL = "is_manual"
/** /**
* Key for category to update. * Key for category to update.
*/ */
const val UPDATE_CATEGORY = "category" const val UPDATE_CATEGORY = "category"
/**
* Key for updating the details instead of the chapters.
*/
const val UPDATE_DETAILS = "details"
/** /**
* Returns the status of the service. * Returns the status of the service.
* *
@ -96,13 +100,13 @@ class LibraryUpdateService : Service() {
* running. * running.
* *
* @param context the application context. * @param context the application context.
* @param isManual whether the update has been manually triggered. * @param category a specific category to update, or null for global update.
* @param category a specific category to update, or null for all in the library. * @param details whether to update the details instead of the list of chapters.
*/ */
fun start(context: Context, isManual: Boolean = false, category: Category? = null) { fun start(context: Context, category: Category? = null, details: Boolean = false) {
if (!isRunning(context)) { if (!isRunning(context)) {
val intent = Intent(context, LibraryUpdateService::class.java).apply { val intent = Intent(context, LibraryUpdateService::class.java).apply {
putExtra(UPDATE_IS_MANUAL, isManual) putExtra(UPDATE_DETAILS, details)
category?.let { putExtra(UPDATE_CATEGORY, it.id) } category?.let { putExtra(UPDATE_CATEGORY, it.id) }
} }
context.startService(intent) context.startService(intent)
@ -126,7 +130,6 @@ class LibraryUpdateService : Service() {
*/ */
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
App.get(this).component.inject(this)
createAndAcquireWakeLock() createAndAcquireWakeLock()
} }
@ -136,7 +139,6 @@ class LibraryUpdateService : Service() {
*/ */
override fun onDestroy() { override fun onDestroy() {
subscription?.unsubscribe() subscription?.unsubscribe()
LibraryUpdateAlarm.startAlarm(this)
destroyWakeLock() destroyWakeLock()
super.onDestroy() super.onDestroy()
} }
@ -157,61 +159,32 @@ class LibraryUpdateService : Service() {
* @return the start value of the command. * @return the start value of the command.
*/ */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) return Service.START_NOT_STICKY
// Get connectivity status
val connection = ReactiveNetwork().getConnectivityStatus(this, true)
// Get library update restrictions
val restrictions = preferences.libraryUpdateRestriction()
// Check if users updates library manual
val isManualUpdate = intent?.getBooleanExtra(UPDATE_IS_MANUAL, false) ?: false
// Whether to cancel the update.
var cancelUpdate = false
// Check if device has internet connection
// Check if device has wifi connection if only wifi is enabled
if (connection == ConnectivityStatus.OFFLINE || (!isManualUpdate && "wifi" in restrictions
&& connection != ConnectivityStatus.WIFI_CONNECTED_HAS_INTERNET)) {
if (isManualUpdate) {
toast(R.string.notification_no_connection_title)
}
// Enable library update when connection available
AndroidComponentUtil.toggleComponent(this, SyncOnConnectionAvailable::class.java, true)
cancelUpdate = true
}
if (!isManualUpdate && "ac" in restrictions && !DeviceUtil.isPowerConnected(this)) {
AndroidComponentUtil.toggleComponent(this, SyncOnPowerConnected::class.java, true)
cancelUpdate = true
}
if (cancelUpdate) {
stopSelf(startId)
return Service.START_NOT_STICKY
}
// Stop enabled components.
AndroidComponentUtil.toggleComponent(this, SyncOnConnectionAvailable::class.java, false)
AndroidComponentUtil.toggleComponent(this, SyncOnPowerConnected::class.java, false)
// Unsubscribe from any previous subscription if needed. // Unsubscribe from any previous subscription if needed.
subscription?.unsubscribe() subscription?.unsubscribe()
// Update favorite manga. Destroy service when completed or in case of an error. // Update favorite manga. Destroy service when completed or in case of an error.
subscription = Observable.defer { updateMangaList(getMangaToUpdate(intent)) } subscription = Observable
.defer {
val mangaList = getMangaToUpdate(intent)
// Update either chapter list or manga details.
if (!intent.getBooleanExtra(UPDATE_DETAILS, false))
updateChapterList(mangaList)
else
updateDetails(mangaList)
}
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe({}, .subscribe({
{ }, {
showNotification(getString(R.string.notification_update_error), "") showNotification(getString(R.string.notification_update_error), "")
stopSelf(startId) stopSelf(startId)
}, { }, {
stopSelf(startId) stopSelf(startId)
}) })
return Service.START_STICKY return Service.START_REDELIVER_INTENT
} }
/** /**
@ -220,19 +193,26 @@ class LibraryUpdateService : Service() {
* @param intent the update intent. * @param intent the update intent.
* @return a list of manga to update * @return a list of manga to update
*/ */
fun getMangaToUpdate(intent: Intent?): List<Manga> { fun getMangaToUpdate(intent: Intent): List<Manga> {
val categoryId = intent?.getIntExtra(UPDATE_CATEGORY, -1) ?: -1 val categoryId = intent.getIntExtra(UPDATE_CATEGORY, -1)
var toUpdate = if (categoryId != -1) var listToUpdate = if (categoryId != -1)
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId } db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
else else {
db.getFavoriteMangas().executeAsBlocking() val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map { it.toInt() }
if (categoriesToUpdate.isNotEmpty())
if (preferences.updateOnlyNonCompleted()) { db.getLibraryMangas().executeAsBlocking()
toUpdate = toUpdate.filter { it.status != Manga.COMPLETED } .filter { it.category in categoriesToUpdate }
.distinctBy { it.id }
else
db.getFavoriteMangas().executeAsBlocking().distinctBy { it.id }
} }
return toUpdate if (!intent.getBooleanExtra(UPDATE_DETAILS, false) && preferences.updateOnlyNonCompleted()) {
listToUpdate = listToUpdate.filter { it.status != Manga.COMPLETED }
}
return listToUpdate
} }
/** /**
@ -244,7 +224,7 @@ class LibraryUpdateService : Service() {
* @param mangaToUpdate the list to update * @param mangaToUpdate the list to update
* @return an observable delivering the progress of each update. * @return an observable delivering the progress of each update.
*/ */
fun updateMangaList(mangaToUpdate: List<Manga>): Observable<Manga> { fun updateChapterList(mangaToUpdate: List<Manga>): Observable<Manga> {
// Initialize the variables holding the progress of the updates. // Initialize the variables holding the progress of the updates.
val count = AtomicInteger(0) val count = AtomicInteger(0)
val newUpdates = ArrayList<Manga>() val newUpdates = ArrayList<Manga>()
@ -294,6 +274,43 @@ class LibraryUpdateService : Service() {
.map { syncChaptersWithSource(db, it, manga, source) } .map { syncChaptersWithSource(db, it, manga, source) }
} }
/**
* Method that updates the details of the given list of manga. It's called in a background
* thread, so it's safe to do heavy operations or network calls here.
* For each manga it calls [updateManga] and updates the notification showing the current
* progress.
*
* @param mangaToUpdate the list to update
* @return an observable delivering the progress of each update.
*/
fun updateDetails(mangaToUpdate: List<Manga>): Observable<Manga> {
// Initialize the variables holding the progress of the updates.
val count = AtomicInteger(0)
val cancelIntent = PendingIntent.getBroadcast(this, 0,
Intent(this, CancelUpdateReceiver::class.java), 0)
// Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate)
// Notify manga that will update.
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) }
// Update the details of the manga.
.concatMap { manga ->
val source = sourceManager.get(manga.source) as? OnlineSource
?: return@concatMap Observable.empty<Manga>()
source.fetchMangaDetails(manga)
.doOnNext { networkManga ->
manga.copyFrom(networkManga)
db.insertManga(manga).executeAsBlocking()
}
.onErrorReturn { manga }
}
.doOnCompleted {
cancelNotification()
}
}
/** /**
* Returns the text that will be displayed in the notification when there are new chapters. * Returns the text that will be displayed in the notification when there are new chapters.
* *
@ -302,7 +319,7 @@ class LibraryUpdateService : Service() {
* @return the body of the notification to display. * @return the body of the notification to display.
*/ */
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String { private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
return with(StringBuilder()) { return buildString {
if (updates.isEmpty()) { if (updates.isEmpty()) {
append(getString(R.string.notification_no_new_chapters)) append(getString(R.string.notification_no_new_chapters))
append("\n") append("\n")
@ -310,7 +327,7 @@ class LibraryUpdateService : Service() {
append(getString(R.string.notification_new_chapters)) append(getString(R.string.notification_new_chapters))
for (manga in updates) { for (manga in updates) {
append("\n") append("\n")
append(manga.title) append(manga.title.chop(45))
} }
} }
if (!failedUpdates.isEmpty()) { if (!failedUpdates.isEmpty()) {
@ -318,10 +335,9 @@ class LibraryUpdateService : Service() {
append(getString(R.string.notification_manga_update_failed)) append(getString(R.string.notification_manga_update_failed))
for (manga in failedUpdates) { for (manga in failedUpdates) {
append("\n") append("\n")
append(manga.title) append(manga.title.chop(45))
} }
} }
toString()
} }
} }
@ -350,8 +366,9 @@ class LibraryUpdateService : Service() {
* @param body the body of the notification. * @param body the body of the notification.
*/ */
private fun showNotification(title: String, body: String) { private fun showNotification(title: String, body: String) {
notificationManager.notify(notificationId, notification() { notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img) setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
setContentTitle(title) setContentTitle(title)
setContentText(body) setContentText(body)
}) })
@ -365,8 +382,9 @@ class LibraryUpdateService : Service() {
* @param total the total progress. * @param total the total progress.
*/ */
private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) { private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) {
notificationManager.notify(notificationId, notification() { notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img) setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
setContentTitle(manga.title) setContentTitle(manga.title)
setProgress(total, current, false) setProgress(total, current, false)
setOngoing(true) setOngoing(true)
@ -385,8 +403,9 @@ class LibraryUpdateService : Service() {
val title = getString(R.string.notification_update_completed) val title = getString(R.string.notification_update_completed)
val body = getUpdatedMangasBody(updates, failed) val body = getUpdatedMangasBody(updates, failed)
notificationManager.notify(notificationId, notification() { notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img) setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
setContentTitle(title) setContentTitle(title)
setStyle(NotificationCompat.BigTextStyle().bigText(body)) setStyle(NotificationCompat.BigTextStyle().bigText(body))
setContentIntent(notificationIntent) setContentIntent(notificationIntent)
@ -411,41 +430,6 @@ class LibraryUpdateService : Service() {
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
/**
* Class that triggers the library to update when a connection is available. It receives
* network changes.
*/
class SyncOnConnectionAvailable : BroadcastReceiver() {
/**
* Method called when a network change occurs.
*
* @param context the application context.
* @param intent the intent received.
*/
override fun onReceive(context: Context, intent: Intent) {
if (DeviceUtil.isNetworkConnected(context)) {
AndroidComponentUtil.toggleComponent(context, this.javaClass, false)
start(context)
}
}
}
/**
* Class that triggers the library to update when connected to power.
*/
class SyncOnPowerConnected: BroadcastReceiver() {
/**
* Method called when AC is connected.
*
* @param context the application context.
* @param intent the intent received.
*/
override fun onReceive(context: Context, intent: Intent) {
AndroidComponentUtil.toggleComponent(context, this.javaClass, false)
start(context)
}
}
/** /**
* Class that stops updating the library. * Class that stops updating the library.
*/ */

View File

@ -1,16 +1,21 @@
package eu.kanade.tachiyomi.data.mangasync package eu.kanade.tachiyomi.data.mangasync
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList
class MangaSyncManager(private val context: Context) { class MangaSyncManager(private val context: Context) {
companion object { companion object {
const val MYANIMELIST = 1 const val MYANIMELIST = 1
const val ANILIST = 2
} }
val myAnimeList = MyAnimeList(context, MYANIMELIST) val myAnimeList = MyAnimeList(context, MYANIMELIST)
val aniList = Anilist(context, ANILIST)
// TODO enable anilist
val services = listOf(myAnimeList) val services = listOf(myAnimeList)
fun getService(id: Int) = services.find { it.id == id } fun getService(id: Int) = services.find { it.id == id }

View File

@ -2,23 +2,18 @@ package eu.kanade.tachiyomi.data.mangasync
import android.content.Context import android.content.Context
import android.support.annotation.CallSuper import android.support.annotation.CallSuper
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.database.models.MangaSync import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.network.NetworkHelper import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import javax.inject.Inject import uy.kohesive.injekt.injectLazy
abstract class MangaSyncService(private val context: Context, val id: Int) { abstract class MangaSyncService(private val context: Context, val id: Int) {
@Inject lateinit var preferences: PreferencesHelper val preferences: PreferencesHelper by injectLazy()
@Inject lateinit var networkService: NetworkHelper val networkService: NetworkHelper by injectLazy()
init {
App.get(context).component.inject(this)
}
open val client: OkHttpClient open val client: OkHttpClient
get() = networkService.client get() = networkService.client

View File

@ -4,25 +4,23 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.MangaSync import eu.kanade.tachiyomi.data.database.models.MangaSync
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
import javax.inject.Inject import uy.kohesive.injekt.injectLazy
class UpdateMangaSyncService : Service() { class UpdateMangaSyncService : Service() {
@Inject lateinit var syncManager: MangaSyncManager val syncManager: MangaSyncManager by injectLazy()
@Inject lateinit var db: DatabaseHelper val db: DatabaseHelper by injectLazy()
private lateinit var subscriptions: CompositeSubscription private lateinit var subscriptions: CompositeSubscription
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
App.get(this).component.inject(this)
subscriptions = CompositeSubscription() subscriptions = CompositeSubscription()
} }

View File

@ -0,0 +1,132 @@
package eu.kanade.tachiyomi.data.mangasync.anilist
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
import rx.Completable
import rx.Observable
import timber.log.Timber
class Anilist(private val context: Context, id: Int) : MangaSyncService(context, id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "AniList"
private val interceptor by lazy { AnilistInterceptor(getPassword()) }
private val api by lazy {
AnilistApi.createService(networkService.client.newBuilder()
.addInterceptor(interceptor)
.build())
}
override fun login(username: String, password: String) = login(password)
fun login(authCode: String): Completable {
// Create a new api with the default client to avoid request interceptions.
return AnilistApi.createService(client)
// Request the access token from the API with the authorization code.
.requestAccessToken(authCode)
// Save the token in the interceptor.
.doOnNext { interceptor.setAuth(it) }
// Obtain the authenticated user from the API.
.zipWith(api.getCurrentUser().map { it["id"].toString() })
{ oauth, user -> Pair(user, oauth.refresh_token!!) }
// Save service credentials (username and refresh token).
.doOnNext { saveCredentials(it.first, it.second) }
// Logout on any error.
.doOnError { logout() }
.toCompletable()
}
override fun logout() {
super.logout()
interceptor.setAuth(null)
}
fun search(query: String): Observable<List<MangaSync>> {
return api.search(query, 1)
.flatMap { Observable.from(it) }
.filter { it.type != "Novel" }
.map { it.toMangaSync() }
.toList()
}
fun getList(): Observable<List<MangaSync>> {
return api.getList(getUsername())
.flatMap { Observable.from(it.flatten()) }
.map { it.toMangaSync() }
.toList()
}
override fun add(manga: MangaSync): Observable<MangaSync> {
return api.addManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
manga.score.toInt())
.doOnNext { it.body().close() }
.doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
.doOnError { Timber.e(it, it.message) }
.map { manga }
}
override fun update(manga: MangaSync): Observable<MangaSync> {
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
manga.status = COMPLETED
}
return api.updateManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
manga.score.toInt())
.doOnNext { it.body().close() }
.doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
.doOnError { Timber.e(it, it.message) }
.map { manga }
}
override fun bind(manga: MangaSync): Observable<MangaSync> {
return getList()
.flatMap { userlist ->
manga.sync_id = id
val mangaFromList = userlist.find { it.remote_id == manga.remote_id }
if (mangaFromList != null) {
manga.copyPersonalFrom(mangaFromList)
update(manga)
} else {
// Set default fields if it's not found in the list
manga.score = DEFAULT_SCORE.toFloat()
manga.status = DEFAULT_STATUS
add(manga)
}
}
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
private fun MangaSync.getAnilistStatus() = when (status) {
READING -> "reading"
COMPLETED -> "completed"
ON_HOLD -> "on-hold"
DROPPED -> "dropped"
PLAN_TO_READ -> "plan to read"
else -> throw NotImplementedError("Unknown status")
}
}

View File

@ -0,0 +1,89 @@
package eu.kanade.tachiyomi.data.mangasync.anilist
import android.net.Uri
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALManga
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALUserLists
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
import eu.kanade.tachiyomi.data.network.POST
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import rx.Observable
interface AnilistApi {
companion object {
private const val clientId = "tachiyomi-hrtje"
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val baseUrl = "https://anilist.co/api/"
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
.appendQueryParameter("grant_type", "authorization_code")
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", clientUrl)
.appendQueryParameter("response_type", "code")
.build()
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
fun createService(client: OkHttpClient) = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(AnilistApi::class.java)
}
@FormUrlEncoded
@POST("auth/access_token")
fun requestAccessToken(
@Field("code") code: String,
@Field("grant_type") grant_type: String = "authorization_code",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret,
@Field("redirect_uri") redirect_uri: String = clientUrl)
: Observable<OAuth>
@GET("user")
fun getCurrentUser(): Observable<JsonObject>
@GET("manga/search/{query}")
fun search(@Path("query") query: String, @Query("page") page: Int): Observable<List<ALManga>>
@GET("user/{username}/mangalist")
fun getList(@Path("username") username: String): Observable<ALUserLists>
@FormUrlEncoded
@PUT("mangalist")
fun addManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String,
@Field("score_raw") score_raw: Int)
: Observable<Response<ResponseBody>>
@FormUrlEncoded
@PUT("mangalist")
fun updateManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String,
@Field("score_raw") score_raw: Int)
: Observable<Response<ResponseBody>>
}

View File

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.data.mangasync.anilist
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
import okhttp3.Interceptor
import okhttp3.Response
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
/**
* OAuth object used for authenticated requests.
*
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date.
*/
private var oauth: OAuth? = null
set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
if (refreshToken.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist")
}
// Refresh access token if null or expired.
if (oauth == null || oauth!!.isExpired()) {
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!))
oauth = if (response.isSuccessful) {
Gson().fromJson(response.body().string(), OAuth::class.java)
} else {
response.close()
null
}
}
// Throw on null auth.
if (oauth == null) {
throw Exception("Access token wasn't refreshed")
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build()
return chain.proceed(authRequest)
}
/**
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
refreshToken = oauth?.refresh_token
this.oauth = oauth
}
}

View File

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.mangasync.anilist.model
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
data class ALManga(
val id: Int,
val title_romaji: String,
val type: String,
val total_chapters: Int) {
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
remote_id = this@ALManga.id
title = title_romaji
total_chapters = this@ALManga.total_chapters
}
}

View File

@ -0,0 +1,6 @@
package eu.kanade.tachiyomi.data.mangasync.anilist.model
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
fun flatten() = lists.values.flatten()
}

View File

@ -0,0 +1,29 @@
package eu.kanade.tachiyomi.data.mangasync.anilist.model
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
data class ALUserManga(
val id: Int,
val list_status: String,
val score_raw: Int,
val chapters_read: Int,
val manga: ALManga) {
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
remote_id = manga.id
status = getMangaSyncStatus()
score = score_raw.toFloat()
last_chapter_read = chapters_read
}
fun getMangaSyncStatus() = when (list_status) {
"reading" -> Anilist.READING
"completed" -> Anilist.COMPLETED
"on-hold" -> Anilist.ON_HOLD
"dropped" -> Anilist.DROPPED
"plan to read" -> Anilist.PLAN_TO_READ
else -> throw NotImplementedError("Unknown status")
}
}

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.data.mangasync.anilist.model
data class OAuth(
val access_token: String,
val token_type: String,
val expires: Long,
val expires_in: Long,
val refresh_token: String?) {
fun isExpired() = System.currentTimeMillis() > expires
}

View File

@ -26,7 +26,7 @@ class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(cont
private lateinit var headers: Headers private lateinit var headers: Headers
companion object { companion object {
val BASE_URL = "http://myanimelist.net" val BASE_URL = "https://myanimelist.net"
private val ENTRY_TAG = "entry" private val ENTRY_TAG = "entry"
private val CHAPTER_TAG = "chapter" private val CHAPTER_TAG = "chapter"
@ -97,8 +97,8 @@ class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(cont
.flatMap { Observable.from(it.select("entry")) } .flatMap { Observable.from(it.select("entry")) }
.filter { it.select("type").text() != "Novel" } .filter { it.select("type").text() != "Novel" }
.map { .map {
MangaSync.create(this).apply { MangaSync.create(id).apply {
title = it.selectText("title") title = it.selectText("title")!!
remote_id = it.selectInt("id") remote_id = it.selectInt("id")
total_chapters = it.selectInt("chapters") total_chapters = it.selectInt("chapters")
} }
@ -114,8 +114,8 @@ class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(cont
.map { Jsoup.parse(it.body().string()) } .map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("manga")) } .flatMap { Observable.from(it.select("manga")) }
.map { .map {
MangaSync.create(this).apply { MangaSync.create(id).apply {
title = it.selectText("series_title") title = it.selectText("series_title")!!
remote_id = it.selectInt("series_mangadb_id") remote_id = it.selectInt("series_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters") last_chapter_read = it.selectInt("my_read_chapters")
status = it.selectInt("my_status") status = it.selectInt("my_status")

View File

@ -55,13 +55,12 @@ class CloudflareInterceptor(private val cookies: PersistentCookieStore) : Interc
val js = operation val js = operation
//language=RegExp //language=RegExp
.replace(Regex("""a\.value =(.+?) \+ .+?;"""), "$1") .replace(Regex("""a\.value =(.+?) \+.*"""), "$1")
//language=RegExp //language=RegExp
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "") .replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("\n", "") .replace("\n", "")
// Duktape can only return strings, so the result has to be converted to string first val result = (duktape.evaluate(js) as Double).toInt()
val result = duktape.evaluate("$js.toString()").toInt()
val answer = "${result + domain.length}" val answer = "${result + domain.length}"

View File

@ -5,24 +5,44 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import rx.subscriptions.Subscriptions import rx.Producer
import java.io.IOException import rx.Subscription
import java.util.concurrent.atomic.AtomicBoolean
fun Call.asObservable(): Observable<Response> { fun Call.asObservable(): Observable<Response> {
return Observable.create { subscriber -> return Observable.create { subscriber ->
subscriber.add(Subscriptions.create { cancel() }) // Since Call is a one-shot type, clone it for each new subscriber.
val call = clone()
try { // Wrap the call in a helper which handles both unsubscription and backpressure.
val response = execute() val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
if (!subscriber.isUnsubscribed) { override fun request(n: Long) {
subscriber.onNext(response) if (n == 0L || !compareAndSet(false, true)) return
subscriber.onCompleted()
try {
val response = call.execute()
if (!subscriber.isUnsubscribed) {
subscriber.onNext(response)
subscriber.onCompleted()
}
} catch (error: Exception) {
if (!subscriber.isUnsubscribed) {
subscriber.onError(error)
}
}
} }
} catch (error: IOException) {
if (!subscriber.isUnsubscribed) { override fun unsubscribe() {
subscriber.onError(error) call.cancel()
}
override fun isUnsubscribed(): Boolean {
return call.isCanceled
} }
} }
subscriber.add(requestArbiter)
subscriber.setProducer(requestArbiter)
} }
} }

View File

@ -8,15 +8,18 @@ import eu.kanade.tachiyomi.R
* in the file "keys.xml". By using this class we can define preferences in one place and get them * in the file "keys.xml". By using this class we can define preferences in one place and get them
* referenced here. * referenced here.
*/ */
@Suppress("HasPlatformType")
class PreferenceKeys(context: Context) { class PreferenceKeys(context: Context) {
val theme = context.getString(R.string.pref_theme_key)
val rotation = context.getString(R.string.pref_rotation_type_key) val rotation = context.getString(R.string.pref_rotation_type_key)
val enableTransitions = context.getString(R.string.pref_enable_transitions_key) val enableTransitions = context.getString(R.string.pref_enable_transitions_key)
val showPageNumber = context.getString(R.string.pref_show_page_number_key) val showPageNumber = context.getString(R.string.pref_show_page_number_key)
val hideStatusBar = context.getString(R.string.pref_hide_status_bar_key) val fullscreen = context.getString(R.string.pref_fullscreen_key)
val keepScreenOn = context.getString(R.string.pref_keep_screen_on_key) val keepScreenOn = context.getString(R.string.pref_keep_screen_on_key)
@ -24,6 +27,10 @@ class PreferenceKeys(context: Context) {
val customBrightnessValue = context.getString(R.string.pref_custom_brightness_value_key) val customBrightnessValue = context.getString(R.string.pref_custom_brightness_value_key)
val colorFilter = context.getString(R.string.pref_color_filter_key)
val colorFilterValue = context.getString(R.string.pref_color_filter_value_key)
val defaultViewer = context.getString(R.string.pref_default_viewer_key) val defaultViewer = context.getString(R.string.pref_default_viewer_key)
val imageScaleType = context.getString(R.string.pref_image_scale_type_key) val imageScaleType = context.getString(R.string.pref_image_scale_type_key)
@ -38,8 +45,6 @@ class PreferenceKeys(context: Context) {
val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key) val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key)
val reencodeImage = context.getString(R.string.pref_reencode_key)
val portraitColumns = context.getString(R.string.pref_library_columns_portrait_key) val portraitColumns = context.getString(R.string.pref_library_columns_portrait_key)
val landscapeColumns = context.getString(R.string.pref_library_columns_landscape_key) val landscapeColumns = context.getString(R.string.pref_library_columns_landscape_key)
@ -54,8 +59,6 @@ class PreferenceKeys(context: Context) {
val lastUsedCategory = context.getString(R.string.pref_last_used_category_key) val lastUsedCategory = context.getString(R.string.pref_last_used_category_key)
val seamlessMode = context.getString(R.string.pref_seamless_mode_key)
val catalogueAsList = context.getString(R.string.pref_display_catalogue_as_list) val catalogueAsList = context.getString(R.string.pref_display_catalogue_as_list)
val enabledLanguages = context.getString(R.string.pref_source_languages) val enabledLanguages = context.getString(R.string.pref_source_languages)
@ -66,9 +69,7 @@ class PreferenceKeys(context: Context) {
val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key) val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key)
val removeAfterRead = context.getString(R.string.pref_remove_after_read_key) val removeAfterReadSlots = context.getString(R.string.pref_remove_after_read_slots_key)
val removeAfterReadPrevious = context.getString(R.string.pref_remove_after_read_previous_key)
val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key) val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key)
@ -76,10 +77,16 @@ class PreferenceKeys(context: Context) {
val libraryUpdateRestriction = context.getString(R.string.pref_library_update_restriction_key) val libraryUpdateRestriction = context.getString(R.string.pref_library_update_restriction_key)
val libraryUpdateCategories = context.getString(R.string.pref_library_update_categories_key)
val filterDownloaded = context.getString(R.string.pref_filter_downloaded_key) val filterDownloaded = context.getString(R.string.pref_filter_downloaded_key)
val filterUnread = context.getString(R.string.pref_filter_unread_key) val filterUnread = context.getString(R.string.pref_filter_unread_key)
val automaticUpdates = context.getString(R.string.pref_enable_automatic_updates_key)
val startScreen = context.getString(R.string.pref_start_screen_key)
fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId" fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId"
fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId" fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId"
@ -88,4 +95,6 @@ class PreferenceKeys(context: Context) {
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId" fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
val libraryAsList = context.getString(R.string.pref_display_library_as_list)
} }

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.preference package eu.kanade.tachiyomi.data.preference
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Environment import android.os.Environment
import android.preference.PreferenceManager import android.preference.PreferenceManager
import com.f2prateek.rx.preferences.Preference import com.f2prateek.rx.preferences.Preference
@ -9,51 +10,25 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
import eu.kanade.tachiyomi.data.source.Source import eu.kanade.tachiyomi.data.source.Source
import java.io.File import java.io.File
import java.io.IOException
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!! fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
class PreferencesHelper(private val context: Context) { class PreferencesHelper(context: Context) {
val keys = PreferenceKeys(context) val keys = PreferenceKeys(context)
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val rxPrefs = RxSharedPreferences.create(prefs) private val rxPrefs = RxSharedPreferences.create(prefs)
private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath + private val defaultDownloadsDir = Uri.fromFile(
File.separator + context.getString(R.string.app_name), "downloads") File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
context.getString(R.string.app_name), "downloads"))
init { fun startScreen() = prefs.getInt(keys.startScreen, 1)
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file
try {
File(downloadsDirectory().getOrDefault(), ".nomedia").createNewFile()
} catch (e: IOException) {
/* Ignore */
}
}
companion object { fun clear() = prefs.edit().clear().apply()
fun getLibraryUpdateInterval(context: Context): Int { fun theme() = prefs.getInt(keys.theme, 1)
return PreferenceManager.getDefaultSharedPreferences(context).getInt(
context.getString(R.string.pref_library_update_interval_key), 0)
}
fun getAutomaticUpdateStatus(context: Context): Boolean {
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
context.getString(R.string.pref_enable_automatic_updates), false)
}
@JvmStatic
fun getTheme(context: Context): Int {
return PreferenceManager.getDefaultSharedPreferences(context).getInt(
context.getString(R.string.pref_theme_key), 1)
}
}
fun clear() {
prefs.edit().clear().apply()
}
fun rotation() = rxPrefs.getInteger(keys.rotation, 1) fun rotation() = rxPrefs.getInteger(keys.rotation, 1)
@ -61,13 +36,17 @@ class PreferencesHelper(private val context: Context) {
fun showPageNumber() = rxPrefs.getBoolean(keys.showPageNumber, true) fun showPageNumber() = rxPrefs.getBoolean(keys.showPageNumber, true)
fun hideStatusBar() = rxPrefs.getBoolean(keys.hideStatusBar, true) fun fullscreen() = rxPrefs.getBoolean(keys.fullscreen, true)
fun keepScreenOn() = rxPrefs.getBoolean(keys.keepScreenOn, true) fun keepScreenOn() = rxPrefs.getBoolean(keys.keepScreenOn, true)
fun customBrightness() = rxPrefs.getBoolean(keys.customBrightness, false) fun customBrightness() = rxPrefs.getBoolean(keys.customBrightness, false)
fun customBrightnessValue() = rxPrefs.getFloat(keys.customBrightnessValue, 0f) fun customBrightnessValue() = rxPrefs.getInteger(keys.customBrightnessValue, 0)
fun colorFilter() = rxPrefs.getBoolean(keys.colorFilter, false)
fun colorFilterValue() = rxPrefs.getInteger(keys.colorFilterValue, 0)
fun defaultViewer() = prefs.getInt(keys.defaultViewer, 1) fun defaultViewer() = prefs.getInt(keys.defaultViewer, 1)
@ -83,8 +62,6 @@ class PreferencesHelper(private val context: Context) {
fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false) fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false)
fun reencodeImage() = prefs.getBoolean(keys.reencodeImage, false)
fun portraitColumns() = rxPrefs.getInteger(keys.portraitColumns, 0) fun portraitColumns() = rxPrefs.getInteger(keys.portraitColumns, 0)
fun landscapeColumns() = rxPrefs.getInteger(keys.landscapeColumns, 0) fun landscapeColumns() = rxPrefs.getInteger(keys.landscapeColumns, 0)
@ -101,8 +78,6 @@ class PreferencesHelper(private val context: Context) {
fun lastVersionCode() = rxPrefs.getInteger("last_version_code", 0) fun lastVersionCode() = rxPrefs.getInteger("last_version_code", 0)
fun seamlessMode() = prefs.getBoolean(keys.seamlessMode, true)
fun catalogueAsList() = rxPrefs.getBoolean(keys.catalogueAsList, false) fun catalogueAsList() = rxPrefs.getBoolean(keys.catalogueAsList, false)
fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("EN")) fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("EN"))
@ -129,15 +104,13 @@ class PreferencesHelper(private val context: Context) {
.apply() .apply()
} }
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath) fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1) fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true) fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true)
fun removeAfterRead() = prefs.getBoolean(keys.removeAfterRead, false) fun removeAfterReadSlots() = prefs.getInt(keys.removeAfterReadSlots, -1)
fun removeAfterReadPrevious() = prefs.getBoolean(keys.removeAfterReadPrevious, false)
fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false) fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)
@ -145,8 +118,16 @@ class PreferencesHelper(private val context: Context) {
fun libraryUpdateRestriction() = prefs.getStringSet(keys.libraryUpdateRestriction, emptySet()) fun libraryUpdateRestriction() = prefs.getStringSet(keys.libraryUpdateRestriction, emptySet())
fun libraryUpdateCategories() = rxPrefs.getStringSet(keys.libraryUpdateCategories, emptySet())
fun libraryAsList() = rxPrefs.getBoolean(keys.libraryAsList, false)
fun filterDownloaded() = rxPrefs.getBoolean(keys.filterDownloaded, false) fun filterDownloaded() = rxPrefs.getBoolean(keys.filterDownloaded, false)
fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false) fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false)
fun automaticUpdates() = prefs.getBoolean(keys.automaticUpdates, false)
fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet())
} }

View File

@ -2,7 +2,8 @@ package eu.kanade.tachiyomi.data.source
class Language(val code: String, val lang: String) class Language(val code: String, val lang: String)
val DE = Language("DE", "German")
val EN = Language("EN", "English") val EN = Language("EN", "English")
val RU = Language("RU", "Russian") val RU = Language("RU", "Russian")
fun getLanguages() = listOf(EN, RU) fun getLanguages() = listOf(DE, EN, RU)

View File

@ -47,5 +47,4 @@ interface Source {
* @param page the page. * @param page the page.
*/ */
fun fetchImage(page: Page): Observable<Page> fun fetchImage(page: Page): Observable<Page>
} }

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.source.online.OnlineSource import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.YamlOnlineSource import eu.kanade.tachiyomi.data.source.online.YamlOnlineSource
import eu.kanade.tachiyomi.data.source.online.english.* import eu.kanade.tachiyomi.data.source.online.english.*
import eu.kanade.tachiyomi.data.source.online.german.WieManga
import eu.kanade.tachiyomi.data.source.online.russian.Mangachan import eu.kanade.tachiyomi.data.source.online.russian.Mangachan
import eu.kanade.tachiyomi.data.source.online.russian.Mintmanga import eu.kanade.tachiyomi.data.source.online.russian.Mintmanga
import eu.kanade.tachiyomi.data.source.online.russian.Readmanga import eu.kanade.tachiyomi.data.source.online.russian.Readmanga
@ -17,18 +18,7 @@ import java.io.File
open class SourceManager(private val context: Context) { open class SourceManager(private val context: Context) {
val BATOTO = 1 private val sourcesMap = createSources()
val MANGAHERE = 2
val MANGAFOX = 3
val KISSMANGA = 4
val READMANGA = 5
val MINTMANGA = 6
val MANGACHAN = 7
val READMANGATODAY = 8
val LAST_SOURCE = 8
val sourcesMap = createSources()
open fun get(sourceKey: Int): Source? { open fun get(sourceKey: Int): Source? {
return sourcesMap[sourceKey] return sourcesMap[sourceKey]
@ -36,22 +26,21 @@ open class SourceManager(private val context: Context) {
fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java) fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java)
private fun createSource(id: Int): Source? = when (id) { private fun createOnlineSourceList(): List<Source> = listOf(
BATOTO -> Batoto(context, id) Batoto(1),
KISSMANGA -> Kissmanga(context, id) Mangahere(2),
MANGAHERE -> Mangahere(context, id) Mangafox(3),
MANGAFOX -> Mangafox(context, id) Kissmanga(4),
READMANGA -> Readmanga(context, id) Readmanga(5),
MINTMANGA -> Mintmanga(context, id) Mintmanga(6),
MANGACHAN -> Mangachan(context, id) Mangachan(7),
READMANGATODAY -> Readmangatoday(context, id) Readmangatoday(8),
else -> null Mangasee(9),
} WieManga(10)
)
private fun createSources(): Map<Int, Source> = hashMapOf<Int, Source>().apply { private fun createSources(): Map<Int, Source> = hashMapOf<Int, Source>().apply {
for (i in 1..LAST_SOURCE) { createOnlineSourceList().forEach { put(it.id, it) }
createSource(i)?.let { put(i, it) }
}
val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath + val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath +
File.separator + context.getString(R.string.app_name), "parsers") File.separator + context.getString(R.string.app_name), "parsers")
@ -61,7 +50,7 @@ open class SourceManager(private val context: Context) {
for (file in parsersDir.listFiles().filter { it.extension == "yml" }) { for (file in parsersDir.listFiles().filter { it.extension == "yml" }) {
try { try {
val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) } val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) }
YamlOnlineSource(context, map).let { put(it.id, it) } YamlOnlineSource(map).let { put(it.id, it) }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Error loading source from file. Bad format?") Timber.e("Error loading source from file. Bad format?")
} }

View File

@ -1,18 +0,0 @@
package eu.kanade.tachiyomi.data.source.model;
import java.util.List;
import eu.kanade.tachiyomi.data.database.models.Manga;
public class MangasPage {
public List<Manga> mangas;
public int page;
public String url;
public String nextPageUrl;
public MangasPage(int page) {
this.page = page;
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.source.model
import eu.kanade.tachiyomi.data.database.models.Manga
class MangasPage(val page: Int) {
val mangas: MutableList<Manga> = mutableListOf()
lateinit var url: String
var nextPageUrl: String? = null
}

View File

@ -1,105 +0,0 @@
package eu.kanade.tachiyomi.data.source.model;
import java.util.List;
import eu.kanade.tachiyomi.data.database.models.Chapter;
import eu.kanade.tachiyomi.data.network.ProgressListener;
import rx.subjects.PublishSubject;
public class Page implements ProgressListener {
private int pageNumber;
private String url;
private String imageUrl;
private transient Chapter chapter;
private transient String imagePath;
private transient volatile int status;
private transient volatile int progress;
private transient PublishSubject<Integer> statusSubject;
public static final int QUEUE = 0;
public static final int LOAD_PAGE = 1;
public static final int DOWNLOAD_IMAGE = 2;
public static final int READY = 3;
public static final int ERROR = 4;
public Page(int pageNumber, String url) {
this(pageNumber, url, null, null);
}
public Page(int pageNumber, String url, String imageUrl) {
this(pageNumber, url, imageUrl, null);
}
public Page(int pageNumber, String url, String imageUrl, String imagePath) {
this.pageNumber = pageNumber;
this.url = url;
this.imageUrl = imageUrl;
this.imagePath = imagePath;
}
public int getPageNumber() {
return pageNumber;
}
public String getUrl() {
return url;
}
public String getImageUrl() {
return imageUrl;
}
public void setImageUrl(String imageUrl) {
this.imageUrl = imageUrl;
}
public String getImagePath() {
return imagePath;
}
public void setImagePath(String imagePath) {
this.imagePath = imagePath;
}
public int getStatus() {
return status;
}
public void setStatus(int status) {
this.status = status;
if (statusSubject != null)
statusSubject.onNext(status);
}
public int getProgress() {
return progress;
}
public void setProgress(int value) {
progress = value;
}
@Override
public void update(long bytesRead, long contentLength, boolean done) {
progress = (int) ((100 * bytesRead) / contentLength);
}
public void setStatusSubject(PublishSubject<Integer> subject) {
this.statusSubject = subject;
}
public Chapter getChapter() {
return chapter;
}
public void setChapter(Chapter chapter) {
this.chapter = chapter;
}
public boolean isLastPage() {
List<Page> chapterPages = chapter.getPages();
return chapterPages.size() -1 == pageNumber;
}
}

View File

@ -0,0 +1,47 @@
package eu.kanade.tachiyomi.data.source.model
import android.net.Uri
import eu.kanade.tachiyomi.data.network.ProgressListener
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import rx.subjects.Subject
class Page(
val index: Int,
val url: String = "",
var imageUrl: String? = null,
@Transient var uri: Uri? = null
) : ProgressListener {
val number: Int
get() = index + 1
@Transient lateinit var chapter: ReaderChapter
@Transient @Volatile var status: Int = 0
set(value) {
field = value
statusSubject?.onNext(value)
}
@Transient @Volatile var progress: Int = 0
@Transient private var statusSubject: Subject<Int, Int>? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = (100 * bytesRead / contentLength).toInt()
}
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
companion object {
const val QUEUE = 0
const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
}
}

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.data.source.online package eu.kanade.tachiyomi.data.source.online
import android.content.Context import android.net.Uri
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
@ -14,31 +13,33 @@ import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.Source import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.source.model.MangasPage import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import okhttp3.* import eu.kanade.tachiyomi.util.UrlUtil
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable import rx.Observable
import javax.inject.Inject import uy.kohesive.injekt.injectLazy
/** /**
* A simple implementation for sources from a website. * A simple implementation for sources from a website.
*
* @param context the application context.
*/ */
abstract class OnlineSource(context: Context) : Source { abstract class OnlineSource() : Source {
/** /**
* Network service. * Network service.
*/ */
@Inject lateinit var network: NetworkHelper val network: NetworkHelper by injectLazy()
/** /**
* Chapter cache. * Chapter cache.
*/ */
@Inject lateinit var chapterCache: ChapterCache val chapterCache: ChapterCache by injectLazy()
/** /**
* Preferences helper. * Preferences helper.
*/ */
@Inject lateinit var preferences: PreferencesHelper val preferences: PreferencesHelper by injectLazy()
/** /**
* Base url of the website without the trailing slash, like: http://mysite.com * Base url of the website without the trailing slash, like: http://mysite.com
@ -50,22 +51,27 @@ abstract class OnlineSource(context: Context) : Source {
*/ */
abstract val lang: Language abstract val lang: Language
/**
* Whether the source has support for latest updates.
*/
abstract val supportsLatest : Boolean
/** /**
* Headers used for requests. * Headers used for requests.
*/ */
val headers by lazy { headersBuilder().build() } val headers by lazy { headersBuilder().build() }
/**
* Genre filters.
*/
val filters by lazy { getFilterList() }
/** /**
* Default network client for doing requests. * Default network client for doing requests.
*/ */
open val client: OkHttpClient open val client: OkHttpClient
get() = network.client get() = network.client
init {
// Inject dependencies.
App.get(context).component.inject(this)
}
/** /**
* Headers builder for requests. Implementations can override this method for custom headers. * Headers builder for requests. Implementations can override this method for custom headers.
*/ */
@ -89,10 +95,8 @@ abstract class OnlineSource(context: Context) : Source {
.newCall(popularMangaRequest(page)) .newCall(popularMangaRequest(page))
.asObservable() .asObservable()
.map { response -> .map { response ->
page.apply { popularMangaParse(response, page)
mangas = mutableListOf<Manga>() page
popularMangaParse(response, this)
}
} }
/** /**
@ -130,14 +134,12 @@ abstract class OnlineSource(context: Context) : Source {
* the current page and the next page url. * the current page and the next page url.
* @param query the search query. * @param query the search query.
*/ */
open fun fetchSearchManga(page: MangasPage, query: String): Observable<MangasPage> = client open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client
.newCall(searchMangaRequest(page, query)) .newCall(searchMangaRequest(page, query, filters))
.asObservable() .asObservable()
.map { response -> .map { response ->
page.apply { searchMangaParse(response, page, query, filters)
mangas = mutableListOf<Manga>() page
searchMangaParse(response, this, query)
}
} }
/** /**
@ -147,9 +149,9 @@ abstract class OnlineSource(context: Context) : Source {
* @param page the page object. * @param page the page object.
* @param query the search query. * @param query the search query.
*/ */
open protected fun searchMangaRequest(page: MangasPage, query: String): Request { open protected fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) { if (page.page == 1) {
page.url = searchMangaInitialUrl(query) page.url = searchMangaInitialUrl(query, filters)
} }
return GET(page.url, headers) return GET(page.url, headers)
} }
@ -159,7 +161,7 @@ abstract class OnlineSource(context: Context) : Source {
* *
* @param query the search query. * @param query the search query.
*/ */
abstract protected fun searchMangaInitialUrl(query: String): String abstract protected fun searchMangaInitialUrl(query: String, filters: List<Filter>): String
/** /**
* Parse the response from the site. It should add a list of manga and the absolute url to the * Parse the response from the site. It should add a list of manga and the absolute url to the
@ -169,7 +171,38 @@ abstract class OnlineSource(context: Context) : Source {
* @param page the page object to be filled. * @param page the page object to be filled.
* @param query the search query. * @param query the search query.
*/ */
abstract protected fun searchMangaParse(response: Response, page: MangasPage, query: String) abstract protected fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>)
/**
* Returns an observable containing a page with a list of latest manga.
*/
open fun fetchLatestUpdates(page: MangasPage): Observable<MangasPage> = client
.newCall(latestUpdatesRequest(page))
.asObservable()
.map { response ->
latestUpdatesParse(response, page)
page
}
/**
* Returns the request for latest manga given the page.
*/
open protected fun latestUpdatesRequest(page: MangasPage): Request {
if (page.page == 1) {
page.url = latestUpdatesInitialUrl()
}
return GET(page.url, headers)
}
/**
* Returns the absolute url of the first page to latest manga.
*/
abstract protected fun latestUpdatesInitialUrl(): String
/**
* Same as [popularMangaParse], but for latest manga.
*/
abstract protected fun latestUpdatesParse(response: Response, page: MangasPage)
/** /**
* Returns an observable with the updated details for a manga. Normally it's not needed to * Returns an observable with the updated details for a manga. Normally it's not needed to
@ -193,7 +226,7 @@ abstract class OnlineSource(context: Context) : Source {
* *
* @param manga the manga to be updated. * @param manga the manga to be updated.
*/ */
open protected fun mangaDetailsRequest(manga: Manga): Request { open fun mangaDetailsRequest(manga: Manga): Request {
return GET(baseUrl + manga.url, headers) return GET(baseUrl + manga.url, headers)
} }
@ -363,7 +396,7 @@ abstract class OnlineSource(context: Context) : Source {
* @param page the chapter whose page list has to be fetched * @param page the chapter whose page list has to be fetched
*/ */
open protected fun imageRequest(page: Page): Request { open protected fun imageRequest(page: Page): Request {
return GET(page.imageUrl, headers) return GET(page.imageUrl!!, headers)
} }
/** /**
@ -373,20 +406,18 @@ abstract class OnlineSource(context: Context) : Source {
* @param page the page. * @param page the page.
*/ */
fun getCachedImage(page: Page): Observable<Page> { fun getCachedImage(page: Page): Observable<Page> {
val pageObservable = Observable.just(page) val imageUrl = page.imageUrl ?: return Observable.just(page)
if (page.imageUrl.isNullOrEmpty())
return pageObservable
return pageObservable return Observable.just(page)
.flatMap { .flatMap {
if (!chapterCache.isImageInCache(page.imageUrl)) { if (!chapterCache.isImageInCache(imageUrl)) {
cacheImage(page) cacheImage(page)
} else { } else {
Observable.just(page) Observable.just(page)
} }
} }
.doOnNext { .doOnNext {
page.imagePath = chapterCache.getImagePath(page.imageUrl) page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
page.status = Page.READY page.status = Page.READY
} }
.doOnError { page.status = Page.ERROR } .doOnError { page.status = Page.ERROR }
@ -401,32 +432,13 @@ abstract class OnlineSource(context: Context) : Source {
private fun cacheImage(page: Page): Observable<Page> { private fun cacheImage(page: Page): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE page.status = Page.DOWNLOAD_IMAGE
return imageResponse(page) return imageResponse(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl, it, preferences.reencodeImage()) } .doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page } .map { page }
} }
// Utility methods // Utility methods
/**
* Returns an absolute url from a href.
*
* Ex:
* href="http://example.com/foo" url="http://example.com" -> http://example.com/foo
* href="/mypath" url="http://example.com/foo" -> http://example.com/mypath
* href="bar" url="http://example.com/foo" -> http://example.com/bar
* href="bar" url="http://example.com/foo/" -> http://example.com/foo/bar
*
* @param href the href attribute from the html.
* @param url the requested url.
*/
fun getAbsoluteUrl(href: String, url: HttpUrl) = when {
href.startsWith("http://") || href.startsWith("https://") -> href
href.startsWith("/") -> url.newBuilder().encodedPath("/").fragment(null).query(null)
.toString() + href.substring(1)
else -> url.toString().substringBeforeLast('/') + "/$href"
}
fun fetchAllImageUrlsFromPageList(pages: List<Page>) = Observable.from(pages) fun fetchAllImageUrlsFromPageList(pages: List<Page>) = Observable.from(pages)
.filter { !it.imageUrl.isNullOrEmpty() } .filter { !it.imageUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingImageUrlsFromPageList(pages)) .mergeWith(fetchRemainingImageUrlsFromPageList(pages))
@ -441,9 +453,27 @@ abstract class OnlineSource(context: Context) : Source {
} }
} }
// Overridable method to allow custom parsing. fun Chapter.setUrlWithoutDomain(url: String) {
open fun parseChapterNumber(chapter: Chapter) { this.url = UrlUtil.getPath(url)
}
fun Manga.setUrlWithoutDomain(url: String) {
this.url = UrlUtil.getPath(url)
}
/**
* Called before inserting a new chapter into database. Use it if you need to override chapter
* fields, like the title or the chapter number. Do not change anything to [manga].
*
* @param chapter the chapter to be added.
* @param manga the manga of the chapter.
*/
open fun prepareNewChapter(chapter: Chapter, manga: Manga) {
} }
data class Filter(val id: String, val name: String)
open fun getFilterList(): List<Filter> = emptyList()
} }

View File

@ -1,21 +1,18 @@
package eu.kanade.tachiyomi.data.source.online package eu.kanade.tachiyomi.data.source.online
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.model.MangasPage import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
/** /**
* A simple implementation for sources from a website using Jsoup, an HTML parser. * A simple implementation for sources from a website using Jsoup, an HTML parser.
*
* @param context the application context.
*/ */
abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) { abstract class ParsedOnlineSource() : OnlineSource() {
/** /**
* Parse the response from the site and fills [page]. * Parse the response from the site and fills [page].
@ -24,19 +21,16 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param page the page object to be filled. * @param page the page object to be filled.
*/ */
override fun popularMangaParse(response: Response, page: MangasPage) { override fun popularMangaParse(response: Response, page: MangasPage) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
for (element in document.select(popularMangaSelector())) { for (element in document.select(popularMangaSelector())) {
Manga().apply { Manga.create(id).apply {
source = this@ParsedOnlineSource.id
popularMangaFromElement(element, this) popularMangaFromElement(element, this)
page.mangas.add(this) page.mangas.add(this)
} }
} }
popularMangaNextPageSelector()?.let { selector -> popularMangaNextPageSelector()?.let { selector ->
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let { page.nextPageUrl = document.select(selector).first()?.absUrl("href")
getAbsoluteUrl(it, response.request().url())
}
} }
} }
@ -67,20 +61,17 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param page the page object to be filled. * @param page the page object to be filled.
* @param query the search query. * @param query the search query.
*/ */
override fun searchMangaParse(response: Response, page: MangasPage, query: String) { override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
for (element in document.select(searchMangaSelector())) { for (element in document.select(searchMangaSelector())) {
Manga().apply { Manga.create(id).apply {
source = this@ParsedOnlineSource.id
searchMangaFromElement(element, this) searchMangaFromElement(element, this)
page.mangas.add(this) page.mangas.add(this)
} }
} }
searchMangaNextPageSelector()?.let { selector -> searchMangaNextPageSelector()?.let { selector ->
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let { page.nextPageUrl = document.select(selector).first()?.absUrl("href")
getAbsoluteUrl(it, response.request().url())
}
} }
} }
@ -104,6 +95,38 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
*/ */
abstract protected fun searchMangaNextPageSelector(): String? abstract protected fun searchMangaNextPageSelector(): String?
/**
* Parse the response from the site for latest updates and fills [page].
*/
override fun latestUpdatesParse(response: Response, page: MangasPage) {
val document = response.asJsoup()
for (element in document.select(latestUpdatesSelector())) {
Manga.create(id).apply {
latestUpdatesFromElement(element, this)
page.mangas.add(this)
}
}
latestUpdatesNextPageSelector()?.let { selector ->
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
}
}
/**
* Returns the Jsoup selector similar to [popularMangaSelector], but for latest updates.
*/
abstract protected fun latestUpdatesSelector(): String
/**
* Fills [manga] with the given [element]. For latest updates.
*/
abstract protected fun latestUpdatesFromElement(element: Element, manga: Manga)
/**
* Returns the Jsoup selector that returns the <a> tag, like [popularMangaNextPageSelector].
*/
abstract protected fun latestUpdatesNextPageSelector(): String?
/** /**
* Parse the response from the site and fills the details of [manga]. * Parse the response from the site and fills the details of [manga].
* *
@ -111,7 +134,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param manga the manga to fill. * @param manga the manga to fill.
*/ */
override fun mangaDetailsParse(response: Response, manga: Manga) { override fun mangaDetailsParse(response: Response, manga: Manga) {
mangaDetailsParse(Jsoup.parse(response.body().string()), manga) mangaDetailsParse(response.asJsoup(), manga)
} }
/** /**
@ -129,7 +152,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param chapters the list of chapters to fill. * @param chapters the list of chapters to fill.
*/ */
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) { override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
for (element in document.select(chapterListSelector())) { for (element in document.select(chapterListSelector())) {
Chapter.create().apply { Chapter.create().apply {
@ -159,7 +182,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param pages the list of pages to fill. * @param pages the list of pages to fill.
*/ */
override fun pageListParse(response: Response, pages: MutableList<Page>) { override fun pageListParse(response: Response, pages: MutableList<Page>) {
pageListParse(Jsoup.parse(response.body().string()), pages) pageListParse(response.asJsoup(), pages)
} }
/** /**
@ -176,7 +199,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun imageUrlParse(response: Response): String { override fun imageUrlParse(response: Response): String {
return imageUrlParse(Jsoup.parse(response.body().string())) return imageUrlParse(response.asJsoup())
} }
/** /**
@ -185,5 +208,4 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
* @param document the parsed document. * @param document the parsed document.
*/ */
abstract protected fun imageUrlParse(document: Document): String abstract protected fun imageUrlParse(document: Document): String
} }

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.source.online package eu.kanade.tachiyomi.data.source.online
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET import eu.kanade.tachiyomi.data.network.GET
@ -8,6 +7,8 @@ import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.source.getLanguages import eu.kanade.tachiyomi.data.source.getLanguages
import eu.kanade.tachiyomi.data.source.model.MangasPage import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.attrOrText
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.Jsoup import org.jsoup.Jsoup
@ -15,7 +16,7 @@ import org.jsoup.nodes.Element
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(context) { class YamlOnlineSource(mappings: Map<*, *>) : OnlineSource() {
val map = YamlSourceNode(mappings) val map = YamlSourceNode(mappings)
@ -30,6 +31,8 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
getLanguages().find { code == it.code }!! getLanguages().find { code == it.code }!!
} }
override val supportsLatest = map.latestupdates != null
override val client = when(map.client) { override val client = when(map.client) {
"cloudflare" -> network.cloudflareClient "cloudflare" -> network.cloudflareClient
else -> network.client else -> network.client
@ -52,26 +55,23 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
override fun popularMangaInitialUrl() = map.popular.url override fun popularMangaInitialUrl() = map.popular.url
override fun popularMangaParse(response: Response, page: MangasPage) { override fun popularMangaParse(response: Response, page: MangasPage) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
for (element in document.select(map.popular.manga_css)) { for (element in document.select(map.popular.manga_css)) {
Manga().apply { Manga.create(id).apply {
source = this@YamlOnlineSource.id
title = element.text() title = element.text()
setUrl(element.attr("href")) setUrlWithoutDomain(element.attr("href"))
page.mangas.add(this) page.mangas.add(this)
} }
} }
map.popular.next_url_css?.let { selector -> map.popular.next_url_css?.let { selector ->
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let { page.nextPageUrl = document.select(selector).first()?.absUrl("href")
getAbsoluteUrl(it, response.request().url())
}
} }
} }
override fun searchMangaRequest(page: MangasPage, query: String): Request { override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) { if (page.page == 1) {
page.url = searchMangaInitialUrl(query) page.url = searchMangaInitialUrl(query, filters)
} }
return when (map.search.method?.toLowerCase()) { return when (map.search.method?.toLowerCase()) {
"post" -> POST(page.url, headers, map.search.createForm()) "post" -> POST(page.url, headers, map.search.createForm())
@ -79,28 +79,52 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
} }
} }
override fun searchMangaInitialUrl(query: String) = map.search.url.replace("\$query", query) override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = map.search.url.replace("\$query", query)
override fun searchMangaParse(response: Response, page: MangasPage, query: String) { override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
for (element in document.select(map.search.manga_css)) { for (element in document.select(map.search.manga_css)) {
Manga().apply { Manga.create(id).apply {
source = this@YamlOnlineSource.id
title = element.text() title = element.text()
setUrl(element.attr("href")) setUrlWithoutDomain(element.attr("href"))
page.mangas.add(this) page.mangas.add(this)
} }
} }
map.search.next_url_css?.let { selector -> map.search.next_url_css?.let { selector ->
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let { page.nextPageUrl = document.select(selector).first()?.absUrl("href")
getAbsoluteUrl(it, response.request().url()) }
}
override fun latestUpdatesRequest(page: MangasPage): Request {
if (page.page == 1) {
page.url = latestUpdatesInitialUrl()
}
return when (map.latestupdates!!.method?.toLowerCase()) {
"post" -> POST(page.url, headers, map.latestupdates.createForm())
else -> GET(page.url, headers)
}
}
override fun latestUpdatesInitialUrl() = map.latestupdates!!.url
override fun latestUpdatesParse(response: Response, page: MangasPage) {
val document = response.asJsoup()
for (element in document.select(map.latestupdates!!.manga_css)) {
Manga.create(id).apply {
title = element.text()
setUrlWithoutDomain(element.attr("href"))
page.mangas.add(this)
} }
} }
map.latestupdates.next_url_css?.let { selector ->
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
}
} }
override fun mangaDetailsParse(response: Response, manga: Manga) { override fun mangaDetailsParse(response: Response, manga: Manga) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
with(map.manga) { with(map.manga) {
val pool = parts.get(document) val pool = parts.get(document)
@ -114,7 +138,7 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
} }
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) { override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
with(map.chapters) { with(map.chapters) {
val pool = emptyMap<String, Element>() val pool = emptyMap<String, Element>()
val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH) val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH)
@ -123,7 +147,7 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
val chapter = Chapter.create() val chapter = Chapter.create()
element.select(title).first().let { element.select(title).first().let {
chapter.name = it.text() chapter.name = it.text()
chapter.setUrl(it.attr("href")) chapter.setUrlWithoutDomain(it.attr("href"))
} }
val dateElement = element.select(date?.select).first() val dateElement = element.select(date?.select).first()
chapter.date_upload = date?.getDate(dateElement, pool, dateFormat)?.time ?: 0 chapter.date_upload = date?.getDate(dateElement, pool, dateFormat)?.time ?: 0
@ -133,33 +157,59 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
} }
override fun pageListParse(response: Response, pages: MutableList<Page>) { override fun pageListParse(response: Response, pages: MutableList<Page>) {
val document = Jsoup.parse(response.body().string()) val body = response.body().string()
val url = response.request().url().toString()
// TODO lazy initialization in Kotlin 1.1
val document = Jsoup.parse(body, url)
with(map.pages) { with(map.pages) {
val url = response.request().url().toString() // Capture a list of values where page urls will be resolved.
pages_css?.let { val capturedPages = if (pages_regex != null)
for (element in document.select(it)) { pages_regex!!.toRegex().findAll(body).map { it.value }.toList()
val value = element.attr(pages_attr) else if (pages_css != null)
val pageUrl = replace?.let { url.replace(it.toRegex(), replacement!!.replace("\$value", value)) } ?: value document.select(pages_css).map { it.attrOrText(pages_attr!!) }
pages.add(Page(pages.size, pageUrl)) else
} null
// For each captured value, obtain the url and create a new page.
capturedPages?.forEach { value ->
// If the captured value isn't an url, we have to use replaces with the chapter url.
val pageUrl = if (replace != null && replacement != null)
url.replace(replace!!.toRegex(), replacement!!.replace("\$value", value))
else
value
pages.add(Page(pages.size, pageUrl))
} }
for ((i, element) in document.select(image_css).withIndex()) { // Capture a list of images.
pages.getOrNull(i)?.imageUrl = element.attr(image_attr).let { val capturedImages = if (image_regex != null)
getAbsoluteUrl(it, response.request().url()) image_regex!!.toRegex().findAll(body).map { it.groups[1]?.value }.toList()
} else if (image_css != null)
document.select(image_css).map { it.absUrl(image_attr) }
else
null
// Assign the image url to each page
capturedImages?.forEachIndexed { i, url ->
val page = pages.getOrElse(i) { Page(i, "").apply { pages.add(this) } }
page.imageUrl = url
} }
} }
} }
override fun imageUrlParse(response: Response): String { override fun imageUrlParse(response: Response): String {
val document = Jsoup.parse(response.body().string()) val body = response.body().string()
return with(map.pages) { val url = response.request().url().toString()
document.select(image_css).first().attr(image_attr).let {
getAbsoluteUrl(it, response.request().url()) with(map.pages) {
} return if (image_regex != null)
image_regex!!.toRegex().find(body)!!.groups[1]!!.value
else if (image_css != null)
Jsoup.parse(body, url).select(image_css).first().absUrl(image_attr)
else
throw Exception("image_regex and image_css are null")
} }
} }
} }

View File

@ -30,6 +30,8 @@ class YamlSourceNode(uncheckedMap: Map<*, *>) {
val popular = PopularNode(toMap(map["popular"])!!) val popular = PopularNode(toMap(map["popular"])!!)
val latestupdates = toMap(map["latest_updates"])?.let { LatestUpdatesNode(it) }
val search = SearchNode(toMap(map["search"])!!) val search = SearchNode(toMap(map["search"])!!)
val manga = MangaNode(toMap(map["manga"])!!) val manga = MangaNode(toMap(map["manga"])!!)
@ -73,6 +75,17 @@ class PopularNode(override val map: Map<String, Any?>): RequestableNode {
} }
class LatestUpdatesNode(override val map: Map<String, Any?>): RequestableNode {
val manga_css: String by map
val next_url_css: String?
get() = map["next_url_css"] as? String
}
class SearchNode(override val map: Map<String, Any?>): RequestableNode { class SearchNode(override val map: Map<String, Any?>): RequestableNode {
val manga_css: String by map val manga_css: String by map
@ -194,6 +207,9 @@ class DateNode(private val map: Map<String, Any?>) : SelectableNode(map) {
class PagesNode(private val map: Map<String, Any?>) { class PagesNode(private val map: Map<String, Any?>) {
val pages_regex: String?
get() = map["pages_regex"] as? String
val pages_css: String? val pages_css: String?
get() = map["pages_css"] as? String get() = map["pages_css"] as? String
@ -206,7 +222,11 @@ class PagesNode(private val map: Map<String, Any?>) {
val replacement: String? val replacement: String?
get() = map["url_replacement"] as? String get() = map["url_replacement"] as? String
val image_css: String by map val image_regex: String?
get() = map["image_regex"] as? String
val image_css: String?
get() = map["image_css"] as? String
val image_attr: String val image_attr: String
get() = map["image_attr"] as? String ?: "src" get() = map["image_attr"] as? String ?: "src"

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.data.source.online.english package eu.kanade.tachiyomi.data.source.online.english
import android.content.Context
import android.net.Uri import android.net.Uri
import android.text.Html import android.text.Html
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
@ -14,11 +13,11 @@ import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.LoginSource import eu.kanade.tachiyomi.data.source.online.LoginSource
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.selectText import eu.kanade.tachiyomi.util.selectText
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
@ -28,7 +27,7 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(context), LoginSource { class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource {
override val name = "Batoto" override val name = "Batoto"
@ -36,6 +35,8 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
override val lang: Language get() = EN override val lang: Language get() = EN
override val supportsLatest = true
private val datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*") private val datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*")
private val dateFields = HashMap<String, Int>().apply { private val dateFields = HashMap<String, Int>().apply {
@ -59,11 +60,12 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
override fun popularMangaInitialUrl() = "$baseUrl/search_ajax?order_cond=views&order=desc&p=1" override fun popularMangaInitialUrl() = "$baseUrl/search_ajax?order_cond=views&order=desc&p=1"
override fun latestUpdatesInitialUrl() = "$baseUrl/search_ajax?order_cond=update&order=desc&p=1"
override fun popularMangaParse(response: Response, page: MangasPage) { override fun popularMangaParse(response: Response, page: MangasPage) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
for (element in document.select(popularMangaSelector())) { for (element in document.select(popularMangaSelector())) {
Manga().apply { Manga.create(id).apply {
source = this@Batoto.id
popularMangaFromElement(element, this) popularMangaFromElement(element, this)
page.mangas.add(this) page.mangas.add(this)
} }
@ -74,31 +76,64 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
} }
} }
override fun popularMangaSelector() = "tr:not([id]):not([class])" override fun latestUpdatesParse(response: Response, page: MangasPage) {
val document = response.asJsoup()
for (element in document.select(latestUpdatesSelector())) {
Manga.create(id).apply {
latestUpdatesFromElement(element, this)
page.mangas.add(this)
}
}
page.nextPageUrl = document.select(latestUpdatesNextPageSelector()).first()?.let {
"$baseUrl/search_ajax?order_cond=update&order=desc&p=${page.page + 1}"
}
}
override fun popularMangaSelector() = "tr:has(a)"
override fun latestUpdatesSelector() = "tr:has(a)"
override fun popularMangaFromElement(element: Element, manga: Manga) { override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("a[href^=http://bato.to]").first().let { element.select("a[href^=http://bato.to]").first().let {
manga.setUrl(it.attr("href")) manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text().trim() manga.title = it.text().trim()
} }
} }
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "#show_more_row" override fun popularMangaNextPageSelector() = "#show_more_row"
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&p=1" override fun latestUpdatesNextPageSelector() = "#show_more_row"
override fun searchMangaParse(response: Response, page: MangasPage, query: String) { override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=1&genre_cond=and&genres=${getFilterParams(filters)}"
val document = Jsoup.parse(response.body().string())
private fun getFilterParams(filters: List<Filter>): String = filters
.map {
";i" + it.id
}.joinToString()
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) {
page.url = searchMangaInitialUrl(query, filters)
}
return GET(page.url, headers)
}
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
val document = response.asJsoup()
for (element in document.select(searchMangaSelector())) { for (element in document.select(searchMangaSelector())) {
Manga().apply { Manga.create(id).apply {
source = this@Batoto.id
searchMangaFromElement(element, this) searchMangaFromElement(element, this)
page.mangas.add(this) page.mangas.add(this)
} }
} }
page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let { page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let {
"$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}" "$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}&order_cond=views&order=desc&genre_cond=and&genres=" + getFilterParams(filters)
} }
} }
@ -141,7 +176,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
throw Exception(notice) throw Exception(notice)
} }
val document = Jsoup.parse(body) val document = response.asJsoup(body)
for (element in document.select(chapterListSelector())) { for (element in document.select(chapterListSelector())) {
Chapter.create().apply { Chapter.create().apply {
@ -156,7 +191,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
override fun chapterFromElement(element: Element, chapter: Chapter) { override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a[href^=http://bato.to/reader").first() val urlElement = element.select("a[href^=http://bato.to/reader").first()
chapter.setUrl(urlElement.attr("href")) chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text() chapter.name = urlElement.text()
chapter.date_upload = element.select("td").getOrNull(4)?.let { chapter.date_upload = element.select("td").getOrNull(4)?.let {
parseDateFromElement(it) parseDateFromElement(it)
@ -166,7 +201,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
private fun parseDateFromElement(dateElement: Element): Long { private fun parseDateFromElement(dateElement: Element): Long {
val dateAsString = dateElement.text() val dateAsString = dateElement.text()
val date: Date var date: Date
try { try {
date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString) date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString)
} catch (e: ParseException) { } catch (e: ParseException) {
@ -213,7 +248,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
val start = pageUrl.indexOf("#") + 1 val start = pageUrl.indexOf("#") + 1
val end = pageUrl.indexOf("_", start) val end = pageUrl.indexOf("_", start)
val id = pageUrl.substring(start, end) val id = pageUrl.substring(start, end)
return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end+1)}", pageHeaders) return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end + 1)}", pageHeaders)
} }
override fun imageUrlParse(document: Document): String { override fun imageUrlParse(document: Document): String {
@ -221,13 +256,13 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
} }
override fun login(username: String, password: String) = override fun login(username: String, password: String) =
client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global&section=login", headers)) client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global&section=login", headers))
.asObservable() .asObservable()
.flatMap { doLogin(it.body().string(), username, password) } .flatMap { doLogin(it, username, password) }
.map { isAuthenticationSuccessful(it) } .map { isAuthenticationSuccessful(it) }
private fun doLogin(response: String, username: String, password: String): Observable<Response> { private fun doLogin(response: Response, username: String, password: String): Observable<Response> {
val doc = Jsoup.parse(response) val doc = response.asJsoup()
val form = doc.select("#login").first() val form = doc.select("#login").first()
val url = form.attr("action") val url = form.attr("action")
val authKey = form.select("input[name=auth_key]").first() val authKey = form.select("input[name=auth_key]").first()
@ -244,7 +279,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
} }
override fun isAuthenticationSuccessful(response: Response) = override fun isAuthenticationSuccessful(response: Response) =
response.priorResponse() != null && response.priorResponse().code() == 302 response.priorResponse() != null && response.priorResponse().code() == 302
override fun isLogged(): Boolean { override fun isLogged(): Boolean {
return network.cookies.get(URI(baseUrl)).any { it.name() == "pass_hash" } return network.cookies.get(URI(baseUrl)).any { it.name() == "pass_hash" }
@ -266,4 +301,49 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
} }
} }
// [...document.querySelectorAll("#advanced_options div.genre_buttons")].map((el,i) => {
// const onClick=el.getAttribute('onclick');const id=onClick.substr(14,onClick.length-16);return `Filter("${id}", "${el.textContent.trim()}")`
// }).join(',\n')
// on https://bato.to/search
override fun getFilterList(): List<Filter> = listOf(
Filter("40", "4-Koma"),
Filter("1", "Action"),
Filter("2", "Adventure"),
Filter("39", "Award Winning"),
Filter("3", "Comedy"),
Filter("41", "Cooking"),
Filter("9", "Doujinshi"),
Filter("10", "Drama"),
Filter("12", "Ecchi"),
Filter("13", "Fantasy"),
Filter("15", "Gender Bender"),
Filter("17", "Harem"),
Filter("20", "Historical"),
Filter("22", "Horror"),
Filter("34", "Josei"),
Filter("27", "Martial Arts"),
Filter("30", "Mecha"),
Filter("42", "Medical"),
Filter("37", "Music"),
Filter("4", "Mystery"),
Filter("38", "Oneshot"),
Filter("5", "Psychological"),
Filter("6", "Romance"),
Filter("7", "School Life"),
Filter("8", "Sci-fi"),
Filter("32", "Seinen"),
Filter("35", "Shoujo"),
Filter("16", "Shoujo Ai"),
Filter("33", "Shounen"),
Filter("19", "Shounen Ai"),
Filter("21", "Slice of Life"),
Filter("23", "Smut"),
Filter("25", "Sports"),
Filter("26", "Supernatural"),
Filter("28", "Tragedy"),
Filter("36", "Webtoon"),
Filter("29", "Yaoi"),
Filter("31", "Yuri")
)
} }

View File

@ -19,7 +19,7 @@ import org.jsoup.nodes.Element
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.regex.Pattern import java.util.regex.Pattern
class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(context) { class Kissmanga(override val id: Int) : ParsedOnlineSource() {
override val name = "Kissmanga" override val name = "Kissmanga"
@ -27,37 +27,52 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
override val lang: Language get() = EN override val lang: Language get() = EN
override val client: OkHttpClient get() = network.cloudflareClient override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
override fun popularMangaInitialUrl() = "$baseUrl/MangaList/MostPopular" override fun popularMangaInitialUrl() = "$baseUrl/MangaList/MostPopular"
override fun latestUpdatesInitialUrl() = "http://kissmanga.com/MangaList/LatestUpdate"
override fun popularMangaSelector() = "table.listing tr:gt(1)" override fun popularMangaSelector() = "table.listing tr:gt(1)"
override fun latestUpdatesSelector() = "table.listing tr:gt(1)"
override fun popularMangaFromElement(element: Element, manga: Manga) { override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("td a:eq(0)").first().let { element.select("td a:eq(0)").first().let {
manga.setUrl(it.attr("href")) manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text() manga.title = it.text()
} }
} }
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "li > a:contains( Next)" override fun popularMangaNextPageSelector() = "li > a:contains( Next)"
override fun searchMangaRequest(page: MangasPage, query: String): Request { override fun latestUpdatesNextPageSelector(): String = "ul.pager > li > a:contains(Next)"
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) { if (page.page == 1) {
page.url = searchMangaInitialUrl(query) page.url = searchMangaInitialUrl(query, filters)
} }
val form = FormBody.Builder().apply { val form = FormBody.Builder().apply {
add("authorArtist", "") add("authorArtist", "")
add("mangaName", query) add("mangaName", query)
add("status", "") add("status", "")
add("genres", "")
}.build()
return POST(page.url, headers, form) this@Kissmanga.filters.forEach { filter ->
add("genres", if (filter in filters) "1" else "0")
}
}
return POST(page.url, headers, form.build())
} }
override fun searchMangaInitialUrl(query: String) = "$baseUrl/AdvanceSearch" override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/AdvanceSearch"
override fun searchMangaSelector() = popularMangaSelector() override fun searchMangaSelector() = popularMangaSelector()
@ -73,7 +88,7 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text() manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text()
manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text() manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text() manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text()
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it)} manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src") manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src")
} }
@ -88,7 +103,7 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
override fun chapterFromElement(element: Element, chapter: Chapter) { override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first() val urlElement = element.select("a").first()
chapter.setUrl(urlElement.attr("href")) chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text() chapter.name = urlElement.text()
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let { chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
SimpleDateFormat("MM/dd/yyyy").parse(it).time SimpleDateFormat("MM/dd/yyyy").parse(it).time
@ -109,10 +124,59 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
} }
// Not used // Not used
override fun pageListParse(document: Document, pages: MutableList<Page>) {} override fun pageListParse(document: Document, pages: MutableList<Page>) {
}
override fun imageUrlRequest(page: Page) = GET(page.url) override fun imageUrlRequest(page: Page) = GET(page.url)
override fun imageUrlParse(document: Document) = "" override fun imageUrlParse(document: Document) = ""
// $("select[name=\"genres\"]").map((i,el) => `Filter("${i}", "${$(el).next().text().trim()}")`).get().join(',\n')
// on http://kissmanga.com/AdvanceSearch
override fun getFilterList(): List<Filter> = listOf(
Filter("0", "Action"),
Filter("1", "Adult"),
Filter("2", "Adventure"),
Filter("3", "Comedy"),
Filter("4", "Comic"),
Filter("5", "Cooking"),
Filter("6", "Doujinshi"),
Filter("7", "Drama"),
Filter("8", "Ecchi"),
Filter("9", "Fantasy"),
Filter("10", "Gender Bender"),
Filter("11", "Harem"),
Filter("12", "Historical"),
Filter("13", "Horror"),
Filter("14", "Josei"),
Filter("15", "Lolicon"),
Filter("16", "Manga"),
Filter("17", "Manhua"),
Filter("18", "Manhwa"),
Filter("19", "Martial Arts"),
Filter("20", "Mature"),
Filter("21", "Mecha"),
Filter("22", "Medical"),
Filter("23", "Music"),
Filter("24", "Mystery"),
Filter("25", "One shot"),
Filter("26", "Psychological"),
Filter("27", "Romance"),
Filter("28", "School Life"),
Filter("29", "Sci-fi"),
Filter("30", "Seinen"),
Filter("31", "Shotacon"),
Filter("32", "Shoujo"),
Filter("33", "Shoujo Ai"),
Filter("34", "Shounen"),
Filter("35", "Shounen Ai"),
Filter("36", "Slice of Life"),
Filter("37", "Smut"),
Filter("38", "Sports"),
Filter("39", "Supernatural"),
Filter("40", "Tragedy"),
Filter("41", "Webtoon"),
Filter("42", "Yaoi"),
Filter("43", "Yuri")
)
} }

View File

@ -1,21 +1,20 @@
package eu.kanade.tachiyomi.data.source.online.english package eu.kanade.tachiyomi.data.source.online.english
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.EN import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import java.text.ParseException import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(context) { class Mangafox(override val id: Int) : ParsedOnlineSource() {
override val name = "Mangafox" override val name = "Mangafox"
@ -23,27 +22,39 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
override val lang: Language get() = EN override val lang: Language get() = EN
override val supportsLatest = true
override fun popularMangaInitialUrl() = "$baseUrl/directory/" override fun popularMangaInitialUrl() = "$baseUrl/directory/"
override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?latest"
override fun popularMangaSelector() = "div#mangalist > ul.list > li" override fun popularMangaSelector() = "div#mangalist > ul.list > li"
override fun latestUpdatesSelector() = "div#mangalist > ul.list > li"
override fun popularMangaFromElement(element: Element, manga: Manga) { override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("a.title").first().let { element.select("a.title").first().let {
manga.setUrl(it.attr("href")) manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text() manga.title = it.text()
} }
} }
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "a:has(span.next)" override fun popularMangaNextPageSelector() = "a:has(span.next)"
override fun searchMangaInitialUrl(query: String) = override fun latestUpdatesNextPageSelector() = "a:has(span.next)"
"$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1&${filters.map { it.id + "=1" }.joinToString("&")}"
override fun searchMangaSelector() = "table#listing > tbody > tr:gt(0)" override fun searchMangaSelector() = "table#listing > tbody > tr:gt(0)"
override fun searchMangaFromElement(element: Element, manga: Manga) { override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("a.series_preview").first().let { element.select("a.series_preview").first().let {
manga.setUrl(it.attr("href")) manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text() manga.title = it.text()
} }
} }
@ -74,7 +85,7 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
override fun chapterFromElement(element: Element, chapter: Chapter) { override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a.tips").first() val urlElement = element.select("a.tips").first()
chapter.setUrl(urlElement.attr("href")) chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text() chapter.name = urlElement.text()
chapter.date_upload = element.select("span.date").first()?.text()?.let { parseChapterDate(it) } ?: 0 chapter.date_upload = element.select("span.date").first()?.text()?.let { parseChapterDate(it) } ?: 0
} }
@ -105,10 +116,10 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
} }
override fun pageListParse(response: Response, pages: MutableList<Page>) { override fun pageListParse(response: Response, pages: MutableList<Page>) {
val document = Jsoup.parse(response.body().string()) val document = response.asJsoup()
val url = response.request().url().toString().substringBeforeLast('/') val url = response.request().url().toString().substringBeforeLast('/')
document.select("select.m").first().select("option:not([value=0])").forEach { document.select("select.m").first()?.select("option:not([value=0])")?.forEach {
pages.add(Page(pages.size, "$url/${it.attr("value")}.html")) pages.add(Page(pages.size, "$url/${it.attr("value")}.html"))
} }
} }
@ -118,4 +129,44 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src") override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
// $('select.genres').map((i,el)=>`Filter("${$(el).attr('name')}", "${$(el).next().text().trim()}")`).get().join(',\n')
// on http://kissmanga.com/AdvanceSearch
override fun getFilterList(): List<Filter> = listOf(
Filter("genres[Action]", "Action"),
Filter("genres[Adult]", "Adult"),
Filter("genres[Adventure]", "Adventure"),
Filter("genres[Comedy]", "Comedy"),
Filter("genres[Doujinshi]", "Doujinshi"),
Filter("genres[Drama]", "Drama"),
Filter("genres[Ecchi]", "Ecchi"),
Filter("genres[Fantasy]", "Fantasy"),
Filter("genres[Gender Bender]", "Gender Bender"),
Filter("genres[Harem]", "Harem"),
Filter("genres[Historical]", "Historical"),
Filter("genres[Horror]", "Horror"),
Filter("genres[Josei]", "Josei"),
Filter("genres[Martial Arts]", "Martial Arts"),
Filter("genres[Mature]", "Mature"),
Filter("genres[Mecha]", "Mecha"),
Filter("genres[Mystery]", "Mystery"),
Filter("genres[One Shot]", "One Shot"),
Filter("genres[Psychological]", "Psychological"),
Filter("genres[Romance]", "Romance"),
Filter("genres[School Life]", "School Life"),
Filter("genres[Sci-fi]", "Sci-fi"),
Filter("genres[Seinen]", "Seinen"),
Filter("genres[Shoujo]", "Shoujo"),
Filter("genres[Shoujo Ai]", "Shoujo Ai"),
Filter("genres[Shounen]", "Shounen"),
Filter("genres[Shounen Ai]", "Shounen Ai"),
Filter("genres[Slice of Life]", "Slice of Life"),
Filter("genres[Smut]", "Smut"),
Filter("genres[Sports]", "Sports"),
Filter("genres[Supernatural]", "Supernatural"),
Filter("genres[Tragedy]", "Tragedy"),
Filter("genres[Webtoons]", "Webtoons"),
Filter("genres[Yaoi]", "Yaoi"),
Filter("genres[Yuri]", "Yuri")
)
} }

View File

@ -13,7 +13,7 @@ import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(context) { class Mangahere(override val id: Int) : ParsedOnlineSource() {
override val name = "Mangahere" override val name = "Mangahere"
@ -21,27 +21,38 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
override val lang: Language get() = EN override val lang: Language get() = EN
override fun popularMangaInitialUrl() = "$baseUrl/directory/" override val supportsLatest = true
override fun popularMangaInitialUrl() = "$baseUrl/directory/?views.za"
override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?last_chapter_time.za"
override fun popularMangaSelector() = "div.directory_list > ul > li" override fun popularMangaSelector() = "div.directory_list > ul > li"
override fun latestUpdatesSelector() = "div.directory_list > ul > li"
override fun popularMangaFromElement(element: Element, manga: Manga) { override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("div.title > a").first().let { element.select("div.title > a").first().let {
manga.setUrl(it.attr("href")) manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text() manga.title = it.text()
} }
} }
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "div.next-page > a.next" override fun popularMangaNextPageSelector() = "div.next-page > a.next"
override fun searchMangaInitialUrl(query: String) = override fun latestUpdatesNextPageSelector() = "div.next-page > a.next"
"$baseUrl/search.php?name=$query&page=1&sort=views&order=za"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search.php?name=$query&page=1&sort=views&order=za&${filters.map { it.id + "=1" }.joinToString("&")}&advopts=1"
override fun searchMangaSelector() = "div.result_search > dl:has(dt)" override fun searchMangaSelector() = "div.result_search > dl:has(dt)"
override fun searchMangaFromElement(element: Element, manga: Manga) { override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("a.manga_info").first().let { element.select("a.manga_info").first().let {
manga.setUrl(it.attr("href")) manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text() manga.title = it.text()
} }
} }
@ -69,10 +80,22 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
override fun chapterListSelector() = ".detail_list > ul:not([class]) > li" override fun chapterListSelector() = ".detail_list > ul:not([class]) > li"
override fun chapterFromElement(element: Element, chapter: Chapter) { override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first() val parentEl = element.select("span.left").first()
chapter.setUrl(urlElement.attr("href")) val urlElement = parentEl.select("a").first()
chapter.name = urlElement.text()
var volume = parentEl.select("span.mr6")?.first()?.text()?.trim()?:""
if (volume.length > 0) {
volume = " - " + volume
}
var title = parentEl?.textNodes()?.last()?.text()?.trim()?:""
if (title.length > 0) {
title = " - " + title
}
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text() + volume + title
chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0 chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0
} }
@ -102,7 +125,7 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
} }
override fun pageListParse(document: Document, pages: MutableList<Page>) { override fun pageListParse(document: Document, pages: MutableList<Page>) {
document.select("select.wid60").first().getElementsByTag("option").forEach { document.select("select.wid60").first()?.getElementsByTag("option")?.forEach {
pages.add(Page(pages.size, it.attr("value"))) pages.add(Page(pages.size, it.attr("value")))
} }
pages.getOrNull(0)?.imageUrl = imageUrlParse(document) pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
@ -110,4 +133,41 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src") override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
// [...document.querySelectorAll("select[id^='genres'")].map((el,i) => `Filter("${el.getAttribute('name')}", "${el.nextSibling.nextSibling.textContent.trim()}")`).join(',\n')
// http://www.mangahere.co/advsearch.htm
override fun getFilterList(): List<Filter> = listOf(
Filter("genres[Action]", "Action"),
Filter("genres[Adventure]", "Adventure"),
Filter("genres[Comedy]", "Comedy"),
Filter("genres[Doujinshi]", "Doujinshi"),
Filter("genres[Drama]", "Drama"),
Filter("genres[Ecchi]", "Ecchi"),
Filter("genres[Fantasy]", "Fantasy"),
Filter("genres[Gender Bender]", "Gender Bender"),
Filter("genres[Harem]", "Harem"),
Filter("genres[Historical]", "Historical"),
Filter("genres[Horror]", "Horror"),
Filter("genres[Josei]", "Josei"),
Filter("genres[Martial Arts]", "Martial Arts"),
Filter("genres[Mature]", "Mature"),
Filter("genres[Mecha]", "Mecha"),
Filter("genres[Mystery]", "Mystery"),
Filter("genres[One Shot]", "One Shot"),
Filter("genres[Psychological]", "Psychological"),
Filter("genres[Romance]", "Romance"),
Filter("genres[School Life]", "School Life"),
Filter("genres[Sci-fi]", "Sci-fi"),
Filter("genres[Seinen]", "Seinen"),
Filter("genres[Shoujo]", "Shoujo"),
Filter("genres[Shoujo Ai]", "Shoujo Ai"),
Filter("genres[Shounen]", "Shounen"),
Filter("genres[Shounen Ai]", "Shounen Ai"),
Filter("genres[Slice of Life]", "Slice of Life"),
Filter("genres[Sports]", "Sports"),
Filter("genres[Supernatural]", "Supernatural"),
Filter("genres[Tragedy]", "Tragedy"),
Filter("genres[Yaoi]", "Yaoi"),
Filter("genres[Yuri]", "Yuri")
)
} }

View File

@ -0,0 +1,253 @@
package eu.kanade.tachiyomi.data.source.online.english
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.regex.Pattern
class Mangasee(override val id: Int) : ParsedOnlineSource() {
override val name = "Mangasee"
override val baseUrl = "http://mangaseeonline.net"
override val lang: Language get() = EN
override val supportsLatest = true
private val recentUpdatesPattern = Pattern.compile("(.*?)\\s(\\d+\\.?\\d*)\\s?(Completed)?")
private val indexPattern = Pattern.compile("-index-(.*?)-")
override fun popularMangaInitialUrl() = "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending"
override fun popularMangaSelector() = "div.requested > div.row"
override fun popularMangaRequest(page: MangasPage): Request {
if (page.page == 1) {
page.url = popularMangaInitialUrl()
}
val (body, requestUrl) = convertQueryToPost(page)
return POST(requestUrl, headers, body.build())
}
override fun popularMangaParse(response: Response, page: MangasPage) {
val document = response.asJsoup()
for (element in document.select(popularMangaSelector())) {
Manga.create(id).apply {
popularMangaFromElement(element, this)
page.mangas.add(this)
}
}
page.nextPageUrl = page.url
}
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("a.resultLink").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
}
// Not used, overrides parent.
override fun popularMangaNextPageSelector() = ""
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending&keyword=$query&genre=${filters.map { it.id }.joinToString(",")}"
override fun searchMangaSelector() = "div.searchResults > div.requested > div.row"
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) {
page.url = searchMangaInitialUrl(query, filters)
}
val (body, requestUrl) = convertQueryToPost(page)
return POST(requestUrl, headers, body.build())
}
private fun convertQueryToPost(page: MangasPage): Pair<FormBody.Builder, String> {
val url = HttpUrl.parse(page.url)
val body = FormBody.Builder().add("page", page.page.toString())
for (i in 0..url.querySize() - 1) {
body.add(url.queryParameterName(i), url.queryParameterValue(i))
}
val requestUrl = url.scheme() + "://" + url.host() + url.encodedPath()
return Pair(body, requestUrl)
}
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
val document = response.asJsoup()
for (element in document.select(popularMangaSelector())) {
Manga.create(id).apply {
popularMangaFromElement(element, this)
page.mangas.add(this)
}
}
page.nextPageUrl = page.url
}
override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("a.resultLink").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
}
// Not used, overrides parent.
override fun searchMangaNextPageSelector() = ""
override fun mangaDetailsParse(document: Document, manga: Manga) {
val detailElement = document.select("div.well > div.row").first()
manga.author = detailElement.select("a[href^=/search/?author=]").first()?.text()
manga.genre = detailElement.select("span.details > div.row > div:has(b:contains(Genre(s))) > a").map { it.text() }.joinToString()
manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text()
manga.status = detailElement.select("a[href^=/search/?status=]").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = detailElement.select("div > img").first()?.absUrl("src")
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing (Scan)") -> Manga.ONGOING
status.contains("Complete (Scan)") -> Manga.COMPLETED
else -> Manga.UNKNOWN
}
override fun chapterListSelector() = "div.chapter-list > a"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = element.select("span.chapterLabel").first().text()?.let { it } ?: ""
chapter.date_upload = element.select("time").first()?.attr("datetime")?.let { parseChapterDate(it) } ?: 0
}
private fun parseChapterDate(dateAsString: String): Long {
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(dateAsString).time
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
val document = response.asJsoup()
val fullUrl = response.request().url().toString()
val url = fullUrl.substringBeforeLast('/')
val series = document.select("input.IndexName").first().attr("value")
val chapter = document.select("span.CurChapter").first().text()
var index = ""
val m = indexPattern.matcher(fullUrl)
if (m.find()) {
val indexNumber = m.group(1)
index = "-index-$indexNumber"
}
document.select("div.ContainerNav").first().select("select.PageSelect > option").forEach {
pages.add(Page(pages.size, "$url/$series-chapter-$chapter$index-page-${pages.size + 1}.html"))
}
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
}
// Not used, overrides parent.
override fun pageListParse(document: Document, pages: MutableList<Page>) {
}
override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src")
// [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
// http://mangasee.co/advanced-search/
override fun getFilterList(): List<Filter> = listOf(
Filter("Action", "Action"),
Filter("Adult", "Adult"),
Filter("Adventure", "Adventure"),
Filter("Comedy", "Comedy"),
Filter("Doujinshi", "Doujinshi"),
Filter("Drama", "Drama"),
Filter("Ecchi", "Ecchi"),
Filter("Fantasy", "Fantasy"),
Filter("Gender_Bender", "Gender Bender"),
Filter("Harem", "Harem"),
Filter("Hentai", "Hentai"),
Filter("Historical", "Historical"),
Filter("Horror", "Horror"),
Filter("Josei", "Josei"),
Filter("Lolicon", "Lolicon"),
Filter("Martial_Arts", "Martial Arts"),
Filter("Mature", "Mature"),
Filter("Mecha", "Mecha"),
Filter("Mystery", "Mystery"),
Filter("Psychological", "Psychological"),
Filter("Romance", "Romance"),
Filter("School_Life", "School Life"),
Filter("Sci-fi", "Sci-fi"),
Filter("Seinen", "Seinen"),
Filter("Shotacon", "Shotacon"),
Filter("Shoujo", "Shoujo"),
Filter("Shoujo_Ai", "Shoujo Ai"),
Filter("Shounen", "Shounen"),
Filter("Shounen_Ai", "Shounen Ai"),
Filter("Slice_of_Life", "Slice of Life"),
Filter("Smut", "Smut"),
Filter("Sports", "Sports"),
Filter("Supernatural", "Supernatural"),
Filter("Tragedy", "Tragedy"),
Filter("Yaoi", "Yaoi"),
Filter("Yuri", "Yuri")
)
override fun latestUpdatesInitialUrl(): String = "http://mangaseeonline.net/home/latest.request.php"
// Not used, overrides parent.
override fun latestUpdatesNextPageSelector(): String = ""
override fun latestUpdatesSelector(): String = "a.latestSeries"
override fun latestUpdatesRequest(page: MangasPage): Request {
if (page.page == 1) {
page.url = latestUpdatesInitialUrl()
}
val (body, requestUrl) = convertQueryToPost(page)
return POST(requestUrl, headers, body.build())
}
override fun latestUpdatesParse(response: Response, page: MangasPage) {
val document = response.asJsoup()
for (element in document.select(latestUpdatesSelector())) {
Manga.create(id).apply {
latestUpdatesFromElement(element, this)
page.mangas.add(this)
}
}
page.nextPageUrl = page.url
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
element.select("a.latestSeries").first().let {
val chapterUrl = it.attr("href")
val indexOfMangaUrl = chapterUrl.indexOf("-chapter-")
val indexOfLastPath = chapterUrl.lastIndexOf("/")
val mangaUrl = chapterUrl.substring(indexOfLastPath, indexOfMangaUrl)
val defaultText = it.select("p.clamp2").text()
val m = recentUpdatesPattern.matcher(defaultText)
val title = if (m.matches()) m.group(1) else defaultText
manga.setUrlWithoutDomain("/manga" + mangaUrl)
manga.title = title
}
}
}

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