Compare commits

...

202 Commits

Author SHA1 Message Date
len
4be5f0dab3 Release 0.5.1 2017-03-19 11:58:56 +01:00
len
abe1929b49 Update vietnamese strings. Document Kissmanga changes 2017-03-19 10:51:38 +01:00
68c4116327 Category-specific auto download (#701)
* Category-specific auto download
2017-03-18 13:09:40 -04:00
len
3be9881997 Kissmanga fix. Kotlin 1.1.1 2017-03-18 14:11:16 +01:00
2e44f29882 Prevent some manga breaking the download notifier (#711) 2017-03-15 22:19:06 +01:00
len
a5520c1936 Manga info with constraint layout 2017-03-12 13:00:47 +01:00
len
112cdd54e3 Update chapters adapter 2017-03-11 21:20:46 +01:00
len
b512c67b5d Fix #704. Dependency updates 2017-03-11 16:00:07 +01:00
len
d8fa7bc9d2 Post updater notification before starting downloads 2017-03-10 20:36:43 +01:00
len
41397ab41d Don't post too many notifications in the updater 2017-03-10 20:21:09 +01:00
len
c437f1473c Add dev flavor. Bugfix in reader 2017-03-08 18:56:27 +01:00
6020cd011d Fix #692. Mangasee needs proper headers for data requests. (#694) 2017-03-03 22:53:54 +01:00
len
582bb3e2ca Handle a few more possible external directories before Lollipop 2017-03-03 18:45:25 +01:00
len
5c67161dce Minor changes for Kotlin 1.1 2017-03-03 18:18:06 +01:00
len
c00eaae62b AS 2.3 and Kotlin 1.1 2017-03-03 17:42:46 +01:00
len
8e4dd030d0 Release v0.5.0 2017-02-26 13:58:45 +01:00
len
4d7b188999 Fix #636. Minor improvements. Dependency updates 2017-02-23 22:30:43 +01:00
len
6de260d73f Fix #660. Update subsampling 2017-02-19 23:39:53 +01:00
len
2230ad59f1 Minor fixes and improvements. Dependency updates. Drop support for the old armeabi and use arm64-v8a instead 2017-02-16 23:22:30 +01:00
len
e22b1661f4 Borders crop improvements 2017-02-15 21:56:05 +01:00
bb723076ee Chapter Recognition for Read/Mintmanga (#682) 2017-02-12 19:43:00 +01:00
fd9c24413d Update CONTRIBUTING.md (#684)
Add catalogue redirection to tachiyomi-extensions
2017-02-12 19:31:38 +01:00
len
91c58640a7 Add crop borders functionality, #219 2017-02-12 00:30:32 +01:00
len
c8e3375248 Rar/cbr support 2017-02-08 22:12:00 +01:00
len
aeef8c02d8 Basic epub support 2017-02-05 12:01:58 +01:00
len
08f2cd2472 Error drawable now looks better with the dark theme 2017-02-04 20:49:07 +01:00
len
fe413d52d6 Show loading/error for images in catalogue grid view. Update support lib 2017-02-04 19:07:06 +01:00
add2ca0b8f Fixed multi-threaded initial download not showing. (#675)
Fixed library notification cancel PendingIntent.
2017-02-04 17:57:26 +01:00
len
ad6cdc9017 Allow glide to use source's network client. Catalogue fixes 2017-02-04 13:44:18 +01:00
dd8cab4562 improve local manga chapter sorting (#672) 2017-02-04 12:03:54 +01:00
len
f7c791d153 Ask for chapter deletion when removing from library 2017-02-03 20:14:33 +01:00
len
b66f06d9dc Use the first 3 bytes for jpeg 2017-02-02 23:44:25 +01:00
len
89940677cc Use custom mime discovery in downloader 2017-02-02 20:21:38 +01:00
56e7a1e2a0 Language hot fix (#668) 2017-02-02 17:01:18 +01:00
len
810f5ad531 Fix FAB behavior 2017-02-02 16:48:18 +01:00
495f5d03ac Enable Vietnamese language 2017-02-02 16:13:42 +01:00
f63d2cebfc Update Vietnamese Language (#664) 2017-02-02 16:02:25 +01:00
ae60e8cbd5 Fix Mangachan issue from #628 (#667) 2017-02-02 16:02:02 +01:00
len
772bef05fc Fix #661 2017-01-30 21:04:56 +01:00
len
9320d1f7a4 Update subsampling 2017-01-29 23:05:55 +01:00
len
e8912c5dc9 Local chapter url relative. Other minor changes 2017-01-29 20:51:11 +01:00
2b73a9d2a4 Local manga in zip/cbz/folder format (#648)
* add local source

* small fixes

* change Chapter to SChapter and and Manga to SManga in ChapterRecognition.
Use ChapterRecognition.parseChapterNumber() to recognize chapter numbers.

* use thread poll

* update isImage()

* add isImage() function to DiskUtil

* improve cover handling

* Support external SD cards

* use R.string.app_name as root folder name
2017-01-29 20:48:55 +01:00
len
e25ce768bb Migrate covers to external files dir. Fix #647 2017-01-26 22:15:17 +01:00
05c628b33c Bulgarian translation typos and fixes for better understanding (#655) 2017-01-26 08:12:43 +01:00
len
fda0aa3ce1 Fix sticky headers in recent chapters not working after rotation 2017-01-24 16:01:43 +01:00
len
cdf5bbadea Disable elevation in recent chapters. Improve downloads discovery 2017-01-22 23:13:07 +01:00
len
c6b89a826c Update recent chapters adapter 2017-01-22 20:06:43 +01:00
6264e56148 Added translatable="false" to keys so that they don't show up as untranslated (#645) 2017-01-21 20:34:34 +01:00
len
7f8bfd759f Fix filter input text introducing a new line. Also fix filter drawer clicks propagated to the views below 2017-01-21 20:31:25 +01:00
len
c3c2013944 Add bulgarian language #644 2017-01-21 19:51:40 +01:00
len
1e4a01399d Catalogue fixes 2017-01-21 18:48:27 +01:00
len
03967b67cf Update extension loader 2017-01-21 17:49:34 +01:00
len
7425478a55 Optimize imports 2017-01-20 21:34:15 +01:00
len
f807447de1 Rename OnlineSource to HttpSource 2017-01-20 21:27:53 +01:00
len
706163e7a6 Move source and network outside data 2017-01-20 21:24:31 +01:00
len
a4c145c1ef Optimize imports 2017-01-20 21:22:17 +01:00
c445ea90ba Notification Improvements (#594)
* Download notifier improvements

* Notification improvements

Added a Notification Service.

Added a Notification Activity Handler.

* Removed service. Everything is now managed by single broadcast

* Fixed some flags

* Fixed ReaderActivity call

* Code review

* Added Handler. Removed dismiss onDestroy
2017-01-20 21:18:15 +01:00
len
52c50398b8 Fix Rapid region decoder 2017-01-19 23:34:54 +01:00
fb89f77db7 French language (#634)
* Added french string values in res

* Added french in the list of languages available

* Fixed remaining //

* Update manga plural according to http://www.academie-francaise.fr/niky-orange

* Update translation based on M2ck's remarks

* Translating remaining english bits and fixing some typos

* Further fixes according to M2ck's suggestions

* Ninja typo fix !

* Updated tapping string with french translation and updated a pretty awkward literal translation

* Updated translation to new strings
2017-01-19 22:48:11 +01:00
len
f7b94179a4 Update subsampling with some fixes 2017-01-19 21:12:49 +01:00
len
e045ca8538 Dependency updates 2017-01-18 21:27:01 +01:00
len
871e17c2f5 Rewrite catalogue adapter 2017-01-17 20:13:29 +01:00
len
f86c3c81bf Catalogue with only one recycler 2017-01-16 22:18:15 +01:00
len
71ab6d38e4 Complete group filters 2017-01-15 17:04:31 +01:00
len
e76fb7a524 Minor changes 2017-01-15 01:08:35 +01:00
len
90a99dde1f Filters with flexible adapter 2017-01-14 22:38:31 +01:00
len
7b9f5d0e9f Update category adapter 2017-01-13 19:35:20 +01:00
len
e4d4dbbeb6 Change package name to flexible adapter v4 2017-01-12 16:24:40 +01:00
len
6ef94fb59b Kitsu: include manga instead of media 2017-01-12 15:38:42 +01:00
a03dceff7d Add Sort filter [Catalogs] (#633)
* Add Sort filter

* remove old views

* onClick default descending

* update remaining catalogs
2017-01-12 15:37:38 +01:00
f717c57648 Added Russian language (#631)
* added russian res/values

* Russian strings fix typo

* added Russian lang in list of lang's

* Russian string.xml: fixes

* Russian string.xml: fixes

* Russian string.xml: fixes

* strings-ru fixes
2017-01-10 20:34:45 +01:00
ca8fdad422 Fixed a typo in an Italian string (#629) (#630) 2017-01-09 14:00:12 +01:00
len
faa61923fb Fix #620 2017-01-08 23:40:54 +01:00
len
96a39f5c54 Remove custom presenter class 2017-01-08 20:56:42 +01:00
72f8c4d5e2 Added add to library dialog when downloading from catalogue (#618)
* Now show snackbar when adding from catalogue

* Code cleanup + added manga favorite event to update favorite drawable when added via snack

* Update SettingsAdvancedFragment.kt

Forgot to check optimize import. I think(hope) I got them all ;).

* Now uses PublishRelay. Manga favorite is now handled in info presenter

* Update MangaInfoFragment.kt
2017-01-08 20:07:19 +01:00
len
07cae4d684 Merge and remove util classes 2017-01-08 18:50:51 +01:00
dd56d7c0bb Initial support for external sources 2017-01-08 18:12:19 +01:00
len
77d986f213 Kitsu: also filter included by manga 2017-01-07 00:54:57 +01:00
len
13bcefe5cd Filter manga library entries in Kitsu 2017-01-07 00:29:52 +01:00
len
49d0e06704 Replace bad image url with the logo in mangafox. Related with #626 2017-01-04 19:12:47 +01:00
2c8790c545 Added circular thumbnails to the catalogue list view, like the ones in the library list view (#616)
* Added circular thumbnails to the catalogue list view, like the ones in the library list view

* Moved setImage to CatalogueHolder parent class and adapted the code so that when the manga is initialized, the thumbnail is set both in the case of grid and list

* In catalog, when switching between grid and list, initialize mangas only if going to grid view or if over wifi
2017-01-04 12:50:31 +01:00
len
d0260acd3d Set IME action done 2017-01-03 15:44:32 +01:00
44ec6184c8 README.md table update (#627) 2017-01-03 15:27:24 +01:00
len
cfa9729831 Remove unused resources 2017-01-02 18:59:15 +01:00
len
c25af3d5ad Change filters dialog with a drawer 2017-01-02 18:35:54 +01:00
d3e9200a7f Improve catalog search filters (#615)
* Add three state (include/exclude/ignore) search filters (works for now only on MangaFox and MangaHere)

* checkbox icons in xml format

* fix checkbox icons referencing

* fix three states filters in remaining catalogs

* use Spinner for filter with more than three states (Mangasee)

* use EditText for freetext filters (Mangasee)

* remove pngs

* Filter class/subclass

* add Filter.Header

* English catalogs
2017-01-02 18:30:10 +01:00
len
2032ba3ba3 Now using subsampling 3.6.0 2017-01-01 21:30:29 +01:00
len
9bcde69ee0 Release 0.4.2 2017-01-01 21:00:52 +01:00
len
beca2b429c Minor changes 2017-01-01 20:54:41 +01:00
len
3a1699f0b3 Fix #373 and a few crashes 2016-12-31 16:19:32 +01:00
len
a7192e866f Locale fix. Kotlin update to 1.0.6 2016-12-27 20:18:38 +01:00
len
dc882b4dce Make clear error codes are from HTTP 2016-12-26 18:12:15 +01:00
len
77b4de3941 Minor changes 2016-12-26 17:21:17 +01:00
len
006d17aac7 Fix locale not applied outside activities 2016-12-26 16:56:19 +01:00
len
1a3a1db4ff Remove Language class. App's language and hidden languages settings were reset 2016-12-26 15:44:59 +01:00
len
97fa659283 Kitsu fixes 2016-12-24 11:32:45 +01:00
f1d84ccb49 Add "Completed" filter; fix Mangahere; fix Mangafox (#604)
* Add "Compled" filter to all english sources; fix Mangahere manga title extraction; fix Mangafox search.

* update Mangasee

* update Batoto
2016-12-24 00:08:49 +01:00
1f14240251 Translated some strings to Italian (#602)
* Translated some strings to Italian

* Added missing strings and fixed a couple of errors
2016-12-24 00:08:27 +01:00
len
ea6fed6ecf Exclude novels from Kitsu results 2016-12-23 16:58:36 +01:00
len
d09eca7833 Anilist/Kitsu Fixes 2016-12-23 16:15:09 +01:00
2c6f64c5ae Refresh option in the library updates tab (#606)
* Solves #550

* Make sure only refresh can only happen when pulling down at top of update library list

* Removed unused import
2016-12-23 15:58:53 +01:00
ec87e4359b Drawerfix/readme update (#601)
* Fixed back button on navigational drawers

* Removing an unused import

* Cleaned up code

* little clean up
2016-12-23 15:56:10 +01:00
len
2b63bae989 Show login errors 2016-12-22 22:11:17 +01:00
len
82f4e3157a Minor changes 2016-12-22 21:57:15 +01:00
len
725ceab00b Hide API implementation from MAL service. Reorder methods and minor changes 2016-12-22 21:17:47 +01:00
len
ba428c401d Fix Kitsu refresh method 2016-12-22 16:34:34 +01:00
len
510669ee2c Fix wrong anilist decimal scores 2016-12-22 16:22:08 +01:00
len
8d749df290 Score formatting. Hide API from Anilist/Kitsu services. 2016-12-21 22:39:46 +01:00
len
091c0c0c71 Fix system language setting always using english 2016-12-21 00:42:46 +01:00
7fdd2cacd7 Fixed updater on Android N. Closes #592 (#595) 2016-12-21 00:34:31 +01:00
2241a0b2de Using title instead of text for Mangahere titles (#591)
Fixes #571
The text on the popular manga page of Mangahere contains escaped HTML characters. The title attributes of the links do not contain them.
2016-12-20 20:37:45 +01:00
len
d21a93123b Dependency updates 2016-12-20 18:58:21 +01:00
len
e542a8d8e2 Fix tab gravity 2016-12-20 16:54:56 +01:00
94ee4e7fb5 Experimental Anilist and Kitsu support (#586)
* Tracking tab with anilist support

* Rename MangaSync to Track

* Rename variables and methods to track

* Kitsu implementation

* Variables refactoring

* Travis fix?
2016-12-18 22:56:28 +01:00
len
e3d430eb5e Fix #587 2016-12-18 22:31:20 +01:00
len
fd76255cf6 Release 0.4.1 2016-12-18 21:05:33 +01:00
len
d180631877 Add ripple effect to filter nav view 2016-12-18 20:29:46 +01:00
len
1977e21363 Fix method conflicts 2016-12-18 16:59:06 +01:00
len
e1a3ee1b81 Bugfixes 2016-12-18 16:35:39 +01:00
cc43d9daed fixes wrong getBroadcast calls from imageNotification (#585) 2016-12-18 15:15:44 +01:00
len
79705df499 Apply material design guidelines to categories 2016-12-18 13:08:56 +01:00
len
36bbb906c1 Library sort change doesn't trigger filtering 2016-12-15 18:51:12 +01:00
len
816cc17ed3 Fix #577. Fix language not applied in reader activity. 2016-12-14 22:33:24 +01:00
len
97e3b5d2ab Add unread sorting 2016-12-13 22:23:49 +01:00
79ab9d80f2 Improved last_read sorting (#576) 2016-12-13 21:36:26 +01:00
len
32511149d1 Format fixes. Move lang setting to the first entry (looks better IMO) 2016-12-13 21:07:48 +01:00
cc9fd53abb Implement language switcher (#563)
* Implement language switching using BaseActivity

* Add requested changes

* Cleanup App.kt Imports and add pref_language_key

* Acutally use @string for key

* Use string resource for language preference title
2016-12-13 20:47:46 +01:00
len
4061c7450b Better network error handling 2016-12-12 20:53:44 +01:00
len
9ad535bde6 Optimize library downloaded filter 2016-12-11 23:59:25 +01:00
b067096fc7 Add drawer to filter and sort the library (#570)
* Add additional drawer to filter and sort the library

* Tint icon when there's a filter active

* Comments and minor changes
2016-12-11 12:43:44 +01:00
len
2dd58e5f7d Ask for confirmation before changing the cover. Fixes #562 2016-12-10 23:16:46 +01:00
len
7c42ab885b Readers know how to move to each side. Fix #566 2016-12-10 14:49:56 +01:00
len
26b283d44d Fix webtoon reader touch events. #561 2016-12-10 14:01:16 +01:00
len
8c1b07c4ba Handle null directories as empty arrays 2016-12-10 12:22:44 +01:00
len
f98e0858a7 Improve download discovery performance in library updates view 2016-12-09 20:23:48 +01:00
8b60d5bfcb Add optional to automatically download new chapers (#538)
* Add optional to automatically download new chapers

* Only trigger download once
2016-12-06 17:22:03 +01:00
len
30b4c6e755 Remove some state from the library view 2016-12-04 23:58:46 +01:00
len
3d2a98451b Avoid going to db when a library filter is changed 2016-12-04 23:48:29 +01:00
aba528b227 Added option to sort library (#536)
* Initial code

* Added all sort options

* Fixes

* Removed sort by added. Some renaming

* Removed date added database calls

* Fixes
2016-12-04 20:22:12 +01:00
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
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
419 changed files with 17212 additions and 10294 deletions

View File

@ -1,3 +1,7 @@
# Catalogue requests
* Catalogue requests should be created at https://github.com/inorichi/tachiyomi-extensions/issues, not here
# Bugs
* Include version (Setting > About > Version)
* If not latest, try updating, it may have already been solved

View File

@ -5,20 +5,30 @@ android:
- tools
# The BuildTools version used by your project
- build-tools-24.0.2
- android-24
- build-tools-25.0.1
- android-25
- extra-android-m2repository
- extra-google-m2repository
- extra-android-support
- extra-google-google_play_services
licenses:
- android-sdk-license-.+
- '.+'
jdk:
- oraclejdk8
before_script:
- chmod +x gradlew
- chmod +x gradlew
before_install:
- mkdir "$ANDROID_HOME/licenses" || true
- echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
- echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_HOME/licenses/android-sdk-preview-license"
#Build, and run tests
script: "./gradlew clean buildDebug"
script: "./gradlew clean buildStandardDebug"
sudo: false
before_cache:

View File

@ -1,6 +1,6 @@
| Build | Download | Auto Update |
| Build | Download | F-Droid |
|-------|----------|-------------|
| [![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) |
| [![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.org-blue.svg)](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [![fdroid dev](https://img.shields.io/badge/dev-wiki-blue.svg)](//github.com/inorichi/tachiyomi/wiki/FDroid-for-dev-versions) |
## [Report an issue](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md)

View File

@ -28,32 +28,28 @@ ext {
}
}
def includeUpdater() {
return hasProperty("include_updater")
}
android {
compileSdkVersion 24
buildToolsVersion "24.0.2"
compileSdkVersion 25
buildToolsVersion "25.0.2"
publishNonDefault true
defaultConfig {
applicationId "eu.kanade.tachiyomi"
minSdkVersion 16
targetSdkVersion 24
targetSdkVersion 25
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 13
versionName "0.3.2"
versionCode 21
versionName "0.5.1"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
buildConfigField "boolean", "INCLUDE_UPDATER", "${includeUpdater()}"
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
vectorDrawables.useSupportLibrary = true
ndk {
abiFilters "armeabi", "armeabi-v7a", "x86"
abiFilters "armeabi-v7a", "arm64-v8a", "x86"
}
}
@ -71,6 +67,18 @@ android {
}
}
productFlavors {
standard {
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
}
fdroid {
}
dev {
minSdkVersion 21
resConfigs "en", "xxhdpi"
}
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'LICENSE.txt'
@ -93,11 +101,11 @@ android {
dependencies {
// Modified dependencies
compile 'com.github.inorichi:subsampling-scale-image-view:96d2c7f'
compile 'com.github.inorichi:ReactiveNetwork:69092ed'
compile 'com.github.inorichi:subsampling-scale-image-view:01e5385'
compile 'com.github.inorichi:junrar-android:634c1f5'
// Android support library
final support_library_version = '24.2.1'
final support_library_version = '25.2.0'
compile "com.android.support:support-v4:$support_library_version"
compile "com.android.support:appcompat-v7:$support_library_version"
compile "com.android.support:cardview-v7:$support_library_version"
@ -106,48 +114,53 @@ dependencies {
compile "com.android.support:support-annotations:$support_library_version"
compile "com.android.support:customtabs:$support_library_version"
compile 'com.android.support:multidex:1.0.1'
compile 'com.android.support.constraint:constraint-layout:1.0.2'
compile 'com.google.android.gms:play-services-gcm:9.8.0'
compile 'com.android.support:multidex:1.0.1'
// ReactiveX
compile 'io.reactivex:rxandroid:1.2.1'
compile 'io.reactivex:rxjava:1.2.1'
compile 'io.reactivex:rxjava:1.2.7'
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
compile 'com.github.pwittchen:reactivenetwork:0.7.0'
// Network client
compile "com.squareup.okhttp3:okhttp:3.4.1"
compile "com.squareup.okhttp3:okhttp:3.6.0"
compile 'com.squareup.okio:okio:1.11.0'
// REST
final retrofit_version = '2.1.0'
final retrofit_version = '2.2.0'
compile "com.squareup.retrofit2:retrofit:$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.10.0'
// JSON
compile 'com.google.code.gson:gson:2.7'
compile 'com.github.salomonbrys.kotson:kotson:2.4.0'
compile 'com.google.code.gson:gson:2.8.0'
compile 'com.github.salomonbrys.kotson:kotson:2.5.0'
// YAML
compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
// JavaScript engine
compile 'com.squareup.duktape:duktape-android:1.0.0'
compile 'com.squareup.duktape:duktape-android:1.1.0'
// Disk cache
// Disk
compile 'com.jakewharton:disklrucache:2.0.2'
compile 'com.github.seven332:unifile:1.0.0'
// Parse HTML
compile 'org.jsoup:jsoup:1.9.2'
// HTML parser
compile 'org.jsoup:jsoup:1.10.2'
// Job scheduling
compile 'com.evernote:android-job:1.1.7'
compile 'com.google.android.gms:play-services-gcm:10.2.0'
// Changelog
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// Database
compile "com.pushtorefresh.storio:sqlite:1.11.0"
compile "com.pushtorefresh.storio:sqlite:1.12.3"
// Model View Presenter
final nucleus_version = '3.0.0'
@ -165,19 +178,23 @@ dependencies {
compile 'jp.wasabeef:glide-transformations:2.0.1'
// Logging
compile 'com.jakewharton.timber:timber:4.3.1'
compile 'com.jakewharton.timber:timber:4.5.1'
// Crash reports
compile 'ch.acra:acra:4.9.1'
compile 'ch.acra:acra:4.9.2'
// Sort
compile 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
// UI
compile 'com.dmitrymalkovich.android:material-design-dimens:1.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:5.0.0-rc1'
compile 'com.github.inorichi:FlexibleAdapter:93985fe' // v4.2.0 to be removed
compile 'com.nononsenseapps:filepicker:2.5.2'
compile 'com.github.amulyakhare:TextDrawable:558677e'
compile 'com.afollestad.material-dialogs:core:0.9.0.2'
compile 'net.xpece.android:support-preference:1.0.3'
compile 'com.afollestad.material-dialogs:core:0.9.4.1'
compile 'net.xpece.android:support-preference:1.2.5'
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
compile 'de.hdodenhof:circleimageview:2.1.0'
@ -185,15 +202,17 @@ dependencies {
testCompile 'junit:junit:4.12'
testCompile 'org.assertj:assertj-core:1.7.1'
testCompile 'org.mockito:mockito-core:1.10.19'
testCompile 'org.robolectric:robolectric:3.1.2'
testCompile 'org.robolectric:shadows-multidex:3.1.2'
testCompile 'org.robolectric:shadows-play-services:3.1.2'
final robolectric_version = '3.1.4'
testCompile "org.robolectric:robolectric:$robolectric_version"
testCompile "org.robolectric:shadows-multidex:$robolectric_version"
testCompile "org.robolectric:shadows-play-services:$robolectric_version"
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}
buildscript {
ext.kotlin_version = '1.0.4'
ext.kotlin_version = '1.1.1'
repositories {
mavenCentral()
}

View File

@ -64,7 +64,10 @@
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
# removes such information by default, so configure it to keep all of it.
@ -73,55 +76,18 @@
# For using GSON @Expose annotation
-keepattributes *Annotation*
-keepattributes EnclosingMethod
# Gson specific classes
-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
-renamesourcefileattribute SourceFile
-keepattributes SourceFile,LineNumberTable
# ACRA needs "annotations" so add this...
-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.** { *; }
# Prevent proguard from stripping interface information from TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# SnakeYaml
-keep class org.yaml.snakeyaml.** { public protected private *; }

View File

@ -1,16 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="eu.kanade.tachiyomi">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<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="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
@ -20,9 +21,8 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:theme="@style/Theme.Tachiyomi" >
<activity
android:name=".ui.main.MainActivity">
android:theme="@style/Theme.Tachiyomi">
<activity android:name=".ui.main.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -31,64 +31,76 @@
</activity>
<activity
android:name=".ui.manga.MangaActivity"
android:parentActivityName=".ui.main.MainActivity"
android:exported="true">
</activity>
android:exported="true"
android:parentActivityName=".ui.main.MainActivity" />
<activity
android:name=".ui.reader.ReaderActivity"
android:theme="@style/Theme.Reader">
</activity>
android:theme="@style/Theme.Reader" />
<activity
android:name=".ui.setting.SettingsActivity"
android:label="@string/label_settings"
android:parentActivityName=".ui.main.MainActivity" >
</activity>
android:parentActivityName=".ui.main.MainActivity" />
<activity
android:name=".ui.category.CategoryActivity"
android:label="@string/label_categories"
android:parentActivityName=".ui.main.MainActivity">
</activity>
android:parentActivityName=".ui.main.MainActivity" />
<activity
android:name=".ui.setting.SettingsDownloadsFragment$CustomLayoutPickerActivity"
android:label="@string/app_name"
android:theme="@style/FilePickerTheme">
android:theme="@style/FilePickerTheme" />
<activity
android:name=".ui.setting.AnilistLoginActivity"
android:label="Anilist">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="anilist-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity
android:name=".ui.download.DownloadActivity"
android:launchMode="singleTop" />
<service android:name=".data.library.LibraryUpdateService"
android:exported="false"/>
<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.download.DownloadService"
android:exported="false"/>
<provider
android:name="eu.kanade.tachiyomi.util.ZipContentProvider"
android:authorities="${applicationId}.zip-provider"
android:exported="false" />
<service android:name=".data.mangasync.UpdateMangaSyncService"
android:exported="false"/>
<service
android:name=".data.library.LibraryUpdateTrigger"
android:exported="true"
android:permission="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE">
<intent-filter>
<action android:name="com.google.android.gms.gcm.ACTION_TASK_READY" />
</intent-filter>
</service>
<service
android:name=".data.updater.UpdateCheckerService"
android:exported="true"
android:permission="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE">
<intent-filter>
<action android:name="com.google.android.gms.gcm.ACTION_TASK_READY" />
</intent-filter>
</service>
<service android:name=".data.updater.UpdateDownloaderService"
android:exported="false"/>
<receiver android:name=".data.updater.UpdateNotificationReceiver"/>
<provider
android:name="eu.kanade.tachiyomi.util.RarContentProvider"
android:authorities="${applicationId}.rar-provider"
android:exported="false" />
<receiver
android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver">
</receiver>
android:name=".data.notification.NotificationReceiver"
android:exported="false" />
<service
android:name=".data.library.LibraryUpdateService"
android:exported="false" />
<service
android:name=".data.download.DownloadService"
android:exported="false" />
<service
android:name=".data.updater.UpdateDownloaderService"
android:exported="false" />
<meta-data
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"

View File

@ -2,7 +2,12 @@ package eu.kanade.tachiyomi
import android.app.Application
import android.content.Context
import android.content.res.Configuration
import android.support.multidex.MultiDex
import com.evernote.android.job.JobManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import eu.kanade.tachiyomi.util.LocaleHelper
import org.acra.ACRA
import org.acra.annotation.ReportsCrashes
import timber.log.Timber
@ -15,7 +20,7 @@ import uy.kohesive.injekt.registry.default.DefaultRegistrar
reportType = org.acra.sender.HttpSender.Type.JSON,
httpMethod = org.acra.sender.HttpSender.Method.PUT,
buildConfigClass = BuildConfig::class,
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*")
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*", ".*token.*")
)
open class App : Application() {
@ -27,6 +32,9 @@ open class App : Application() {
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
setupAcra()
setupJobManager()
LocaleHelper.updateConfiguration(this, resources.configuration)
}
override fun attachBaseContext(base: Context) {
@ -36,8 +44,23 @@ open class App : Application() {
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
LocaleHelper.updateConfiguration(this, newConfig, true)
}
protected open fun setupAcra() {
ACRA.init(this)
}
protected open fun setupJobManager() {
JobManager.create(this).addJobCreator { tag ->
when (tag) {
LibraryUpdateJob.TAG -> LibraryUpdateJob()
UpdateCheckerJob.TAG -> UpdateCheckerJob()
else -> null
}
}
}
}

View File

@ -6,10 +6,10 @@ 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 eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addSingletonFactory
@ -32,7 +32,7 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { DownloadManager(app) }
addSingletonFactory { MangaSyncManager(app) }
addSingletonFactory { TrackManager(app) }
addSingletonFactory { Gson() }

View File

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

View File

@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.data.backup
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.*
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.IntegerSerializer
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.*
import java.io.*
@ -37,12 +39,14 @@ class BackupManager(private val db: DatabaseHelper) {
private val MANGA = "manga"
private val MANGAS = "mangas"
private val CHAPTERS = "chapters"
private val MANGA_SYNC = "sync"
private val TRACK = "sync"
private val CATEGORIES = "categories"
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
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())
.create()
@ -105,10 +109,10 @@ class BackupManager(private val db: DatabaseHelper) {
entry.add(CHAPTERS, gson.toJsonTree(chapters))
}
// Backup manga sync
val mangaSync = db.getMangasSync(manga).executeAsBlocking()
if (!mangaSync.isEmpty()) {
entry.add(MANGA_SYNC, gson.toJsonTree(mangaSync))
// Backup tracks
val tracks = db.getTracks(manga).executeAsBlocking()
if (!tracks.isEmpty()) {
entry.add(TRACK, gson.toJsonTree(tracks))
}
// Backup categories for this manga
@ -227,13 +231,13 @@ class BackupManager(private val db: DatabaseHelper) {
val element = backupManga.asJsonObject
val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java)
val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray())
val sync = gson.fromJson<List<MangaSyncImpl>>(element.get(MANGA_SYNC) ?: JsonArray())
val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray())
val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
// Restore everything related to this manga
restoreManga(manga)
restoreChaptersForManga(manga, chapters)
restoreSyncForManga(manga, sync)
restoreSyncForManga(manga, tracks)
restoreCategoriesForManga(manga, categories)
}
}
@ -329,35 +333,35 @@ class BackupManager(private val db: DatabaseHelper) {
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param sync the sync to restore.
* @param tracks the track list to restore.
*/
private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) {
private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) {
// Fix foreign keys with the current manga id
for (mangaSync in sync) {
mangaSync.manga_id = manga.id!!
for (track in tracks) {
track.manga_id = manga.id!!
}
val dbSyncs = db.getMangasSync(manga).executeAsBlocking()
val syncToUpdate = ArrayList<MangaSync>()
for (backupSync in sync) {
val dbTracks = db.getTracks(manga).executeAsBlocking()
val trackToUpdate = ArrayList<Track>()
for (backupTrack in tracks) {
// Try to find existing chapter in db
val pos = dbSyncs.indexOf(backupSync)
val pos = dbTracks.indexOf(backupTrack)
if (pos != -1) {
// The sync is already in the db, only update its fields
val dbSync = dbSyncs[pos]
val dbSync = dbTracks[pos]
// Mark the max chapter as read and nothing else
dbSync.last_chapter_read = Math.max(backupSync.last_chapter_read, dbSync.last_chapter_read)
syncToUpdate.add(dbSync)
dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read)
trackToUpdate.add(dbSync)
} else {
// Insert new sync. Let the db assign the id
backupSync.id = null
syncToUpdate.add(backupSync)
backupTrack.id = null
trackToUpdate.add(backupTrack)
}
}
// Update database
if (!syncToUpdate.isEmpty()) {
db.insertMangasSync(syncToUpdate).executeAsBlocking()
if (!trackToUpdate.isEmpty()) {
db.insertTracks(trackToUpdate).executeAsBlocking()
}
}

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.FieldAttributes
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.TrackImpl
class IdExclusion : ExclusionStrategy {
@ -15,10 +15,10 @@ class IdExclusion : ExclusionStrategy {
private val syncExclusions = listOf("id", "manga_id", "update")
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
Manga::class.java -> mangaExclusions.contains(f.name)
Chapter::class.java -> chapterExclusions.contains(f.name)
MangaSync::class.java -> syncExclusions.contains(f.name)
Category::class.java -> categoryExclusions.contains(f.name)
MangaImpl::class.java -> mangaExclusions.contains(f.name)
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
TrackImpl::class.java -> syncExclusions.contains(f.name)
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
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,19 @@ package eu.kanade.tachiyomi.data.cache
import android.content.Context
import android.text.format.Formatter
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.jakewharton.disklrucache.DiskLruCache
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.DiskUtils
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.saveTo
import okhttp3.Response
import okio.Okio
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.IOException
import java.lang.reflect.Type
/**
* Class used to create chapter cache
@ -26,15 +27,6 @@ import java.lang.reflect.Type
*/
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 {
/** Name of cache directory. */
const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache"
@ -49,38 +41,37 @@ class ChapterCache(private val context: Context) {
const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024
}
init {
// Open cache in default cache directory.
diskCache = DiskLruCache.open(
File(context.cacheDir, PARAMETER_CACHE_DIRECTORY),
PARAMETER_APP_VERSION,
PARAMETER_VALUE_COUNT,
PARAMETER_CACHE_SIZE)
}
/** Google Json class used for parsing JSON files. */
private val gson: Gson by injectLazy()
/** Cache class used for cache management. */
private val diskCache = DiskLruCache.open(
File(context.externalCacheDir, PARAMETER_CACHE_DIRECTORY),
PARAMETER_APP_VERSION,
PARAMETER_VALUE_COUNT,
PARAMETER_CACHE_SIZE)
/**
* Returns directory of cache.
* @return directory of cache.
*/
val cacheDir: File
get() = diskCache.directory
/**
* Returns real size of directory.
* @return real size of directory.
*/
private val realSize: Long
get() = DiskUtils.getDirectorySize(cacheDir)
get() = DiskUtil.getDirectorySize(cacheDir)
/**
* Returns real size of directory in human readable format.
* @return real size of directory.
*/
val readableSize: String
get() = Formatter.formatFileSize(context, realSize)
/**
* Remove file from cache.
*
* @param file name of file "md5.0".
* @return status of deletion for the file.
*/
@ -101,27 +92,29 @@ class ChapterCache(private val context: Context) {
/**
* Get page list from cache.
* @param chapterUrl the url of the chapter.
*
* @param chapter the chapter.
* @return an observable of the list of pages.
*/
fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> {
return Observable.fromCallable<List<Page>> {
fun getPageListFromCache(chapter: Chapter): Observable<List<Page>> {
return Observable.fromCallable {
// Get the key for the chapter.
val key = DiskUtils.hashKeyForDisk(chapterUrl)
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
// Convert JSON string to list of objects. Throws an exception if snapshot is null
diskCache.get(key).use {
gson.fromJson(it.getString(0), pageListCollection)
gson.fromJson<List<Page>>(it.getString(0))
}
}
}
/**
* Add page list to disk cache.
* @param chapterUrl the url of the chapter.
*
* @param chapter the chapter.
* @param pages list of pages.
*/
fun putPageListToCache(chapterUrl: String, pages: List<Page>) {
fun putPageListToCache(chapter: Chapter, pages: List<Page>) {
// Convert list of pages to json string.
val cachedValue = gson.toJson(pages)
@ -130,7 +123,7 @@ class ChapterCache(private val context: Context) {
try {
// Get editor from md5 key.
val key = DiskUtils.hashKeyForDisk(chapterUrl)
val key = DiskUtil.hashKeyForDisk(getKey(chapter))
editor = diskCache.edit(key) ?: return
// Write chapter urls to cache.
@ -151,35 +144,34 @@ class ChapterCache(private val context: Context) {
}
/**
* Check if image is in cache.
* Returns true if image is in cache.
*
* @param imageUrl url of image.
* @return true if in cache otherwise false.
*/
fun isImageInCache(imageUrl: String): Boolean {
try {
return diskCache.get(DiskUtils.hashKeyForDisk(imageUrl)) != null
return diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null
} catch (e: IOException) {
return false
}
}
/**
* Get image path from url.
* Get image file from url.
*
* @param imageUrl url of image.
* @return path of image.
*/
fun getImagePath(imageUrl: String): String? {
try {
// Get file from md5 key.
val imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0"
return File(diskCache.directory, imageName).canonicalPath
} catch (e: IOException) {
return null
}
fun getImageFile(imageUrl: String): File {
// Get file from md5 key.
val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0"
return File(diskCache.directory, imageName)
}
/**
* Add image to cache.
*
* @param imageUrl url of image.
* @param response http response from page.
* @throws IOException image error.
@ -191,7 +183,7 @@ class ChapterCache(private val context: Context) {
try {
// 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")
// Get OutputStream and write image with Okio.
@ -205,5 +197,8 @@ class ChapterCache(private val context: Context) {
}
}
private fun getKey(chapter: Chapter): String {
return "${chapter.manga_id}${chapter.url}"
}
}

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.cache
import android.content.Context
import eu.kanade.tachiyomi.util.DiskUtils
import eu.kanade.tachiyomi.util.DiskUtil
import java.io.File
import java.io.IOException
import java.io.InputStream
@ -20,7 +20,7 @@ class CoverCache(private val context: Context) {
/**
* Cache directory used for cache management.
*/
private val cacheDir: File = File(context.externalCacheDir, "cover_disk_cache")
private val cacheDir = context.getExternalFilesDir("covers")
/**
* Returns the cover from cache.
@ -29,7 +29,7 @@ class CoverCache(private val context: Context) {
* @return cover image.
*/
fun getCoverFile(thumbnailUrl: String): File {
return File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl))
return File(cacheDir, DiskUtil.hashKeyForDisk(thumbnailUrl))
}
/**

View File

@ -10,13 +10,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
* This class provides operations to manage the database through its interfaces.
*/
open class DatabaseHelper(context: Context)
: MangaQueries, ChapterQueries, MangaSyncQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
override val db = DefaultStorIOSQLite.builder()
.sqliteOpenHelper(DbOpenHelper(context))
.addTypeMapping(Manga::class.java, MangaTypeMapping())
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
.addTypeMapping(MangaSync::class.java, MangaSyncTypeMapping())
.addTypeMapping(Track::class.java, TrackTypeMapping())
.addTypeMapping(Category::class.java, CategoryTypeMapping())
.addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping())
.addTypeMapping(History::class.java, HistoryTypeMapping())

View File

@ -17,13 +17,13 @@ class DbOpenHelper(context: Context)
/**
* Version of the database.
*/
const val DATABASE_VERSION = 3
const val DATABASE_VERSION = 4
}
override fun onCreate(db: SQLiteDatabase) = with(db) {
execSQL(MangaTable.createTableQuery)
execSQL(ChapterTable.createTableQuery)
execSQL(MangaSyncTable.createTableQuery)
execSQL(TrackTable.createTableQuery)
execSQL(CategoryTable.createTableQuery)
execSQL(MangaCategoryTable.createTableQuery)
execSQL(HistoryTable.createTableQuery)
@ -48,6 +48,9 @@ class DbOpenHelper(context: Context)
db.execSQL(HistoryTable.createTableQuery)
db.execSQL(HistoryTable.createChapterIdIndexQuery)
}
if (oldVersion < 4) {
db.execSQL(ChapterTable.bookmarkUpdateQuery)
}
}
override fun onConfigure(db: SQLiteDatabase) {

View File

@ -11,6 +11,7 @@ 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
@ -41,12 +42,13 @@ class ChapterPutResolver : DefaultPutResolver<Chapter>() {
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Chapter) = ContentValues(10).apply {
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)
@ -63,6 +65,7 @@ class ChapterGetResolver : DefaultGetResolver<Chapter>() {
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))

View File

@ -69,7 +69,7 @@ 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))
source = cursor.getLong(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))

View File

@ -9,38 +9,38 @@ 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
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_REMOTE_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS
import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE
class MangaSyncTypeMapping : SQLiteTypeMapping<MangaSync>(
MangaSyncPutResolver(),
MangaSyncGetResolver(),
MangaSyncDeleteResolver()
class TrackTypeMapping : SQLiteTypeMapping<Track>(
TrackPutResolver(),
TrackGetResolver(),
TrackDeleteResolver()
)
class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() {
class TrackPutResolver : DefaultPutResolver<Track>() {
override fun mapToInsertQuery(obj: MangaSync) = InsertQuery.builder()
override fun mapToInsertQuery(obj: Track) = InsertQuery.builder()
.table(TABLE)
.build()
override fun mapToUpdateQuery(obj: MangaSync) = UpdateQuery.builder()
override fun mapToUpdateQuery(obj: Track) = UpdateQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: MangaSync) = ContentValues(9).apply {
override fun mapToContentValues(obj: Track) = ContentValues(9).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_SYNC_ID, obj.sync_id)
@ -53,9 +53,9 @@ class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() {
}
}
class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() {
class TrackGetResolver : DefaultGetResolver<Track>() {
override fun mapFromCursor(cursor: Cursor): MangaSync = MangaSyncImpl().apply {
override fun mapFromCursor(cursor: Cursor): Track = TrackImpl().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))
@ -68,9 +68,9 @@ class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() {
}
}
class MangaSyncDeleteResolver : DefaultDeleteResolver<MangaSync>() {
class TrackDeleteResolver : DefaultDeleteResolver<Track>() {
override fun mapToDeleteQuery(obj: MangaSync) = DeleteQuery.builder()
override fun mapToDeleteQuery(obj: Track) = DeleteQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)

View File

@ -1,27 +1,22 @@
package eu.kanade.tachiyomi.data.database.models
import eu.kanade.tachiyomi.source.model.SChapter
import java.io.Serializable
interface Chapter : Serializable {
interface Chapter : SChapter, 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

View File

@ -12,6 +12,8 @@ class ChapterImpl : Chapter {
override var read: Boolean = false
override var bookmark: Boolean = false
override var last_page_read: Int = 0
override var date_fetch: Long = 0

View File

@ -1,35 +1,17 @@
package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
import eu.kanade.tachiyomi.source.model.SManga
interface Manga : Serializable {
interface Manga : SManga {
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 source: Long
var favorite: Boolean
var last_update: Long
var initialized: Boolean
var viewer: Int
var chapter_flags: Int
@ -38,27 +20,6 @@ interface Manga : Serializable {
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)
}
@ -84,17 +45,16 @@ interface Manga : Serializable {
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
@ -110,6 +70,10 @@ interface Manga : Serializable {
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
@ -118,12 +82,13 @@ interface Manga : Serializable {
const val DISPLAY_NUMBER = 0x00100000
const val DISPLAY_MASK = 0x00100000
fun create(source: Int): Manga = MangaImpl().apply {
fun create(source: Long): Manga = MangaImpl().apply {
this.source = source
}
fun create(pathUrl: String, source: Int = 0): Manga = MangaImpl().apply {
fun create(pathUrl: String, title: String, source: Long = 0): Manga = MangaImpl().apply {
url = pathUrl
this.title = title
this.source = source
}
}

View File

@ -4,7 +4,7 @@ class MangaImpl : Manga {
override var id: Long? = null
override var source: Int = 0
override var source: Long = -1
override lateinit var url: String

View File

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
interface MangaSync : Serializable {
interface Track : Serializable {
var id: Long?
@ -22,9 +22,7 @@ interface MangaSync : Serializable {
var status: Int
var update: Boolean
fun copyPersonalFrom(other: MangaSync) {
fun copyPersonalFrom(other: Track) {
last_chapter_read = other.last_chapter_read
score = other.score
status = other.status
@ -32,7 +30,7 @@ interface MangaSync : Serializable {
companion object {
fun create(serviceId: Int): MangaSync = MangaSyncImpl().apply {
fun create(serviceId: Int): Track = TrackImpl().apply {
sync_id = serviceId
}
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.models
class MangaSyncImpl : MangaSync {
class TrackImpl : Track {
override var id: Long? = null
@ -20,17 +20,15 @@ class MangaSyncImpl : MangaSync {
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
other as Track
if (manga_id != mangaSync.manga_id) return false
if (sync_id != mangaSync.sync_id) return false
return remote_id == mangaSync.remote_id
if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false
return remote_id == other.remote_id
}
override fun hashCode(): Int {

View File

@ -33,6 +33,15 @@ interface ChapterQueries : DbProvider {
.withGetResolver(MangaChapterGetResolver.INSTANCE)
.prepare()
fun getChapter(id: Long) = db.get()
.`object`(Chapter::class.java)
.withQuery(Query.builder()
.table(ChapterTable.TABLE)
.where("${ChapterTable.COL_ID} = ?")
.whereArgs(id)
.build())
.prepare()
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()

View File

@ -40,7 +40,6 @@ interface HistoryQueries : DbProvider {
.build())
.prepare()
/**
* Updates the history last read.
* Inserts history object if not yet in database

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
import eu.kanade.tachiyomi.data.database.tables.MangaTable
@ -29,7 +30,7 @@ interface MangaQueries : DbProvider {
.withGetResolver(LibraryMangaGetResolver.INSTANCE)
.prepare()
open fun getFavoriteMangas() = db.get()
fun getFavoriteMangas() = db.get()
.listOfObjects(Manga::class.java)
.withQuery(Query.builder()
.table(MangaTable.TABLE)
@ -39,7 +40,7 @@ interface MangaQueries : DbProvider {
.build())
.prepare()
fun getManga(url: String, sourceId: Int) = db.get()
fun getManga(url: String, sourceId: Long) = db.get()
.`object`(Manga::class.java)
.withQuery(Query.builder()
.table(MangaTable.TABLE)
@ -66,6 +67,11 @@ interface MangaQueries : DbProvider {
.withPutResolver(MangaFlagsPutResolver())
.prepare()
fun updateLastUpdated(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaLastUpdatedPutResolver())
.prepare()
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
@ -78,4 +84,11 @@ interface MangaQueries : DbProvider {
.build())
.prepare()
fun getLastReadManga() = db.get()
.listOfObjects(Manga::class.java)
.withQuery(RawQuery.builder()
.query(getLastReadMangaQuery())
.observesTables(MangaTable.TABLE)
.build())
.prepare()
}

View File

@ -1,46 +0,0 @@
package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
interface MangaSyncQueries : DbProvider {
fun getMangaSync(manga: Manga, sync: MangaSyncService) = db.get()
.`object`(MangaSync::class.java)
.withQuery(Query.builder()
.table(MangaSyncTable.TABLE)
.where("${MangaSyncTable.COL_MANGA_ID} = ? AND " +
"${MangaSyncTable.COL_SYNC_ID} = ?")
.whereArgs(manga.id, sync.id)
.build())
.prepare()
fun getMangasSync(manga: Manga) = db.get()
.listOfObjects(MangaSync::class.java)
.withQuery(Query.builder()
.table(MangaSyncTable.TABLE)
.where("${MangaSyncTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id)
.build())
.prepare()
fun insertMangaSync(manga: MangaSync) = db.put().`object`(manga).prepare()
fun insertMangasSync(mangas: List<MangaSync>) = db.put().objects(mangas).prepare()
fun deleteMangaSync(manga: MangaSync) = db.delete().`object`(manga).prepare()
fun deleteMangaSyncForManga(manga: Manga) = db.delete()
.byQuery(DeleteQuery.builder()
.table(MangaSyncTable.TABLE)
.where("${MangaSyncTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id)
.build())
.prepare()
}

View File

@ -73,6 +73,18 @@ fun getHistoryByMangaId() = """
WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
"""
fun getLastReadMangaQuery() = """
SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max
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}
WHERE ${Manga.TABLE}.${Manga.COL_FAVORITE} = 1
GROUP BY ${Manga.TABLE}.${Manga.COL_ID}
ORDER BY max DESC
"""
/**
* Query to get the categories for a manga.
*/

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.tables.TrackTable
import eu.kanade.tachiyomi.data.track.TrackService
interface TrackQueries : DbProvider {
fun getTracks(manga: Manga) = db.get()
.listOfObjects(Track::class.java)
.withQuery(Query.builder()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id)
.build())
.prepare()
fun insertTrack(track: Track) = db.put().`object`(track).prepare()
fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare()
fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
.byQuery(DeleteQuery.builder()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
.whereArgs(manga.id, sync.id)
.build())
.prepare()
}

View File

@ -25,8 +25,9 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() {
.whereArgs(chapter.id)
.build()
fun mapToContentValues(chapter: Chapter) = ContentValues(2).apply {
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
put(ChapterTable.COL_READ, chapter.read)
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
}

View File

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.data.database.resolvers
import android.content.ContentValues
import com.pushtorefresh.storio.sqlite.StorIOSQLite
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.inTransactionReturn
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.tables.MangaTable
class MangaLastUpdatedPutResolver : PutResolver<Manga>() {
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
val updateQuery = mapToUpdateQuery(manga)
val contentValues = mapToContentValues(manga)
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
}
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_LAST_UPDATE, manga.last_update)
}
}

View File

@ -14,6 +14,8 @@ object ChapterTable {
const val COL_READ = "read"
const val COL_BOOKMARK = "bookmark"
const val COL_DATE_FETCH = "date_fetch"
const val COL_DATE_UPLOAD = "date_upload"
@ -31,6 +33,7 @@ object ChapterTable {
$COL_URL TEXT NOT NULL,
$COL_NAME TEXT NOT NULL,
$COL_READ BOOLEAN NOT NULL,
$COL_BOOKMARK BOOLEAN NOT NULL,
$COL_LAST_PAGE_READ INT NOT NULL,
$COL_CHAPTER_NUMBER FLOAT NOT NULL,
$COL_SOURCE_ORDER INTEGER NOT NULL,
@ -46,4 +49,7 @@ object ChapterTable {
val sourceOrderUpdateQuery: String
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

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.tables
object MangaSyncTable {
object TrackTable {
const val TABLE = "manga_sync"

View File

@ -1,450 +1,180 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R
import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.BehaviorRelay
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.preference.getOrDefault
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.online.OnlineSource
import eu.kanade.tachiyomi.util.*
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page
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 uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.FileReader
import java.util.*
class DownloadManager(
private val context: Context,
private val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
) {
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)
}
}, { error ->
DownloadService.stop(context)
Timber.e(error)
downloadNotifier.onError(error.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: Observable<List<Page>> = 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)
.map {
val file = File(directory, filename)
try {
file.parentFile.mkdirs()
it.body().source().saveTo(file.outputStream())
} catch (e: Exception) {
it.close()
file.delete()
throw e
}
page
}
// Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
.retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
}
// 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 (error: Exception) {
Timber.e(error)
}
}
}
// 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 {
if (queue.isEmpty())
return false
return downloader.start()
}
if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed)
initializeSubscriptions()
/**
* Tells the downloader to stop downloads.
*
* @param reason an optional reason for being stopped, used to notify the user.
*/
fun stopDownloads(reason: String? = null) {
downloader.stop(reason)
}
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)
/**
* Tells the downloader to pause downloads.
*/
fun pauseDownloads() {
downloader.pause()
}
/**
* Empties the download queue.
*
* @param isNotification value that determines if status is set (needed for view updates)
*/
fun clearQueue(isNotification: Boolean = false) {
downloader.clearQueue(isNotification)
}
/**
* 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 }
}
}
downloadsQueueSubject.onNext(pending)
return !pending.isEmpty()
}
fun stopDownloads(errorMessage: String? = null) {
destroySubscriptions()
for (download in queue) {
if (download.status == Download.DOWNLOADING) {
download.status = Download.ERROR
}
}
errorMessage?.let { downloadNotifier.onError(it) }
/**
* Returns the directory name for a manga.
*
* @param manga the manga to query.
*/
fun getMangaDirName(manga: Manga): String {
return provider.getMangaDirName(manga)
}
fun clearQueue() {
queue.clear()
downloadNotifier.onClear()
/**
* 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 download directory for a source if it exists.
*
* @param source the source to query.
*/
fun findSourceDir(source: Source): UniFile? {
return provider.findSourceDir(source)
}
/**
* 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,29 +1,31 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.graphics.BitmapFactory
import android.support.v4.app.NotificationCompat
import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.util.chop
import eu.kanade.tachiyomi.util.notificationManager
import java.util.regex.Pattern
/**
* DownloadNotifier is used to show notifications when downloading one or multiple chapters.
*
* @param context context of application
*/
class DownloadNotifier(private val context: Context) {
internal class DownloadNotifier(private val context: Context) {
/**
* Notification builder.
*/
private val notificationBuilder = NotificationCompat.Builder(context)
/**
* Id of the notification.
*/
private val notificationId: Int
get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
private val notification by lazy {
NotificationCompat.Builder(context)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
}
/**
* Status of download. Used for correct notification icon.
@ -33,12 +35,59 @@ class DownloadNotifier(private val context: Context) {
/**
* The size of queue on start download.
*/
internal var initialQueueSize = 0
var initialQueueSize = 0
get() = field
set(value) {
if (value != 0){
isSingleChapter = (value == 1)
}
field = value
}
/**
* Simultaneous download setting > 1.
*/
internal var multipleDownloadThreads = false
var multipleDownloadThreads = false
/**
* Updated when error is thrown
*/
var errorThrown = false
/**
* Updated when only single page is downloaded
*/
var isSingleChapter = false
/**
* Updated when paused
*/
var paused = 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())
}
/**
* Clear old actions if they exist.
*/
private fun clearActions() = with(notification) {
if (!mActions.isEmpty())
mActions.clear()
}
/**
* 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.
@ -46,50 +95,41 @@ class DownloadNotifier(private val context: Context) {
*
* @param queue the queue containing downloads.
*/
internal fun onProgressChange(queue: DownloadQueue) {
fun onProgressChange(queue: DownloadQueue) {
if (multipleDownloadThreads) {
doOnProgressChange(null, queue)
}
}
/**
* Called when download progress changes
* Note: Only accepted when single download active
* Called when download progress changes.
* Note: Only accepted when single download active.
*
* @param download download object containing download information
* @param queue the queue containing downloads
* @param download download object containing download information.
* @param queue the queue containing downloads.
*/
internal fun onProgressChange(download: Download, queue: DownloadQueue) {
fun onProgressChange(download: Download, queue: DownloadQueue) {
if (!multipleDownloadThreads) {
doOnProgressChange(download, queue)
}
}
/**
* Show notification progress of chapter
* Show notification progress of chapter.
*
* @param download download object containing download information
* @param queue the queue containing downloads
* @param download download object containing download information.
* @param queue the queue containing downloads.
*/
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
// Check if download is completed
if (multipleDownloadThreads) {
if (queue.isEmpty()) {
onComplete(null)
return
}
} else {
if (download != null && download.pages!!.size == download.downloadedImages) {
onComplete(download)
return
}
}
// Create notification
with (notificationBuilder) {
// Check if icon needs refresh
with(notification) {
// Check if first call.
if (!isDownloading) {
setSmallIcon(android.R.drawable.stat_sys_download)
setAutoCancel(false)
clearActions()
// Open download manager when clicked
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
isDownloading = true
}
@ -105,11 +145,10 @@ class DownloadNotifier(private val context: Context) {
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
} else {
download?.let {
if (it.chapter.name.length >= 33)
setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
else
setContentTitle(it.chapter.name)
val title = it.manga.title.chop(15)
val quotedTitle = Pattern.quote(title)
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
setContentTitle("$title - $chapter".chop(30))
setContentText(context.getString(R.string.chapter_downloading_progress)
.format(it.downloadedImages, it.pages!!.size))
setProgress(it.pages!!.size, it.downloadedImages, false)
@ -118,25 +157,34 @@ class DownloadNotifier(private val context: Context) {
}
}
// Displays the progress bar on notification
context.notificationManager.notify(notificationId, notificationBuilder.build())
notification.show()
}
/**
* Called when chapter is downloaded
*
* @param download download object containing download information
* Show notification when download is paused.
*/
private fun onComplete(download: Download?) {
// Create notification.
with(notificationBuilder) {
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
setContentText(context.getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)
fun onDownloadPaused() {
with(notification) {
setContentTitle(context.getString(R.string.chapter_paused))
setContentText(context.getString(R.string.download_notifier_download_paused))
setSmallIcon(R.drawable.ic_av_pause_grey_24dp_img)
setAutoCancel(false)
setProgress(0, 0, false)
clearActions()
// Open download manager when clicked
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
// Resume action
addAction(R.drawable.ic_av_play_arrow_grey_img,
context.getString(R.string.action_resume),
NotificationReceiver.resumeDownloadsPendingBroadcast(context))
//Clear action
addAction(R.drawable.ic_clear_grey_24dp_img,
context.getString(R.string.action_clear),
NotificationReceiver.clearDownloadsPendingBroadcast(context))
}
// Show notification.
context.notificationManager.notify(notificationId, notificationBuilder.build())
notification.show()
// Reset initial values
isDownloading = false
@ -144,30 +192,80 @@ class DownloadNotifier(private val context: Context) {
}
/**
* Clears the notification message
* Called when chapter is downloaded.
*
* @param download download object containing download information.
*/
internal fun onClear() {
context.notificationManager.cancel(notificationId)
fun onDownloadCompleted(download: Download, queue: DownloadQueue) {
// Check if last download
if (!queue.isEmpty()) {
return
}
// Create notification.
with(notification) {
val title = download.manga.title.chop(15)
val quotedTitle = Pattern.quote(title)
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
setContentTitle("$title - $chapter".chop(30))
setContentText(context.getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)
setAutoCancel(true)
clearActions()
setContentIntent(NotificationReceiver.openChapterPendingBroadcast(context, download.manga, download.chapter))
setProgress(0, 0, false)
}
// Show notification.
notification.show()
// Reset initial values
isDownloading = false
initialQueueSize = 0
}
/**
* Called on error while downloading chapter
* Called when the downloader receives a warning.
*
* @param error string containing error information
* @param chapter string containing chapter title
* @param reason the text to show.
*/
internal fun onError(error: String? = null, chapter: String? = null) {
// Create notification
with(notificationBuilder) {
setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error))
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
fun onWarning(reason: String) {
with(notification) {
setContentTitle(context.getString(R.string.download_notifier_downloader_title))
setContentText(reason)
setSmallIcon(android.R.drawable.stat_sys_warning)
setAutoCancel(true)
clearActions()
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
setProgress(0, 0, false)
}
context.notificationManager.notify(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID, notificationBuilder.build())
notification.show()
// Reset download information
onClear()
isDownloading = false
}
/**
* 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 chapter string containing chapter title.
*/
fun onError(error: String? = null, chapter: String? = null) {
// Create notification
with(notification) {
setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title))
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
setSmallIcon(android.R.drawable.stat_sys_warning)
clearActions()
setAutoCancel(false)
setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context))
setProgress(0, 0, false)
}
notification.show(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID)
// Reset download information
errorThrown = true
isDownloading = false
}
}

View File

@ -0,0 +1,111 @@
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.preference.getOrDefault
import eu.kanade.tachiyomi.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 var downloadsDir = preferences.downloadsDirectory().getOrDefault().let {
UniFile.fromUri(context, Uri.parse(it))
}
init {
preferences.downloadsDirectory().asObservable()
.skip(1)
.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 source if it exists.
*
* @param source the source to query.
*/
fun findSourceDir(source: Source): UniFile? {
return downloadsDir.findFile(getSourceDirName(source))
}
/**
* 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 = findSourceDir(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,130 +3,177 @@ package eu.kanade.tachiyomi.data.download
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.NetworkInfo.State.CONNECTED
import android.net.NetworkInfo.State.DISCONNECTED
import android.os.IBinder
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.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R
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 rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
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() {
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) {
context.startService(Intent(context, DownloadService::class.java))
}
/**
* Stops this service.
*
* @param context the application context.
*/
fun stop(context: Context) {
context.stopService(Intent(context, DownloadService::class.java))
}
}
val downloadManager: DownloadManager by injectLazy()
val preferences: PreferencesHelper by injectLazy()
/**
* Download manager.
*/
private val downloadManager: DownloadManager by injectLazy()
private var wakeLock: PowerManager.WakeLock? = null
private var networkChangeSubscription: Subscription? = null
private var queueRunningSubscription: Subscription? = null
private var isRunning: Boolean = false
/**
* Preferences helper.
*/
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() {
super.onCreate()
createWakeLock()
listenQueueRunningChanges()
runningRelay.call(true)
subscriptions = CompositeSubscription()
listenDownloaderState()
listenNetworkChanges()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return Service.START_STICKY
}
/**
* Called when the service is destroyed.
*/
override fun onDestroy() {
queueRunningSubscription?.unsubscribe()
networkChangeSubscription?.unsubscribe()
downloadManager.destroySubscriptions()
destroyWakeLock()
runningRelay.call(false)
subscriptions.unsubscribe()
downloadManager.stopDownloads()
wakeLock.releaseIfNeeded()
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? {
return null
}
/**
* Listens to network changes.
*
* @see onNetworkStateChanged
*/
private fun listenNetworkChanges() {
networkChangeSubscription = ReactiveNetwork().enableInternetCheck()
.observeConnectivity(applicationContext)
subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ 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))
}
}
}
.subscribe({ state -> onNetworkStateChanged(state)
}, { error ->
toast(R.string.download_queue_error)
stopSelf()
})
}
private fun listenQueueRunningChanges() {
queueRunningSubscription = downloadManager.runningSubject.subscribe { running ->
isRunning = running
/**
* Called when the network state changes.
*
* @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)
acquireWakeLock()
wakeLock.acquireIfNeeded()
else
releaseWakeLock()
wakeLock.releaseIfNeeded()
}
}
private fun createWakeLock() {
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
/**
* Releases the wake lock if it's held.
*/
fun PowerManager.WakeLock.releaseIfNeeded() {
if (isHeld) release()
}
private fun destroyWakeLock() {
if (wakeLock != null && wakeLock!!.isHeld) {
wakeLock!!.release()
wakeLock = null
}
}
fun acquireWakeLock() {
if (wakeLock != null && !wakeLock!!.isHeld) {
wakeLock!!.acquire()
}
}
fun releaseWakeLock() {
if (wakeLock != null && wakeLock!!.isHeld) {
wakeLock!!.release()
}
/**
* Acquires the wake lock if it's not held.
*/
fun PowerManager.WakeLock.acquireIfNeeded() {
if (!isHeld) acquire()
}
}

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.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
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? HttpSource ?: 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,463 @@
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.preference.getOrDefault
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
import eu.kanade.tachiyomi.util.*
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
/**
* 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 {
if (notifier.paused) {
notifier.paused = false
notifier.onDownloadPaused()
} else if (notifier.isSingleChapter && !notifier.errorThrown) {
notifier.isSingleChapter = false
} else {
notifier.dismiss()
}
}
}
/**
* Pauses the downloader
*/
fun pause() {
destroySubscriptions()
queue
.filter { it.status == Download.DOWNLOADING }
.forEach { it.status = Download.QUEUE }
notifier.paused = true
}
/**
* Removes everything from the queue.
*
* @param isNotification value that determines if status is set (needed for view updates)
*/
fun clearQueue(isNotification: Boolean = false) {
destroySubscriptions()
//Needed to update the chapter view
if (isNotification) {
queue
.filter { it.status == Download.QUEUE }
.forEach { it.status = Download.NOT_DOWNLOADED }
}
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? HttpSource ?: 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
// Initial multi-thread
notifier.multipleDownloadThreads = preferences.downloadThreads().getOrDefault() > 1
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.fetchPageList(download.chapter)
.doOnNext { pages ->
if (pages.isEmpty()) {
throw Exception("Page list is empty")
}
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: HttpSource, tmpDir: UniFile, filename: String): Observable<UniFile> {
page.status = Page.DOWNLOAD_IMAGE
page.progress = 0
return source.fetchImage(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.
?: DiskUtil.findImageMime { file.openInputStream() }
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()) {
if (notifier.isSingleChapter && !notifier.errorThrown) {
notifier.onDownloadCompleted(download, queue)
}
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

@ -2,14 +2,11 @@ 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 eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import rx.subjects.PublishSubject
import java.io.File
class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
lateinit var directory: File
class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) {
var pages: List<Page>? = null

View File

@ -1,38 +1,52 @@
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.source.model.Page
import eu.kanade.tachiyomi.data.download.DownloadStore
import eu.kanade.tachiyomi.source.model.Page
import rx.Observable
import rx.subjects.PublishSubject
import java.util.concurrent.CopyOnWriteArrayList
class DownloadQueue(private val queue: MutableList<Download> = 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 removeSubject = PublishSubject.create<Download>()
private val updatedRelay = PublishRelay.create<Unit>()
fun add(download: Download): Boolean {
download.setStatusSubject(statusSubject)
download.status = Download.QUEUE
return queue.add(download)
fun addAll(downloads: List<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) {
val removed = queue.remove(download)
store.remove(download)
download.setStatusSubject(null)
if (removed) {
removeSubject.onNext(download)
updatedRelay.call(Unit)
}
}
fun del(chapter: Chapter) {
find { it.chapter.id == chapter.id }?.let { del(it) }
fun remove(chapter: Chapter) {
find { it.chapter.id == chapter.id }?.let { remove(it) }
}
fun clear() {
queue.forEach { del(it) }
queue.forEach { download ->
download.setStatusSubject(null)
}
queue.clear()
store.clear()
updatedRelay.call(Unit)
}
fun getActiveDownloads(): Observable<Download> =
@ -40,7 +54,9 @@ class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayL
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
fun getRemovedObservable(): Observable<Download> = removeSubject.onBackpressureBuffer()
fun getUpdatedObservable(): Observable<List<Download>> = updatedRelay.onBackpressureBuffer()
.startWith(Unit)
.map { this }
fun getProgressObservable(): Observable<Download> {
return statusSubject.onBackpressureBuffer()

View File

@ -8,7 +8,7 @@ import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.GlideModule
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.network.NetworkHelper
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.InputStream

View File

@ -0,0 +1,35 @@
package eu.kanade.tachiyomi.data.glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.data.DataFetcher
import java.io.File
import java.io.IOException
import java.io.InputStream
open class FileFetcher(private val file: File) : DataFetcher<InputStream> {
private var data: InputStream? = null
override fun loadData(priority: Priority): InputStream {
data = file.inputStream()
return data!!
}
override fun cleanup() {
data?.let { data ->
try {
data.close()
} catch (e: IOException) {
// Ignore
}
}
}
override fun cancel() {
// Do nothing.
}
override fun getId(): String {
return file.toString()
}
}

View File

@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.data.glide
import eu.kanade.tachiyomi.data.database.models.Manga
import java.io.File
open class MangaFileFetcher(private val file: File, private val manga: Manga) : FileFetcher(file) {
/**
* Returns the id for this manga's cover.
*
* Appending the file's modified date to the url, we can force Glide to skip its memory and disk
* lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when
* the file has changed. If the file doesn't exist it will append a 0.
*/
override fun getId(): String {
return manga.thumbnail_url + file.lastModified()
}
}

View File

@ -3,20 +3,21 @@ package eu.kanade.tachiyomi.data.glide
import android.content.Context
import android.util.LruCache
import com.bumptech.glide.Glide
import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher
import com.bumptech.glide.load.data.DataFetcher
import com.bumptech.glide.load.model.*
import com.bumptech.glide.load.model.stream.StreamModelLoader
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.online.HttpSource
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.InputStream
/**
* A class for loading a cover associated with a [Manga] that can be present in our own cache.
* Coupled with [MangaDataFetcher], this class allows to implement the following flow:
* Coupled with [MangaUrlFetcher], this class allows to implement the following flow:
*
* - Check in RAM LRU.
* - Check in disk LRU.
@ -30,17 +31,17 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
/**
* Cover cache where persistent covers are stored.
*/
val coverCache: CoverCache by injectLazy()
private val coverCache: CoverCache by injectLazy()
/**
* Source manager.
*/
val sourceManager: SourceManager by injectLazy()
private val sourceManager: SourceManager by injectLazy()
/**
* Base network loader.
*/
private val baseLoader = Glide.buildModelLoader(GlideUrl::class.java,
private val baseUrlLoader = Glide.buildModelLoader(GlideUrl::class.java,
InputStream::class.java, context)
/**
@ -52,7 +53,7 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
/**
* Map where request headers are stored for a source.
*/
private val cachedHeaders = hashMapOf<Int, LazyHeaders>()
private val cachedHeaders = hashMapOf<Long, LazyHeaders>()
/**
* Factory class for creating [MangaModelLoader] instances.
@ -66,7 +67,7 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
}
/**
* Returns a [MangaDataFetcher] for the given manga or null if the url is empty.
* Returns a fetcher for the given manga or null if the url is empty.
*
* @param manga the model.
* @param width the width of the view where the resource will be loaded.
@ -78,22 +79,33 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
// Check thumbnail is not null or empty
val url = manga.thumbnail_url
if (url.isNullOrEmpty()) {
if (url == null || url.isEmpty()) {
return null
}
// Obtain the request url and the file for this url from the LRU cache, or calculate it
// and add them to the cache.
val (glideUrl, file) = lruCache.get(url) ?:
Pair(GlideUrl(url, getHeaders(manga)), coverCache.getCoverFile(url!!)).apply {
lruCache.put(url, this)
}
if (url.startsWith("http")) {
val source = sourceManager.get(manga.source) as? HttpSource
// Get the network fetcher for this request url.
val networkFetcher = baseLoader.getResourceFetcher(glideUrl, width, height)
// Obtain the request url and the file for this url from the LRU cache, or calculate it
// and add them to the cache.
val (glideUrl, file) = lruCache.get(url) ?:
Pair(GlideUrl(url, getHeaders(manga, source)), coverCache.getCoverFile(url)).apply {
lruCache.put(url, this)
}
// Return an instance of our fetcher providing the needed elements.
return MangaDataFetcher(networkFetcher, file, manga)
// Get the resource fetcher for this request url.
val networkFetcher = source?.let { OkHttpStreamFetcher(it.client, glideUrl) }
?: baseUrlLoader.getResourceFetcher(glideUrl, width, height)
// Return an instance of the fetcher providing the needed elements.
return MangaUrlFetcher(networkFetcher, file, manga)
} else {
// Get the file from the url, removing the scheme if present.
val file = File(url.substringAfter("file://"))
// Return an instance of the fetcher providing the needed elements.
return MangaFileFetcher(file, manga)
}
}
/**
@ -101,8 +113,9 @@ class MangaModelLoader(context: Context) : StreamModelLoader<Manga> {
*
* @param manga the model.
*/
fun getHeaders(manga: Manga): Headers {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return LazyHeaders.DEFAULT
fun getHeaders(manga: Manga, source: HttpSource?): Headers {
if (source == null) return LazyHeaders.DEFAULT
return cachedHeaders.getOrPut(manga.source) {
LazyHeaders.Builder().apply {
val nullStr: String? = null

View File

@ -18,13 +18,12 @@ import java.io.InputStream
* @param file the file where this cover should be. It may exists or not.
* @param manga the manga of the cover to load.
*/
class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
private val file: File,
private val manga: Manga)
: DataFetcher<InputStream> {
class MangaUrlFetcher(private val networkFetcher: DataFetcher<InputStream>,
private val file: File,
private val manga: Manga)
: MangaFileFetcher(file, manga) {
@Throws(Exception::class)
override fun loadData(priority: Priority): InputStream? {
override fun loadData(priority: Priority): InputStream {
if (manga.favorite) {
synchronized(file) {
if (!file.exists()) {
@ -51,7 +50,7 @@ class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
}
}
}
return file.inputStream()
return super.loadData(priority)
} else {
if (file.exists()) {
file.delete()
@ -60,22 +59,12 @@ class MangaDataFetcher(private val networkFetcher: DataFetcher<InputStream>,
}
}
/**
* Returns the id for this manga's cover.
*
* Appending the file's modified date to the url, we can force Glide to skip its memory and disk
* lookup step and fetch from our custom cache. This allows us to invalidate Glide's cache when
* the file has changed. If the file doesn't exist it will append a 0.
*/
override fun getId(): String {
return manga.thumbnail_url + file.lastModified()
}
override fun cancel() {
networkFetcher.cancel()
}
override fun cleanup() {
super.cleanup()
networkFetcher.cleanup()
}

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

@ -2,10 +2,8 @@ package eu.kanade.tachiyomi.data.library
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.IBinder
import android.os.PowerManager
@ -14,17 +12,19 @@ import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
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.online.OnlineSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.AndroidComponentUtil
import eu.kanade.tachiyomi.util.notification
import eu.kanade.tachiyomi.util.notificationManager
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import eu.kanade.tachiyomi.util.*
import rx.Observable
import rx.Subscription
import rx.schedulers.Schedulers
@ -57,6 +57,11 @@ class LibraryUpdateService : Service() {
*/
val preferences: PreferencesHelper by injectLazy()
/**
* Download Manager
*/
val downloadManager: DownloadManager by injectLazy()
/**
* Wake lock that will be held until the service is destroyed.
*/
@ -67,13 +72,20 @@ class LibraryUpdateService : Service() {
*/
private var subscription: Subscription? = null
/**
* Pending intent of action that cancels the library update
*/
private val cancelPendingIntent by lazy {NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)}
/**
* Id of the library update notification.
*/
private val notificationId: Int
get() = Constants.NOTIFICATION_LIBRARY_ID
private var notificationBitmap: Bitmap? = null
private val notificationBitmap by lazy {
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
companion object {
@ -141,8 +153,6 @@ class LibraryUpdateService : Service() {
*/
override fun onDestroy() {
subscription?.unsubscribe()
notificationBitmap?.recycle()
notificationBitmap = null
destroyWakeLock()
super.onDestroy()
}
@ -171,10 +181,6 @@ class LibraryUpdateService : Service() {
// Update favorite manga. Destroy service when completed or in case of an error.
subscription = Observable
.defer {
if (notificationBitmap == null) {
notificationBitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
val mangaList = getMangaToUpdate(intent)
// Update either chapter list or manga details.
@ -213,11 +219,11 @@ class LibraryUpdateService : Service() {
.filter { it.category in categoriesToUpdate }
.distinctBy { it.id }
else
db.getFavoriteMangas().executeAsBlocking().distinctBy { it.id }
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
}
if (!intent.getBooleanExtra(UPDATE_DETAILS, false) && preferences.updateOnlyNonCompleted()) {
listToUpdate = listToUpdate.filter { it.status != Manga.COMPLETED }
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
}
return listToUpdate
@ -235,50 +241,82 @@ class LibraryUpdateService : Service() {
fun updateChapterList(mangaToUpdate: List<Manga>): Observable<Manga> {
// Initialize the variables holding the progress of the updates.
val count = AtomicInteger(0)
// List containing new updates
val newUpdates = ArrayList<Manga>()
// list containing failed updates
val failedUpdates = ArrayList<Manga>()
val cancelIntent = PendingIntent.getBroadcast(this, 0,
Intent(this, CancelUpdateReceiver::class.java), 0)
// List containing categories that get included in downloads.
val categoriesToDownload = preferences.downloadNewCategories().getOrDefault().map(String::toInt)
// Boolean to determine if user wants to automatically download new chapters.
val downloadNew = preferences.downloadNew().getOrDefault()
// Boolean to determine if DownloadManager has downloads
var hasDownloads = false
// Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate)
// Notify manga that will update.
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) }
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) }
// Update the chapters of the manga.
.concatMap { manga ->
updateManga(manga)
// If there's any error, return empty update and continue.
.onErrorReturn {
failedUpdates.add(manga)
Pair(0, 0)
Pair(emptyList<Chapter>(), emptyList<Chapter>())
}
// Filter out mangas without new chapters (or failed).
.filter { pair -> pair.first > 0 }
.filter { pair -> pair.first.isNotEmpty() }
.doOnNext {
if (downloadNew) {
if (categoriesToDownload.isEmpty() || manga.category in categoriesToDownload) {
downloadChapters(manga, it.first)
hasDownloads = true
}
}
}
// Convert to the manga that contains new chapters.
.map { manga }
}
// Add manga with new chapters to the list.
.doOnNext { newUpdates.add(it) }
.doOnNext { manga ->
// Set last updated time
manga.last_update = Date().time
db.updateLastUpdated(manga).executeAsBlocking()
// Add to the list
newUpdates.add(manga)
}
// Notify result of the overall update.
.doOnCompleted {
if (newUpdates.isEmpty()) {
cancelNotification()
} else {
showResultNotification(newUpdates, failedUpdates)
if (downloadNew) {
if (hasDownloads) {
DownloadService.start(this)
}
}
}
LibraryUpdateTrigger.setupTask(this)
}
}
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
// we need to get the chapters from the db so we have chapter ids
val mangaChapters = db.getChapters(manga).executeAsBlocking()
val dbChapters = chapters.map {
mangaChapters.find { mangaChapter -> mangaChapter.url == it.url }!!
}
downloadManager.downloadChapters(manga, dbChapters)
}
/**
* Updates the chapters for the given manga and adds them to the database.
*
* @param manga the manga to update.
* @return a pair of the inserted and removed chapters.
*/
fun updateManga(manga: Manga): Observable<Pair<Int, Int>> {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return Observable.empty()
fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty()
return source.fetchChapterList(manga)
.map { syncChaptersWithSource(db, it, manga, source) }
}
@ -296,22 +334,20 @@ class LibraryUpdateService : Service() {
// 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) }
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) }
// Update the details of the manga.
.concatMap { manga ->
val source = sourceManager.get(manga.source) as? OnlineSource
val source = sourceManager.get(manga.source) as? HttpSource
?: return@concatMap Observable.empty<Manga>()
source.fetchMangaDetails(manga)
.doOnNext { networkManga ->
.map { networkManga ->
manga.copyFrom(networkManga)
db.insertManga(manga).executeAsBlocking()
manga
}
.onErrorReturn { manga }
}
@ -328,7 +364,7 @@ class LibraryUpdateService : Service() {
* @return the body of the notification to display.
*/
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
return with(StringBuilder()) {
return buildString {
if (updates.isEmpty()) {
append(getString(R.string.notification_no_new_chapters))
append("\n")
@ -336,7 +372,7 @@ class LibraryUpdateService : Service() {
append(getString(R.string.notification_new_chapters))
for (manga in updates) {
append("\n")
append(manga.title)
append(manga.title.chop(45))
}
}
if (!failedUpdates.isEmpty()) {
@ -344,10 +380,9 @@ class LibraryUpdateService : Service() {
append(getString(R.string.notification_manga_update_failed))
for (manga in failedUpdates) {
append("\n")
append(manga.title)
append(manga.title.chop(45))
}
}
toString()
}
}
@ -376,7 +411,7 @@ class LibraryUpdateService : Service() {
* @param body the body of the notification.
*/
private fun showNotification(title: String, body: String) {
notificationManager.notify(notificationId, notification() {
notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
setContentTitle(title)
@ -392,7 +427,7 @@ class LibraryUpdateService : Service() {
* @param total the total progress.
*/
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)
setLargeIcon(notificationBitmap)
setContentTitle(manga.title)
@ -413,7 +448,7 @@ class LibraryUpdateService : Service() {
val title = getString(R.string.notification_update_completed)
val body = getUpdatedMangasBody(updates, failed)
notificationManager.notify(notificationId, notification() {
notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
setContentTitle(title)
@ -439,19 +474,4 @@ class LibraryUpdateService : Service() {
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Class that stops updating the library.
*/
class CancelUpdateReceiver : BroadcastReceiver() {
/**
* Method called when user wants a library update.
* @param context the application context.
* @param intent the intent received.
*/
override fun onReceive(context: Context, intent: Intent) {
LibraryUpdateService.stop(context)
context.notificationManager.cancel(Constants.NOTIFICATION_LIBRARY_ID)
}
}
}

View File

@ -1,52 +0,0 @@
package eu.kanade.tachiyomi.data.library
import android.content.Context
import com.google.android.gms.gcm.*
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 LibraryUpdateTrigger : GcmTaskService() {
override fun onInitializeTasks() {
setupTask(this)
}
override fun onRunTask(params: TaskParams): Int {
LibraryUpdateService.start(this)
return GcmNetworkManager.RESULT_SUCCESS
}
companion object {
fun setupTask(context: Context, 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)
Task.NETWORK_STATE_UNMETERED
else
Task.NETWORK_STATE_ANY
val task = PeriodicTask.Builder()
.setService(LibraryUpdateTrigger::class.java)
.setTag("Library periodic update")
.setPeriod(interval * 60 * 60L)
.setFlex(5 * 60)
.setRequiredNetwork(wifiRestriction)
.setRequiresCharging(acRestriction)
.setUpdateCurrent(true)
.setPersisted(true)
.build()
GcmNetworkManager.getInstance(context).schedule(task)
}
}
fun cancelTask(context: Context) {
GcmNetworkManager.getInstance(context).cancelAllTasks(LibraryUpdateTrigger::class.java)
}
}
}

View File

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

View File

@ -1,51 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync
import android.content.Context
import android.support.annotation.CallSuper
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class MangaSyncService(private val context: Context, val id: Int) {
val preferences: PreferencesHelper by injectLazy()
val networkService: NetworkHelper by injectLazy()
open val client: OkHttpClient
get() = networkService.client
// Name of the manga sync service to display
abstract val name: String
abstract fun login(username: String, password: String): Completable
open val isLogged: Boolean
get() = !getUsername().isEmpty() &&
!getPassword().isEmpty()
abstract fun add(manga: MangaSync): Observable<MangaSync>
abstract fun update(manga: MangaSync): Observable<MangaSync>
abstract fun bind(manga: MangaSync): Observable<MangaSync>
abstract fun getStatus(status: Int): String
fun saveCredentials(username: String, password: String) {
preferences.setMangaSyncCredentials(this, username, password)
}
@CallSuper
open fun logout() {
preferences.setMangaSyncCredentials(this, "", "")
}
fun getUsername() = preferences.mangaSyncUsername(this)
fun getPassword() = preferences.mangaSyncPassword(this)
}

View File

@ -1,74 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.MangaSync
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
class UpdateMangaSyncService : Service() {
val syncManager: MangaSyncManager by injectLazy()
val db: DatabaseHelper by injectLazy()
private lateinit var subscriptions: CompositeSubscription
override fun onCreate() {
super.onCreate()
subscriptions = CompositeSubscription()
}
override fun onDestroy() {
subscriptions.unsubscribe()
super.onDestroy()
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val manga = intent.getSerializableExtra(EXTRA_MANGASYNC)
if (manga != null) {
updateLastChapterRead(manga as MangaSync, startId)
return Service.START_REDELIVER_INTENT
} else {
stopSelf(startId)
return Service.START_NOT_STICKY
}
}
override fun onBind(intent: Intent): IBinder? {
return null
}
private fun updateLastChapterRead(mangaSync: MangaSync, startId: Int) {
val sync = syncManager.getService(mangaSync.sync_id)
if (sync == null) {
stopSelf(startId)
return
}
subscriptions.add(Observable.defer { sync.update(mangaSync) }
.flatMap { db.insertMangaSync(mangaSync).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ stopSelf(startId) },
{ stopSelf(startId) }))
}
companion object {
private val EXTRA_MANGASYNC = "extra_mangasync"
@JvmStatic
fun start(context: Context, mangaSync: MangaSync) {
val intent = Intent(context, UpdateMangaSyncService::class.java)
intent.putExtra(EXTRA_MANGASYNC, mangaSync)
context.startService(intent)
}
}
}

View File

@ -1,132 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,17 +0,0 @@
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

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

View File

@ -1,29 +0,0 @@
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

@ -1,222 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync.myanimelist
import android.content.Context
import android.net.Uri
import android.util.Xml
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.network.asObservable
import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText
import okhttp3.Credentials
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.RequestBody
import org.jsoup.Jsoup
import org.xmlpull.v1.XmlSerializer
import rx.Completable
import rx.Observable
import java.io.StringWriter
class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(context, id) {
private lateinit var headers: Headers
companion object {
val BASE_URL = "https://myanimelist.net"
private val ENTRY_TAG = "entry"
private val CHAPTER_TAG = "chapter"
private val SCORE_TAG = "score"
private val STATUS_TAG = "status"
val READING = 1
val COMPLETED = 2
val ON_HOLD = 3
val DROPPED = 4
val PLAN_TO_READ = 6
val DEFAULT_STATUS = READING
val DEFAULT_SCORE = 0
}
init {
val username = getUsername()
val password = getPassword()
if (!username.isEmpty() && !password.isEmpty()) {
createHeaders(username, password)
}
}
override val name: String
get() = "MyAnimeList"
fun getLoginUrl() = Uri.parse(BASE_URL).buildUpon()
.appendEncodedPath("api/account/verify_credentials.xml")
.toString()
fun getSearchUrl(query: String) = Uri.parse(BASE_URL).buildUpon()
.appendEncodedPath("api/manga/search.xml")
.appendQueryParameter("q", query)
.toString()
fun getListUrl(username: String) = Uri.parse(BASE_URL).buildUpon()
.appendPath("malappinfo.php")
.appendQueryParameter("u", username)
.appendQueryParameter("status", "all")
.appendQueryParameter("type", "manga")
.toString()
fun getUpdateUrl(manga: MangaSync) = Uri.parse(BASE_URL).buildUpon()
.appendEncodedPath("api/mangalist/update")
.appendPath("${manga.remote_id}.xml")
.toString()
fun getAddUrl(manga: MangaSync) = Uri.parse(BASE_URL).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${manga.remote_id}.xml")
.toString()
override fun login(username: String, password: String): Completable {
createHeaders(username, password)
return client.newCall(GET(getLoginUrl(), headers))
.asObservable()
.doOnNext { it.close() }
.doOnNext { if (it.code() != 200) throw Exception("Login error") }
.toCompletable()
}
fun search(query: String): Observable<List<MangaSync>> {
return client.newCall(GET(getSearchUrl(query), headers))
.asObservable()
.map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("entry")) }
.filter { it.select("type").text() != "Novel" }
.map {
MangaSync.create(id).apply {
title = it.selectText("title")!!
remote_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
}
}
.toList()
}
// MAL doesn't support score with decimals
fun getList(): Observable<List<MangaSync>> {
return networkService.forceCacheClient
.newCall(GET(getListUrl(getUsername()), headers))
.asObservable()
.map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("manga")) }
.map {
MangaSync.create(id).apply {
title = it.selectText("series_title")!!
remote_id = it.selectInt("series_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = it.selectInt("my_status")
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("series_chapters")
}
}
.toList()
}
override fun update(manga: MangaSync): Observable<MangaSync> {
return Observable.defer {
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
manga.status = COMPLETED
}
client.newCall(POST(getUpdateUrl(manga), headers, getMangaPostPayload(manga)))
.asObservable()
.doOnNext { it.close() }
.doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
.map { manga }
}
}
override fun add(manga: MangaSync): Observable<MangaSync> {
return Observable.defer {
client.newCall(POST(getAddUrl(manga), headers, getMangaPostPayload(manga)))
.asObservable()
.doOnNext { it.close() }
.doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
.map { manga }
}
}
private fun getMangaPostPayload(manga: MangaSync): RequestBody {
val xml = Xml.newSerializer()
val writer = StringWriter()
with(xml) {
setOutput(writer)
startDocument("UTF-8", false)
startTag("", ENTRY_TAG)
// Last chapter read
if (manga.last_chapter_read != 0) {
inTag(CHAPTER_TAG, manga.last_chapter_read.toString())
}
// Manga status in the list
inTag(STATUS_TAG, manga.status.toString())
// Manga score
inTag(SCORE_TAG, manga.score.toString())
endTag("", ENTRY_TAG)
endDocument()
}
val form = FormBody.Builder()
form.add("data", writer.toString())
return form.build()
}
fun XmlSerializer.inTag(tag: String, body: String, namespace: String = "") {
startTag(namespace, tag)
text(body)
endTag(namespace, tag)
}
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 -> ""
}
}
fun createHeaders(username: String, password: String) {
val builder = Headers.Builder()
builder.add("Authorization", Credentials.basic(username, password))
builder.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
headers = builder.build()
}
}

View File

@ -0,0 +1,57 @@
package eu.kanade.tachiyomi.data.notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.support.v4.content.FileProvider
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.ui.download.DownloadActivity
import eu.kanade.tachiyomi.util.getUriCompat
import java.io.File
/**
* Class that manages [PendingIntent] of activity's
*/
object NotificationHandler {
/**
* Returns [PendingIntent] that starts a download activity.
*
* @param context context of application
*/
internal fun openDownloadManagerPendingActivity(context: Context): PendingIntent {
val intent = Intent(context, DownloadActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
}
return PendingIntent.getActivity(context, 0, intent, 0)
}
/**
* Returns [PendingIntent] that starts a gallery activity
*
* @param context context of application
* @param file file containing image
*/
internal fun openImagePendingActivity(context: Context, file: File): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply {
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file)
setDataAndType(uri, "image/*")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Returns [PendingIntent] that prompts user with apk install intent
*
* @param context context
* @param file file of apk that is installed
*/
fun installApkPendingActivity(context: Context, file: File): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply {
val uri = file.getUriCompat(context)
setDataAndType(uri, "application/vnd.android.package-archive")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getActivity(context, 0, intent, 0)
}
}

View File

@ -0,0 +1,277 @@
package eu.kanade.tachiyomi.data.notification
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.*
import uy.kohesive.injekt.injectLazy
import java.io.File
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/**
* Global [BroadcastReceiver] that runs on UI thread
* Pending Broadcasts should be made from here.
* NOTE: Use local broadcasts if possible.
*/
class NotificationReceiver : BroadcastReceiver() {
/**
* Download manager.
*/
private val downloadManager: DownloadManager by injectLazy()
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
// Dismiss notification
ACTION_DISMISS_NOTIFICATION -> dismissNotification(context, intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Resume the download service
ACTION_RESUME_DOWNLOADS -> DownloadService.start(context)
// Clear the download queue
ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true)
// Launch share activity and dismiss notification
ACTION_SHARE_IMAGE -> shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Delete image from path and dismiss notification
ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Constants.NOTIFICATION_LIBRARY_ID)
// Open reader activity
ACTION_OPEN_CHAPTER -> {
openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1),
intent.getLongExtra(EXTRA_CHAPTER_ID, -1))
}
}
}
/**
* Dismiss the notification
*
* @param notificationId the id of the notification
*/
private fun dismissNotification(context: Context, notificationId: Int) {
context.notificationManager.cancel(notificationId)
}
/**
* Called to start share intent to share image
*
* @param context context of application
* @param path path of file
* @param notificationId id of notification
*/
private fun shareImage(context: Context, path: String, notificationId: Int) {
// Create intent
val intent = Intent(Intent.ACTION_SEND).apply {
val uri = File(path).getUriCompat(context)
putExtra(Intent.EXTRA_STREAM, uri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
type = "image/*"
}
// Dismiss notification
dismissNotification(context, notificationId)
// Launch share activity
context.startActivity(intent)
}
/**
* Starts reader activity
*
* @param context context of application
* @param mangaId id of manga
* @param chapterId id of chapter
*/
internal fun openChapter(context: Context, mangaId: Long, chapterId: Long) {
val db = DatabaseHelper(context)
val manga = db.getManga(mangaId).executeAsBlocking()
val chapter = db.getChapter(chapterId).executeAsBlocking()
if (manga != null && chapter != null) {
val intent = ReaderActivity.newIntent(context, manga, chapter).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
context.startActivity(intent)
} else {
context.toast(context.getString(R.string.chapter_error))
}
}
/**
* Called to delete image
*
* @param path path of file
* @param notificationId id of notification
*/
private fun deleteImage(context: Context, path: String, notificationId: Int) {
// Dismiss notification
dismissNotification(context, notificationId)
// Delete file
val file = File(path)
file.deleteIfExists()
DiskUtil.scanMedia(context, file)
}
/**
* Method called when user wants to stop a library update
*
* @param context context of application
* @param notificationId id of notification
*/
private fun cancelLibraryUpdate(context: Context, notificationId: Int) {
LibraryUpdateService.stop(context)
Handler().post { dismissNotification(context, notificationId) }
}
companion object {
private const val NAME = "NotificationReceiver"
// Called to launch share intent.
private const val ACTION_SHARE_IMAGE = "$ID.$NAME.SHARE_IMAGE"
// Called to delete image.
private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE"
// Called to cancel library update.
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE"
// Called to open chapter
private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER"
// Value containing file location.
private const val EXTRA_FILE_LOCATION = "$ID.$NAME.FILE_LOCATION"
// Called to resume downloads.
private const val ACTION_RESUME_DOWNLOADS = "$ID.$NAME.ACTION_RESUME_DOWNLOADS"
// Called to clear downloads.
private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS"
// Called to dismiss notification.
private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION"
// Value containing notification id.
private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID"
// Value containing manga id.
private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID"
// Value containing chapter id.
private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID"
/**
* Returns a [PendingIntent] that resumes the download of a chapter
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun resumeDownloadsPendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_RESUME_DOWNLOADS
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Returns a [PendingIntent] that clears the download queue
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun clearDownloadsPendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CLEAR_DOWNLOADS
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Returns [PendingIntent] that starts a service which dismissed the notification
*
* @param context context of application
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun dismissNotificationPendingBroadcast(context: Context, notificationId: Int): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_DISMISS_NOTIFICATION
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Returns [PendingIntent] that starts a service which cancels the notification and starts a share activity
*
* @param context context of application
* @param path location path of file
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun shareImagePendingBroadcast(context: Context, path: String, notificationId: Int): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_SHARE_IMAGE
putExtra(EXTRA_FILE_LOCATION, path)
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Returns [PendingIntent] that starts a service which removes an image from disk
*
* @param context context of application
* @param path location path of file
* @param notificationId id of notification
* @return [PendingIntent]
*/
internal fun deleteImagePendingBroadcast(context: Context, path: String, notificationId: Int): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_DELETE_IMAGE
putExtra(EXTRA_FILE_LOCATION, path)
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Returns [PendingIntent] that start a reader activity containing chapter.
*
* @param context context of application
* @param manga manga of chapter
* @param chapter chapter that needs to be opened
*/
internal fun openChapterPendingBroadcast(context: Context, manga: Manga, chapter: Chapter): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_OPEN_CHAPTER
putExtra(EXTRA_MANGA_ID, manga.id)
putExtra(EXTRA_CHAPTER_ID, chapter.id)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Returns [PendingIntent] that starts a service which stops the library update
*
* @param context context of application
* @return [PendingIntent]
*/
internal fun cancelLibraryUpdatePendingBroadcast(context: Context): PendingIntent {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_LIBRARY_UPDATE
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
}
}

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.R
* in the file "keys.xml". By using this class we can define preferences in one place and get them
* referenced here.
*/
@Suppress("HasPlatformType")
class PreferenceKeys(context: Context) {
val theme = context.getString(R.string.pref_theme_key)
@ -40,6 +41,8 @@ class PreferenceKeys(context: Context) {
val readerTheme = context.getString(R.string.pref_reader_theme_key)
val cropBorders = context.getString(R.string.pref_crop_borders_key)
val readWithTapping = context.getString(R.string.pref_read_with_tapping_key)
val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key)
@ -50,9 +53,9 @@ class PreferenceKeys(context: Context) {
val updateOnlyNonCompleted = context.getString(R.string.pref_update_only_non_completed_key)
val autoUpdateMangaSync = context.getString(R.string.pref_auto_update_manga_sync_key)
val autoUpdateTrack = context.getString(R.string.pref_auto_update_manga_sync_key)
val askUpdateMangaSync = context.getString(R.string.pref_ask_update_manga_sync_key)
val askUpdateTrack = context.getString(R.string.pref_ask_update_manga_sync_key)
val lastUsedCatalogueSource = context.getString(R.string.pref_last_catalogue_source_key)
@ -82,18 +85,28 @@ class PreferenceKeys(context: Context) {
val filterUnread = context.getString(R.string.pref_filter_unread_key)
val librarySortingMode = context.getString(R.string.pref_library_sorting_mode_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"
val downloadNew = context.getString(R.string.pref_download_new_key)
fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId"
val downloadNewCategories = context.getString(R.string.pref_download_new_categories_key)
fun syncUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
val libraryAsList = context.getString(R.string.pref_display_library_as_list)
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
fun trackToken(syncId: Int) = "track_token_$syncId"
val libraryAsList = context.getString(R.string.pref_display_library_as_list)
val lang = context.getString(R.string.pref_language_key)
}

View File

@ -1,36 +1,30 @@
package eu.kanade.tachiyomi.data.preference
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.preference.PreferenceManager
import com.f2prateek.rx.preferences.Preference
import com.f2prateek.rx.preferences.RxSharedPreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.source.Source
import java.io.File
import java.io.IOException
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
class PreferencesHelper(context: Context) {
fun Preference<Boolean>.invert(): Boolean = getOrDefault().let { set(!it); !it }
class PreferencesHelper(val context: Context) {
val keys = PreferenceKeys(context)
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val rxPrefs = RxSharedPreferences.create(prefs)
private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath +
File.separator + context.getString(R.string.app_name), "downloads")
init {
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file
try {
File(downloadsDirectory().getOrDefault(), ".nomedia").createNewFile()
} catch (e: IOException) {
/* Ignore */
}
}
private val defaultDownloadsDir = Uri.fromFile(
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
context.getString(R.string.app_name), "downloads"))
fun startScreen() = prefs.getInt(keys.startScreen, 1)
@ -40,7 +34,7 @@ class PreferencesHelper(context: Context) {
fun rotation() = rxPrefs.getInteger(keys.rotation, 1)
fun enableTransitions() = rxPrefs.getBoolean(keys.enableTransitions, true)
fun pageTransitions() = rxPrefs.getBoolean(keys.enableTransitions, true)
fun showPageNumber() = rxPrefs.getBoolean(keys.showPageNumber, true)
@ -66,6 +60,8 @@ class PreferencesHelper(context: Context) {
fun readerTheme() = rxPrefs.getInteger(keys.readerTheme, 0)
fun cropBorders() = rxPrefs.getBoolean(keys.cropBorders, false)
fun readWithTapping() = rxPrefs.getBoolean(keys.readWithTapping, true)
fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false)
@ -76,11 +72,11 @@ class PreferencesHelper(context: Context) {
fun updateOnlyNonCompleted() = prefs.getBoolean(keys.updateOnlyNonCompleted, false)
fun autoUpdateMangaSync() = prefs.getBoolean(keys.autoUpdateMangaSync, true)
fun autoUpdateTrack() = prefs.getBoolean(keys.autoUpdateTrack, true)
fun askUpdateMangaSync() = prefs.getBoolean(keys.askUpdateMangaSync, false)
fun askUpdateTrack() = prefs.getBoolean(keys.askUpdateTrack, false)
fun lastUsedCatalogueSource() = rxPrefs.getInteger(keys.lastUsedCatalogueSource, -1)
fun lastUsedCatalogueSource() = rxPrefs.getLong(keys.lastUsedCatalogueSource, -1)
fun lastUsedCategory() = rxPrefs.getInteger(keys.lastUsedCategory, 0)
@ -88,7 +84,7 @@ class PreferencesHelper(context: Context) {
fun catalogueAsList() = rxPrefs.getBoolean(keys.catalogueAsList, false)
fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("EN"))
fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("en"))
fun sourceUsername(source: Source) = prefs.getString(keys.sourceUsername(source.id), "")
@ -101,18 +97,22 @@ class PreferencesHelper(context: Context) {
.apply()
}
fun mangaSyncUsername(sync: MangaSyncService) = prefs.getString(keys.syncUsername(sync.id), "")
fun trackUsername(sync: TrackService) = prefs.getString(keys.trackUsername(sync.id), "")
fun mangaSyncPassword(sync: MangaSyncService) = prefs.getString(keys.syncPassword(sync.id), "")
fun trackPassword(sync: TrackService) = prefs.getString(keys.trackPassword(sync.id), "")
fun setMangaSyncCredentials(sync: MangaSyncService, username: String, password: String) {
fun setTrackCredentials(sync: TrackService, username: String, password: String) {
prefs.edit()
.putString(keys.syncUsername(sync.id), username)
.putString(keys.syncPassword(sync.id), password)
.putString(keys.trackUsername(sync.id), username)
.putString(keys.trackPassword(sync.id), password)
.apply()
}
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath)
fun trackToken(sync: TrackService) = rxPrefs.getString(keys.trackToken(sync.id), "")
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
@ -134,8 +134,18 @@ class PreferencesHelper(context: Context) {
fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false)
fun librarySortingMode() = rxPrefs.getInteger(keys.librarySortingMode, 0)
fun librarySortingAscending() = rxPrefs.getBoolean("library_sorting_ascending", true)
fun automaticUpdates() = prefs.getBoolean(keys.automaticUpdates, false)
fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet())
fun downloadNew() = rxPrefs.getBoolean(keys.downloadNew, false)
fun downloadNewCategories() = rxPrefs.getStringSet(keys.downloadNewCategories, emptySet())
fun lang() = prefs.getString(keys.lang, "")
}

View File

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

View File

@ -1,61 +0,0 @@
package eu.kanade.tachiyomi.data.source
import android.Manifest.permission.READ_EXTERNAL_STORAGE
import android.content.Context
import android.os.Environment
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.source.online.YamlOnlineSource
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.Mintmanga
import eu.kanade.tachiyomi.data.source.online.russian.Readmanga
import eu.kanade.tachiyomi.util.hasPermission
import org.yaml.snakeyaml.Yaml
import timber.log.Timber
import java.io.File
open class SourceManager(private val context: Context) {
private val sourcesMap = createSources()
open fun get(sourceKey: Int): Source? {
return sourcesMap[sourceKey]
}
fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java)
private fun createOnlineSourceList(): List<Source> = listOf(
Batoto(1),
Mangahere(2),
Mangafox(3),
Kissmanga(4),
Readmanga(5),
Mintmanga(6),
Mangachan(7),
Readmangatoday(8),
Mangasee(9),
WieManga(10)
)
private fun createSources(): Map<Int, Source> = hashMapOf<Int, Source>().apply {
createOnlineSourceList().forEach { put(it.id, it) }
val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath +
File.separator + context.getString(R.string.app_name), "parsers")
if (parsersDir.exists() && context.hasPermission(READ_EXTERNAL_STORAGE)) {
val yaml = Yaml()
for (file in parsersDir.listFiles().filter { it.extension == "yml" }) {
try {
val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) }
YamlOnlineSource(map).let { put(it.id, it) }
} catch (e: Exception) {
Timber.e("Error loading source from file. Bad format?")
}
}
}
}
}

View File

@ -1,13 +0,0 @@
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,472 +0,0 @@
package eu.kanade.tachiyomi.data.source.online
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.network.asObservable
import eu.kanade.tachiyomi.data.network.newCallWithProgress
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.UrlUtil
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
/**
* A simple implementation for sources from a website.
*/
abstract class OnlineSource() : Source {
/**
* Network service.
*/
val network: NetworkHelper by injectLazy()
/**
* Chapter cache.
*/
val chapterCache: ChapterCache by injectLazy()
/**
* Preferences helper.
*/
val preferences: PreferencesHelper by injectLazy()
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
abstract val baseUrl: String
/**
* Language of the source.
*/
abstract val lang: Language
/**
* Whether the source has support for latest updates.
*/
abstract val supportsLatest : Boolean
/**
* Headers used for requests.
*/
val headers by lazy { headersBuilder().build() }
/**
* Genre filters.
*/
val filters by lazy { getFilterList() }
/**
* Default network client for doing requests.
*/
open val client: OkHttpClient
get() = network.client
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
open protected fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
}
/**
* Visible name of the source.
*/
override fun toString() = "$name (${lang.code})"
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page object where the information will be saved, like the list of manga,
* the current page and the next page url.
*/
open fun fetchPopularManga(page: MangasPage): Observable<MangasPage> = client
.newCall(popularMangaRequest(page))
.asObservable()
.map { response ->
popularMangaParse(response, page)
page
}
/**
* Returns the request for the popular manga given the page. Override only if it's needed to
* send different headers or request method like POST.
*
* @param page the page object.
*/
open protected fun popularMangaRequest(page: MangasPage): Request {
if (page.page == 1) {
page.url = popularMangaInitialUrl()
}
return GET(page.url, headers)
}
/**
* Returns the absolute url of the first page to popular manga.
*/
abstract protected fun popularMangaInitialUrl(): String
/**
* Parse the response from the site. It should add a list of manga and the absolute url to the
* next page (if it has a next one) to [page].
*
* @param response the response from the site.
* @param page the page object to be filled.
*/
abstract protected fun popularMangaParse(response: Response, page: MangasPage)
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method.
*
* @param page the page object where the information will be saved, like the list of manga,
* the current page and the next page url.
* @param query the search query.
*/
open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client
.newCall(searchMangaRequest(page, query, filters))
.asObservable()
.map { response ->
searchMangaParse(response, page, query, filters)
page
}
/**
* Returns the request for the search manga given the page. Override only if it's needed to
* send different headers or request method like POST.
*
* @param page the page object.
* @param query the search query.
*/
open protected fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) {
page.url = searchMangaInitialUrl(query, filters)
}
return GET(page.url, headers)
}
/**
* Returns the absolute url of the first page to popular manga.
*
* @param query the search query.
*/
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
* next page (if it has a next one) to [page].
*
* @param response the response from the site.
* @param page the page object to be filled.
* @param query the search query.
*/
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
* override this method.
*
* @param manga the manga to be updated.
*/
override fun fetchMangaDetails(manga: Manga): Observable<Manga> = client
.newCall(mangaDetailsRequest(manga))
.asObservable()
.map { response ->
Manga.create(manga.url, id).apply {
mangaDetailsParse(response, this)
initialized = true
}
}
/**
* Returns the request for updating a manga. Override only if it's needed to override the url,
* send different headers or request method like POST.
*
* @param manga the manga to be updated.
*/
open fun mangaDetailsRequest(manga: Manga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parse the response from the site. It should fill [manga].
*
* @param response the response from the site.
* @param manga the manga whose fields have to be filled.
*/
abstract protected fun mangaDetailsParse(response: Response, manga: Manga)
/**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method.
*
* @param manga the manga to look for chapters.
*/
override fun fetchChapterList(manga: Manga): Observable<List<Chapter>> = client
.newCall(chapterListRequest(manga))
.asObservable()
.map { response ->
mutableListOf<Chapter>().apply {
chapterListParse(response, this)
if (isEmpty()) {
throw Exception("No chapters found")
}
}
}
/**
* Returns the request for updating the chapter list. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param manga the manga to look for chapters.
*/
open protected fun chapterListRequest(manga: Manga): Request {
return GET(baseUrl + manga.url, headers)
}
/**
* Parse the response from the site. It should fill [chapters].
*
* @param response the response from the site.
* @param chapters the chapter list to be filled.
*/
abstract protected fun chapterListParse(response: Response, chapters: MutableList<Chapter>)
/**
* Returns an observable with the page list for a chapter. It tries to return the page list from
* the local cache, otherwise fallbacks to network calling [fetchPageListFromNetwork].
*
* @param chapter the chapter whose page list has to be fetched.
*/
final override fun fetchPageList(chapter: Chapter): Observable<List<Page>> = chapterCache
.getPageListFromCache(getChapterCacheKey(chapter))
.onErrorResumeNext { fetchPageListFromNetwork(chapter) }
/**
* Returns an observable with the page list for a chapter. Normally it's not needed to override
* this method.
*
* @param chapter the chapter whose page list has to be fetched.
*/
open fun fetchPageListFromNetwork(chapter: Chapter): Observable<List<Page>> = client
.newCall(pageListRequest(chapter))
.asObservable()
.map { response ->
if (!response.isSuccessful) {
throw Exception("Webpage sent ${response.code()} code")
}
mutableListOf<Page>().apply {
pageListParse(response, this)
if (isEmpty()) {
throw Exception("Page list is empty")
}
}
}
/**
* Returns the request for getting the page list. Override only if it's needed to override the
* url, send different headers or request method like POST.
*
* @param chapter the chapter whose page list has to be fetched
*/
open protected fun pageListRequest(chapter: Chapter): Request {
return GET(baseUrl + chapter.url, headers)
}
/**
* Parse the response from the site. It should fill [pages].
*
* @param response the response from the site.
* @param pages the page list to be filled.
*/
abstract protected fun pageListParse(response: Response, pages: MutableList<Page>)
/**
* Returns the key for the page list to be stored in [ChapterCache].
*/
private fun getChapterCacheKey(chapter: Chapter) = "$id${chapter.url}"
/**
* Returns an observable with the page containing the source url of the image. If there's any
* error, it will return null instead of throwing an exception.
*
* @param page the page whose source image has to be fetched.
*/
open protected fun fetchImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE
return client
.newCall(imageUrlRequest(page))
.asObservable()
.map { imageUrlParse(it) }
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
.doOnNext { page.imageUrl = it }
.map { page }
}
/**
* Returns the request for getting the url to the source image. Override only if it's needed to
* override the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
open protected fun imageUrlRequest(page: Page): Request {
return GET(page.url, headers)
}
/**
* Parse the response from the site. It should return the absolute url to the source image.
*
* @param response the response from the site.
*/
abstract protected fun imageUrlParse(response: Response): String
/**
* Returns an observable of the page with the downloaded image.
*
* @param page the page whose source image has to be downloaded.
*/
final override fun fetchImage(page: Page): Observable<Page> =
if (page.imageUrl.isNullOrEmpty())
fetchImageUrl(page).flatMap { getCachedImage(it) }
else
getCachedImage(page)
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
fun imageResponse(page: Page): Observable<Response> = client
.newCallWithProgress(imageRequest(page), page)
.asObservable()
.doOnNext {
if (!it.isSuccessful) {
it.close()
throw RuntimeException("Not a valid response")
}
}
/**
* Returns the request for getting the source image. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
open protected fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers)
}
/**
* Returns an observable of the page that gets the image from the chapter or fallbacks to
* network and copies it to the cache calling [cacheImage].
*
* @param page the page.
*/
fun getCachedImage(page: Page): Observable<Page> {
val imageUrl = page.imageUrl ?: return Observable.just(page)
return Observable.just(page)
.flatMap {
if (!chapterCache.isImageInCache(imageUrl)) {
cacheImage(page)
} else {
Observable.just(page)
}
}
.doOnNext {
page.imagePath = chapterCache.getImagePath(imageUrl)
page.status = Page.READY
}
.doOnError { page.status = Page.ERROR }
.onErrorReturn { page }
}
/**
* Returns an observable of the page that downloads the image to [ChapterCache].
*
* @param page the page.
*/
private fun cacheImage(page: Page): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE
return imageResponse(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
}
// Utility methods
fun fetchAllImageUrlsFromPageList(pages: List<Page>) = Observable.from(pages)
.filter { !it.imageUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
fun fetchRemainingImageUrlsFromPageList(pages: List<Page>) = Observable.from(pages)
.filter { it.imageUrl.isNullOrEmpty() }
.concatMap { fetchImageUrl(it) }
fun savePageList(chapter: Chapter, pages: List<Page>?) {
if (pages != null) {
chapterCache.putPageListToCache(getChapterCacheKey(chapter), pages)
}
}
fun Chapter.setUrlWithoutDomain(url: String) {
this.url = UrlUtil.getPath(url)
}
fun Manga.setUrlWithoutDomain(url: String) {
this.url = UrlUtil.getPath(url)
}
// Overridable method to allow custom parsing.
open fun parseChapterNumber(chapter: Chapter) {
}
data class Filter(val id: String, val name: String)
open fun getFilterList(): List<Filter> = emptyList()
}

View File

@ -1,211 +0,0 @@
package eu.kanade.tachiyomi.data.source.online
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
/**
* A simple implementation for sources from a website using Jsoup, an HTML parser.
*/
abstract class ParsedOnlineSource() : OnlineSource() {
/**
* Parse the response from the site and fills [page].
*
* @param response the response from the site.
* @param page the page object to be filled.
*/
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)
}
}
popularMangaNextPageSelector()?.let { selector ->
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
}
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
abstract protected fun popularMangaSelector(): String
/**
* Fills [manga] with the given [element]. Most sites only show the title and the url, it's
* totally safe to fill only those two values.
*
* @param element an element obtained from [popularMangaSelector].
* @param manga the manga to fill.
*/
abstract protected fun popularMangaFromElement(element: Element, manga: Manga)
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
abstract protected fun popularMangaNextPageSelector(): String?
/**
* Parse the response from the site and fills [page].
*
* @param response the response from the site.
* @param page the page object to be filled.
* @param query the search query.
*/
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
val document = response.asJsoup()
for (element in document.select(searchMangaSelector())) {
Manga.create(id).apply {
searchMangaFromElement(element, this)
page.mangas.add(this)
}
}
searchMangaNextPageSelector()?.let { selector ->
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
}
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/
abstract protected fun searchMangaSelector(): String
/**
* Fills [manga] with the given [element]. Most sites only show the title and the url, it's
* totally safe to fill only those two values.
*
* @param element an element obtained from [searchMangaSelector].
* @param manga the manga to fill.
*/
abstract protected fun searchMangaFromElement(element: Element, manga: Manga)
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
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].
*
* @param response the response from the site.
* @param manga the manga to fill.
*/
override fun mangaDetailsParse(response: Response, manga: Manga) {
mangaDetailsParse(response.asJsoup(), manga)
}
/**
* Fills the details of [manga] from the given [document].
*
* @param document the parsed document.
* @param manga the manga to fill.
*/
abstract protected fun mangaDetailsParse(document: Document, manga: Manga)
/**
* Parse the response from the site and fills the chapter list.
*
* @param response the response from the site.
* @param chapters the list of chapters to fill.
*/
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
val document = response.asJsoup()
for (element in document.select(chapterListSelector())) {
Chapter.create().apply {
chapterFromElement(element, this)
chapters.add(this)
}
}
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
*/
abstract protected fun chapterListSelector(): String
/**
* Fills [chapter] with the given [element].
*
* @param element an element obtained from [chapterListSelector].
* @param chapter the chapter to fill.
*/
abstract protected fun chapterFromElement(element: Element, chapter: Chapter)
/**
* Parse the response from the site and fills the page list.
*
* @param response the response from the site.
* @param pages the list of pages to fill.
*/
override fun pageListParse(response: Response, pages: MutableList<Page>) {
pageListParse(response.asJsoup(), pages)
}
/**
* Fills [pages] from the given [document].
*
* @param document the parsed document.
* @param pages the list of pages to fill.
*/
abstract protected fun pageListParse(document: Document, pages: MutableList<Page>)
/**
* Parse the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
override fun imageUrlParse(response: Response): String {
return imageUrlParse(response.asJsoup())
}
/**
* Returns the absolute url to the source image from the document.
*
* @param document the parsed document.
*/
abstract protected fun imageUrlParse(document: Document): String
}

View File

@ -1,349 +0,0 @@
package eu.kanade.tachiyomi.data.source.online.english
import android.net.Uri
import android.text.Html
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.network.asObservable
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.LoginSource
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.selectText
import okhttp3.FormBody
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
import java.net.URI
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource {
override val name = "Batoto"
override val baseUrl = "http://bato.to"
override val lang: Language get() = EN
override val supportsLatest = true
private val datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*")
private val dateFields = HashMap<String, Int>().apply {
put("second", Calendar.SECOND)
put("minute", Calendar.MINUTE)
put("hour", Calendar.HOUR)
put("day", Calendar.DATE)
put("week", Calendar.WEEK_OF_YEAR)
put("month", Calendar.MONTH)
put("year", Calendar.YEAR)
}
private val staffNotice = Pattern.compile("=+Batoto Staff Notice=+([^=]+)==+", Pattern.CASE_INSENSITIVE)
override fun headersBuilder() = super.headersBuilder()
.add("Cookie", "lang_option=English")
private val pageHeaders = super.headersBuilder()
.add("Referer", "http://bato.to/reader")
.build()
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) {
val document = response.asJsoup()
for (element in document.select(popularMangaSelector())) {
Manga.create(id).apply {
popularMangaFromElement(element, this)
page.mangas.add(this)
}
}
page.nextPageUrl = document.select(popularMangaNextPageSelector()).first()?.let {
"$baseUrl/search_ajax?order_cond=views&order=desc&p=${page.page + 1}"
}
}
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) {
element.select("a[href^=http://bato.to]").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text().trim()
}
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "#show_more_row"
override fun latestUpdatesNextPageSelector() = "#show_more_row"
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)}"
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())) {
Manga.create(id).apply {
searchMangaFromElement(element, this)
page.mangas.add(this)
}
}
page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let {
"$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}&order_cond=views&order=desc&genre_cond=and&genres=" + getFilterParams(filters)
}
}
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
override fun mangaDetailsRequest(manga: Manga): Request {
val mangaId = manga.url.substringAfterLast("r")
return GET("$baseUrl/comic_pop?id=$mangaId", headers)
}
override fun mangaDetailsParse(document: Document, manga: Manga) {
val tbody = document.select("tbody").first()
val artistElement = tbody.select("tr:contains(Author/Artist:)").first()
manga.author = artistElement.selectText("td:eq(1)")
manga.artist = artistElement.selectText("td:eq(2)") ?: manga.author
manga.description = tbody.selectText("tr:contains(Description:) > td:eq(1)")
manga.thumbnail_url = document.select("img[src^=http://img.bato.to/forums/uploads/]").first()?.attr("src")
manga.status = parseStatus(document.selectText("tr:contains(Status:) > td:eq(1)"))
manga.genre = tbody.select("tr:contains(Genres:) img").map { it.attr("alt") }.joinToString(", ")
}
private fun parseStatus(status: String?) = when (status) {
"Ongoing" -> Manga.ONGOING
"Complete" -> Manga.COMPLETED
else -> Manga.UNKNOWN
}
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
val body = response.body().string()
val matcher = staffNotice.matcher(body)
if (matcher.find()) {
val notice = Html.fromHtml(matcher.group(1)).toString().trim()
throw Exception(notice)
}
val document = response.asJsoup(body)
for (element in document.select(chapterListSelector())) {
Chapter.create().apply {
chapterFromElement(element, this)
chapters.add(this)
}
}
}
override fun chapterListSelector() = "tr.row.lang_English.chapter_row"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a[href^=http://bato.to/reader").first()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = element.select("td").getOrNull(4)?.let {
parseDateFromElement(it)
} ?: 0
}
private fun parseDateFromElement(dateElement: Element): Long {
val dateAsString = dateElement.text()
val date: Date
try {
date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString)
} catch (e: ParseException) {
val m = datePattern.matcher(dateAsString)
if (m.matches()) {
val number = m.group(1)
val amount = if (number.contains("A")) 1 else Integer.parseInt(m.group(1))
val unit = m.group(2)
date = Calendar.getInstance().apply {
add(dateFields[unit]!!, -amount)
}.time
} else {
return 0
}
}
return date.time
}
override fun pageListRequest(chapter: Chapter): Request {
val id = chapter.url.substringAfterLast("#")
return GET("$baseUrl/areader?id=$id&p=1", pageHeaders)
}
override fun pageListParse(document: Document, pages: MutableList<Page>) {
val selectElement = document.select("#page_select").first()
if (selectElement != null) {
for ((i, element) in selectElement.select("option").withIndex()) {
pages.add(Page(i, element.attr("value")))
}
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
} else {
// For webtoons in one page
for ((i, element) in document.select("div > img").withIndex()) {
pages.add(Page(i, "", element.attr("src")))
}
}
}
override fun imageUrlRequest(page: Page): Request {
val pageUrl = page.url
val start = pageUrl.indexOf("#") + 1
val end = pageUrl.indexOf("_", start)
val id = pageUrl.substring(start, end)
return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end + 1)}", pageHeaders)
}
override fun imageUrlParse(document: Document): String {
return document.select("#comic_page").first().attr("src")
}
override fun login(username: String, password: String) =
client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global&section=login", headers))
.asObservable()
.flatMap { doLogin(it, username, password) }
.map { isAuthenticationSuccessful(it) }
private fun doLogin(response: Response, username: String, password: String): Observable<Response> {
val doc = response.asJsoup()
val form = doc.select("#login").first()
val url = form.attr("action")
val authKey = form.select("input[name=auth_key]").first()
val payload = FormBody.Builder().apply {
add(authKey.attr("name"), authKey.attr("value"))
add("ips_username", username)
add("ips_password", password)
add("invisible", "1")
add("rememberMe", "1")
}.build()
return client.newCall(POST(url, headers, payload)).asObservable()
}
override fun isAuthenticationSuccessful(response: Response) =
response.priorResponse() != null && response.priorResponse().code() == 302
override fun isLogged(): Boolean {
return network.cookies.get(URI(baseUrl)).any { it.name() == "pass_hash" }
}
override fun fetchChapterList(manga: Manga): Observable<List<Chapter>> {
if (!isLogged()) {
val username = preferences.sourceUsername(this)
val password = preferences.sourcePassword(this)
if (username.isNullOrEmpty() || password.isNullOrEmpty()) {
return Observable.error(Exception("User not logged"))
} else {
return login(username, password).flatMap { super.fetchChapterList(manga) }
}
} else {
return super.fetchChapterList(manga)
}
}
// [...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

@ -1,182 +0,0 @@
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.Manga
import eu.kanade.tachiyomi.data.network.GET
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 okhttp3.FormBody
import okhttp3.OkHttpClient
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 Kissmanga(override val id: Int) : ParsedOnlineSource() {
override val name = "Kissmanga"
override val baseUrl = "http://kissmanga.com"
override val lang: Language get() = EN
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
override fun popularMangaInitialUrl() = "$baseUrl/MangaList/MostPopular"
override fun latestUpdatesInitialUrl() = "http://kissmanga.com/MangaList/LatestUpdate"
override fun popularMangaSelector() = "table.listing tr:gt(1)"
override fun latestUpdatesSelector() = "table.listing tr:gt(1)"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("td a:eq(0)").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "li > a:contains( Next)"
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) {
page.url = searchMangaInitialUrl(query, filters)
}
val form = FormBody.Builder().apply {
add("authorArtist", "")
add("mangaName", query)
add("status", "")
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, filters: List<Filter>) = "$baseUrl/AdvanceSearch"
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun searchMangaNextPageSelector() = null
override fun mangaDetailsParse(document: Document, manga: Manga) {
val infoElement = document.select("div.barContent").first()
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.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.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src")
}
fun parseStatus(status: String) = when {
status.contains("Ongoing") -> Manga.ONGOING
status.contains("Completed") -> Manga.COMPLETED
else -> Manga.UNKNOWN
}
override fun chapterListSelector() = "table.listing tr:gt(1)"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
SimpleDateFormat("MM/dd/yyyy").parse(it).time
} ?: 0
}
override fun pageListRequest(chapter: Chapter) = POST(baseUrl + chapter.url, headers)
override fun pageListParse(response: Response, pages: MutableList<Page>) {
//language=RegExp
val p = Pattern.compile("""lstImages.push\("(.+?)"""")
val m = p.matcher(response.body().string())
var i = 0
while (m.find()) {
pages.add(Page(i++, "", m.group(1)))
}
}
// Not used
override fun pageListParse(document: Document, pages: MutableList<Page>) {
}
override fun imageUrlRequest(page: Page) = GET(page.url)
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,172 +0,0 @@
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.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
class Mangafox(override val id: Int) : ParsedOnlineSource() {
override val name = "Mangafox"
override val baseUrl = "http://mangafox.me"
override val lang: Language get() = EN
override val supportsLatest = true
override fun popularMangaInitialUrl() = "$baseUrl/directory/"
override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?latest"
override fun popularMangaSelector() = "div#mangalist > ul.list > li"
override fun latestUpdatesSelector() = "div#mangalist > ul.list > li"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("a.title").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "a:has(span.next)"
override fun latestUpdatesNextPageSelector() = "a:has(span.next)"
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 searchMangaFromElement(element: Element, manga: Manga) {
element.select("a.series_preview").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
}
override fun searchMangaNextPageSelector() = "a:has(span.next)"
override fun mangaDetailsParse(document: Document, manga: Manga) {
val infoElement = document.select("div#title").first()
val rowElement = infoElement.select("table > tbody > tr:eq(1)").first()
val sideInfoElement = document.select("#series_info").first()
manga.author = rowElement.select("td:eq(1)").first()?.text()
manga.artist = rowElement.select("td:eq(2)").first()?.text()
manga.genre = rowElement.select("td:eq(3)").first()?.text()
manga.description = infoElement.select("p.summary").first()?.text()
manga.status = sideInfoElement.select(".data").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = sideInfoElement.select("div.cover > img").first()?.attr("src")
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> Manga.ONGOING
status.contains("Completed") -> Manga.COMPLETED
else -> Manga.UNKNOWN
}
override fun chapterListSelector() = "div#chapters li div"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a.tips").first()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = element.select("span.date").first()?.text()?.let { parseChapterDate(it) } ?: 0
}
private fun parseChapterDate(date: String): Long {
return if ("Today" in date || " ago" in date) {
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
} else if ("Yesterday" in date) {
Calendar.getInstance().apply {
add(Calendar.DATE, -1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
} else {
try {
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time
} catch (e: ParseException) {
0L
}
}
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
val document = response.asJsoup()
val url = response.request().url().toString().substringBeforeLast('/')
document.select("select.m").first()?.select("option:not([value=0])")?.forEach {
pages.add(Page(pages.size, "$url/${it.attr("value")}.html"))
}
}
// Not used, overrides parent.
override fun pageListParse(document: Document, pages: MutableList<Page>) {}
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

@ -1,161 +0,0 @@
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.Manga
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
class Mangahere(override val id: Int) : ParsedOnlineSource() {
override val name = "Mangahere"
override val baseUrl = "http://www.mangahere.co"
override val lang: Language get() = EN
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 latestUpdatesSelector() = "div.directory_list > ul > li"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("div.title > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "div.next-page > a.next"
override fun latestUpdatesNextPageSelector() = "div.next-page > a.next"
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 searchMangaFromElement(element: Element, manga: Manga) {
element.select("a.manga_info").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
}
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
override fun mangaDetailsParse(document: Document, manga: Manga) {
val detailElement = document.select(".manga_detail_top").first()
val infoElement = detailElement.select(".detail_topText").first()
manga.author = infoElement.select("a[href^=http://www.mangahere.co/author/]").first()?.text()
manga.artist = infoElement.select("a[href^=http://www.mangahere.co/artist/]").first()?.text()
manga.genre = infoElement.select("li:eq(3)").first()?.text()?.substringAfter("Genre(s):")
manga.description = infoElement.select("#show").first()?.text()?.substringBeforeLast("Show less")
manga.status = infoElement.select("li:eq(6)").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = detailElement.select("img.img").first()?.attr("src")
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> Manga.ONGOING
status.contains("Completed") -> Manga.COMPLETED
else -> Manga.UNKNOWN
}
override fun chapterListSelector() = ".detail_list > ul:not([class]) > li"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0
}
private fun parseChapterDate(date: String): Long {
return if ("Today" in date) {
Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
} else if ("Yesterday" in date) {
Calendar.getInstance().apply {
add(Calendar.DATE, -1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
} else {
try {
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time
} catch (e: ParseException) {
0L
}
}
}
override fun pageListParse(document: Document, pages: MutableList<Page>) {
document.select("select.wid60").first()?.getElementsByTag("option")?.forEach {
pages.add(Page(pages.size, it.attr("value")))
}
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
}
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

@ -1,190 +0,0 @@
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.Manga
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.util.*
import java.util.regex.Pattern
class Mangasee(override val id: Int) : ParsedOnlineSource() {
override val name = "Mangasee"
override val baseUrl = "http://www.mangasee.co"
override val lang: Language get() = EN
override val supportsLatest = false
private val datePattern = Pattern.compile("(\\d+)\\s+(.*?)s? (from now|ago).*")
private val dateFields = HashMap<String, Int>().apply {
put("second", Calendar.SECOND)
put("minute", Calendar.MINUTE)
put("hour", Calendar.HOUR)
put("day", Calendar.DATE)
put("week", Calendar.WEEK_OF_YEAR)
put("month", Calendar.MONTH)
put("year", Calendar.YEAR)
}
private val dateRelationFields = HashMap<String, Int>().apply {
put("from now", 1)
put("ago", -1)
}
override fun popularMangaInitialUrl() = "$baseUrl/search_result.php?Action=Yes&order=popularity&numResultPerPage=20&sort=desc"
override fun popularMangaSelector() = "div.well > table > tbody > tr"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("td > h2 > a").first().let {
manga.setUrlWithoutDomain("/${it.attr("href")}")
manga.title = it.text()
}
}
override fun popularMangaNextPageSelector() = "ul.pagination > li > a:contains(Next)"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/advanced-search/result.php?sortBy=alphabet&direction=ASC&textOnly=no&resPerPage=20&page=1&seriesName=$query&${filters.map { it.id + "=Yes" }.joinToString("&")}"
override fun searchMangaSelector() = "div.row > div > div > div > h1"
override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("a").first().let {
manga.setUrlWithoutDomain("/${it.attr("href")}")
manga.title = it.text()
}
}
override fun searchMangaNextPageSelector() = "ul.pagination > li > a:contains(Next)"
override fun mangaDetailsParse(document: Document, manga: Manga) {
val detailElement = document.select("div.well > div.row").first()
manga.author = detailElement.select("a[href^=../search_result.php?author_name=]").first()?.text()
manga.genre = detailElement.select("div > div.row > div:has(b:contains(Genre:)) > a").map { it.text() }.joinToString()
manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text()
manga.status = detailElement.select("div > div.row > div:has(b:contains(Scanlation 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") -> Manga.ONGOING
status.contains("Completed") -> Manga.COMPLETED
else -> Manga.UNKNOWN
}
override fun chapterListSelector() = "div.row > div > div.row > div > div.row:has(a.chapter_link[alt])"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain("/${urlElement.attr("href")}")
chapter.name = urlElement.text()
chapter.date_upload = element.select("span").first()?.text()?.let { parseChapterDate(it) } ?: 0
}
private fun parseChapterDate(dateAsString: String): Long {
val m = datePattern.matcher(dateAsString)
if (m.matches()) {
val amount = Integer.parseInt(m.group(1))
val unit = m.group(2)
val relation = m.group(3)
return Calendar.getInstance().apply {
add(dateFields[unit]!!, dateRelationFields[relation]!! * amount)
}.time.time
} else {
return 0
}
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
val document = response.asJsoup()
val url = response.request().url().toString().substringBeforeLast('/')
val series = document.select("input[name=series]").first().attr("value")
val chapter = document.select("input[name=chapter]").first().attr("value")
val index = document.select("input[name=index]").first().attr("value")
document.select("select[name=page] > option").forEach {
pages.add(Page(pages.size, "$url/?series=$series&chapter=$chapter&index=$index&page=${pages.size + 1}"))
}
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
}
// Not used, overrides parent.
override fun pageListParse(document: Document, pages: MutableList<Page>) {
}
override fun imageUrlParse(document: Document) = document.select("div > a > img").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 {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesNextPageSelector(): String {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesSelector(): String {
throw UnsupportedOperationException("not implemented")
}
}

View File

@ -1,203 +0,0 @@
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.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.OnlineSource
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.util.*
class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
override val name = "ReadMangaToday"
override val baseUrl = "http://www.readmanga.today"
override val lang: Language get() = EN
override val supportsLatest = false
override val client: OkHttpClient get() = network.cloudflareClient
/**
* Search only returns data with this set
*/
override fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
add("X-Requested-With", "XMLHttpRequest")
}
override fun popularMangaInitialUrl() = "$baseUrl/hot-manga/"
override fun popularMangaSelector() = "div.hot-manga > div.style-list > div.box"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("div.title > h2 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
}
override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/service/advanced_search"
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<OnlineSource.Filter>): Request {
if (page.page == 1) {
page.url = searchMangaInitialUrl(query, filters)
}
val builder = okhttp3.FormBody.Builder()
builder.add("manga-name", query)
builder.add("type", "all")
builder.add("status", "both")
for (filter in filters) {
builder.add("include[]", filter.id)
}
return POST(page.url, headers, builder.build())
}
override fun searchMangaSelector() = "div.style-list > div.box"
override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("div.title > h2 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
}
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
override fun mangaDetailsParse(document: Document, manga: Manga) {
val detailElement = document.select("div.movie-meta").first()
manga.author = document.select("ul.cast-list li.director > ul a").first()?.text()
manga.artist = document.select("ul.cast-list li:not(.director) > ul a").first()?.text()
manga.genre = detailElement.select("dl.dl-horizontal > dd:eq(5)").first()?.text()
manga.description = detailElement.select("li.movie-detail").first()?.text()
manga.status = detailElement.select("dl.dl-horizontal > dd:eq(3)").first()?.text().orEmpty().let { parseStatus(it) }
manga.thumbnail_url = detailElement.select("img.img-responsive").first()?.attr("src")
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> Manga.ONGOING
status.contains("Completed") -> Manga.COMPLETED
else -> Manga.UNKNOWN
}
override fun chapterListSelector() = "ul.chp_lst > li"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.select("span.val").text()
chapter.date_upload = element.select("span.dte").first()?.text()?.let { parseChapterDate(it) } ?: 0
}
private fun parseChapterDate(date: String): Long {
val dateWords : List<String> = date.split(" ")
if (dateWords.size == 3) {
val timeAgo = Integer.parseInt(dateWords[0])
var date : Calendar = Calendar.getInstance()
if (dateWords[1].contains("Minute")) {
date.add(Calendar.MINUTE, - timeAgo)
} else if (dateWords[1].contains("Hour")) {
date.add(Calendar.HOUR_OF_DAY, - timeAgo)
} else if (dateWords[1].contains("Day")) {
date.add(Calendar.DAY_OF_YEAR, -timeAgo)
} else if (dateWords[1].contains("Week")) {
date.add(Calendar.WEEK_OF_YEAR, -timeAgo)
} else if (dateWords[1].contains("Month")) {
date.add(Calendar.MONTH, -timeAgo)
} else if (dateWords[1].contains("Year")) {
date.add(Calendar.YEAR, -timeAgo)
}
return date.getTimeInMillis()
}
return 0L
}
override fun pageListParse(document: Document, pages: MutableList<Page>) {
document.select("ul.list-switcher-2 > li > select.jump-menu").first().getElementsByTag("option").forEach {
pages.add(Page(pages.size, it.attr("value")))
}
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
}
override fun imageUrlParse(document: Document) = document.select("img.img-responsive-2").first().attr("src")
// [...document.querySelectorAll("ul.manga-cat span")].map(el => `Filter("${el.getAttribute('data-id')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
// http://www.readmanga.today/advanced-search
override fun getFilterList(): List<Filter> = listOf(
Filter("2", "Action"),
Filter("4", "Adventure"),
Filter("5", "Comedy"),
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", "Martial Arts"),
Filter("17", "Mature"),
Filter("18", "Mecha"),
Filter("19", "Mystery"),
Filter("20", "One shot"),
Filter("21", "Psychological"),
Filter("22", "Romance"),
Filter("23", "School Life"),
Filter("24", "Sci-fi"),
Filter("25", "Seinen"),
Filter("26", "Shotacon"),
Filter("27", "Shoujo"),
Filter("28", "Shoujo Ai"),
Filter("29", "Shounen"),
Filter("30", "Shounen Ai"),
Filter("31", "Slice of Life"),
Filter("32", "Smut"),
Filter("33", "Sports"),
Filter("34", "Supernatural"),
Filter("35", "Tragedy"),
Filter("36", "Yaoi"),
Filter("37", "Yuri")
)
override fun latestUpdatesInitialUrl(): String {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesNextPageSelector(): String {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesSelector(): String {
throw UnsupportedOperationException("not implemented")
}
}

View File

@ -1,107 +0,0 @@
package eu.kanade.tachiyomi.data.source.online.russian
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.RU
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.*
class Mangachan(override val id: Int) : ParsedOnlineSource() {
override val name = "Mangachan"
override val baseUrl = "http://mangachan.me"
override val lang: Language get() = RU
override val supportsLatest = true
override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites"
override fun latestUpdatesInitialUrl() = "$baseUrl/manga/new"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/?do=search&subaction=search&story=$query"
override fun popularMangaSelector() = "div.content_row"
override fun latestUpdatesSelector() = "div.content_row"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("h2 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "a:contains(Вперед)"
override fun latestUpdatesNextPageSelector() = "a:contains(Вперед)"
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
override fun mangaDetailsParse(document: Document, manga: Manga) {
val infoElement = document.select("table.mangatitle").first()
val descElement = document.select("div#description").first()
val imgElement = document.select("img#cover").first()
manga.author = infoElement.select("tr:eq(2) > td:eq(1)").text()
manga.genre = infoElement.select("tr:eq(5) > td:eq(1)").text()
manga.status = parseStatus(infoElement.select("tr:eq(4) > td:eq(1)").text())
manga.description = descElement.textNodes().first().text()
manga.thumbnail_url = baseUrl + imgElement.attr("src")
}
private fun parseStatus(element: String): Int {
when {
element.contains("перевод завершен") -> return Manga.COMPLETED
element.contains("перевод продолжается") -> return Manga.ONGOING
else -> return Manga.UNKNOWN
}
}
override fun chapterListSelector() = "table.table_cha tr:gt(1)"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = urlElement.text()
chapter.date_upload = element.select("div.date").first()?.text()?.let {
SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(it).time
} ?: 0
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
val html = response.body().string()
val beginIndex = html.indexOf("fullimg\":[") + 10
val endIndex = html.indexOf(",]", beginIndex)
val trimmedHtml = html.substring(beginIndex, endIndex).replace("\"", "")
val pageUrls = trimmedHtml.split(',')
for ((i, url) in pageUrls.withIndex()) {
pages.add(Page(i, "", url))
}
}
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
override fun imageUrlParse(document: Document) = ""
}

View File

@ -1,167 +0,0 @@
package eu.kanade.tachiyomi.data.source.online.russian
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.RU
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
class Mintmanga(override val id: Int) : ParsedOnlineSource() {
override val name = "Mintmanga"
override val baseUrl = "http://mintmanga.com"
override val lang: Language get() = RU
override val supportsLatest = true
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
override fun latestUpdatesInitialUrl() = "$baseUrl/list?sortType=updated"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search?q=$query&${filters.map { it.id + "=in" }.joinToString("&")}"
override fun popularMangaSelector() = "div.desc"
override fun latestUpdatesSelector() = "div.desc"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("h3 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "a.nextLink"
override fun latestUpdatesNextPageSelector() = "a.nextLink"
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
// max 200 results
override fun searchMangaNextPageSelector() = null
override fun mangaDetailsParse(document: Document, manga: Manga) {
val infoElement = document.select("div.leftContent").first()
manga.author = infoElement.select("span.elem_author").first()?.text()
manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",")
manga.description = infoElement.select("div.manga-description").text()
manga.status = parseStatus(infoElement.html())
manga.thumbnail_url = infoElement.select("img").attr("data-full")
}
private fun parseStatus(element: String): Int {
when {
element.contains("<h3>Запрещена публикация произведения по копирайту</h3>") -> return Manga.LICENSED
element.contains("<h1 class=\"names\"> Сингл") || element.contains("<b>Перевод:</b> завершен") -> return Manga.COMPLETED
element.contains("<b>Перевод:</b> продолжается") -> return Manga.ONGOING
else -> return Manga.UNKNOWN
}
}
override fun chapterListSelector() = "div.chapters-link tbody tr"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mature=1")
chapter.name = urlElement.text().replace(" новое", "")
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time
} ?: 0
}
override fun parseChapterNumber(chapter: Chapter) {
chapter.chapter_number = -2f
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
val html = response.body().string()
val beginIndex = html.indexOf("rm_h.init( [")
val endIndex = html.indexOf("], 0, false);", beginIndex)
val trimmedHtml = html.substring(beginIndex, endIndex)
val p = Pattern.compile("'.+?','.+?',\".+?\"")
val m = p.matcher(trimmedHtml)
var i = 0
while (m.find()) {
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
}
}
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
override fun imageUrlParse(document: Document) = ""
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => {
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
* on http://mintmanga.com/search
*/
override fun getFilterList(): List<Filter> = listOf(
Filter("el_2220", "арт"),
Filter("el_1353", "бара"),
Filter("el_1346", "боевик"),
Filter("el_1334", "боевые искусства"),
Filter("el_1339", "вампиры"),
Filter("el_1333", "гарем"),
Filter("el_1347", "гендерная интрига"),
Filter("el_1337", "героическое фэнтези"),
Filter("el_1343", "детектив"),
Filter("el_1349", "дзёсэй"),
Filter("el_1332", "додзинси"),
Filter("el_1310", "драма"),
Filter("el_5229", "игра"),
Filter("el_1311", "история"),
Filter("el_1351", "киберпанк"),
Filter("el_1328", "комедия"),
Filter("el_1318", "меха"),
Filter("el_1324", "мистика"),
Filter("el_1325", "научная фантастика"),
Filter("el_1327", "повседневность"),
Filter("el_1342", "постапокалиптика"),
Filter("el_1322", "приключения"),
Filter("el_1335", "психология"),
Filter("el_1313", "романтика"),
Filter("el_1316", "самурайский боевик"),
Filter("el_1350", "сверхъестественное"),
Filter("el_1314", "сёдзё"),
Filter("el_1320", "сёдзё-ай"),
Filter("el_1326", "сёнэн"),
Filter("el_1330", "сёнэн-ай"),
Filter("el_1321", "спорт"),
Filter("el_1329", "сэйнэн"),
Filter("el_1344", "трагедия"),
Filter("el_1341", "триллер"),
Filter("el_1317", "ужасы"),
Filter("el_1331", "фантастика"),
Filter("el_1323", "фэнтези"),
Filter("el_1319", "школа"),
Filter("el_1340", "эротика"),
Filter("el_1354", "этти"),
Filter("el_1315", "юри"),
Filter("el_1336", "яой")
)
}

View File

@ -1,168 +0,0 @@
package eu.kanade.tachiyomi.data.source.online.russian
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.RU
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.text.SimpleDateFormat
import java.util.*
import java.util.regex.Pattern
class Readmanga(override val id: Int) : ParsedOnlineSource() {
override val name = "Readmanga"
override val baseUrl = "http://readmanga.me"
override val lang: Language get() = RU
override val supportsLatest = true
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
override fun latestUpdatesInitialUrl() = "$baseUrl/list?sortType=updated"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search?q=$query&${filters.map { it.id + "=in" }.joinToString("&")}"
override fun popularMangaSelector() = "div.desc"
override fun latestUpdatesSelector() = "div.desc"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("h3 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "a.nextLink"
override fun latestUpdatesNextPageSelector() = "a.nextLink"
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("h3 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
}
// max 200 results
override fun searchMangaNextPageSelector() = null
override fun mangaDetailsParse(document: Document, manga: Manga) {
val infoElement = document.select("div.leftContent").first()
manga.author = infoElement.select("span.elem_author").first()?.text()
manga.genre = infoElement.select("span.elem_genre").text().replace(" ,", ",")
manga.description = infoElement.select("div.manga-description").text()
manga.status = parseStatus(infoElement.html())
manga.thumbnail_url = infoElement.select("img").attr("data-full")
}
private fun parseStatus(element: String): Int {
when {
element.contains("<h3>Запрещена публикация произведения по копирайту</h3>") -> return Manga.LICENSED
element.contains("<h1 class=\"names\"> Сингл") || element.contains("<b>Перевод:</b> завершен") -> return Manga.COMPLETED
element.contains("<b>Перевод:</b> продолжается") -> return Manga.ONGOING
else -> return Manga.UNKNOWN
}
}
override fun chapterListSelector() = "div.chapters-link tbody tr"
override fun chapterFromElement(element: Element, chapter: Chapter) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain(urlElement.attr("href") + "?mature=1")
chapter.name = urlElement.text().replace(" новое", "")
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
SimpleDateFormat("dd/MM/yy", Locale.US).parse(it).time
} ?: 0
}
override fun parseChapterNumber(chapter: Chapter) {
chapter.chapter_number = -2f
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
val html = response.body().string()
val beginIndex = html.indexOf("rm_h.init( [")
val endIndex = html.indexOf("], 0, false);", beginIndex)
val trimmedHtml = html.substring(beginIndex, endIndex)
val p = Pattern.compile("'.+?','.+?',\".+?\"")
val m = p.matcher(trimmedHtml)
var i = 0
while (m.find()) {
val urlParts = m.group().replace("[\"\']+".toRegex(), "").split(',')
pages.add(Page(i++, "", urlParts[1] + urlParts[0] + urlParts[2]))
}
}
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
override fun imageUrlParse(document: Document) = ""
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => {
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
* on http://readmanga.me/search
*/
override fun getFilterList(): List<Filter> = listOf(
Filter("el_5685", "арт"),
Filter("el_2155", "боевик"),
Filter("el_2143", "боевые искусства"),
Filter("el_2148", "вампиры"),
Filter("el_2142", "гарем"),
Filter("el_2156", "гендерная интрига"),
Filter("el_2146", "героическое фэнтези"),
Filter("el_2152", "детектив"),
Filter("el_2158", "дзёсэй"),
Filter("el_2141", "додзинси"),
Filter("el_2118", "драма"),
Filter("el_2154", "игра"),
Filter("el_2119", "история"),
Filter("el_2137", "кодомо"),
Filter("el_2136", "комедия"),
Filter("el_2147", "махо-сёдзё"),
Filter("el_2126", "меха"),
Filter("el_2132", "мистика"),
Filter("el_2133", "научная фантастика"),
Filter("el_2135", "повседневность"),
Filter("el_2151", "постапокалиптика"),
Filter("el_2130", "приключения"),
Filter("el_2144", "психология"),
Filter("el_2121", "романтика"),
Filter("el_2124", "самурайский боевик"),
Filter("el_2159", "сверхъестественное"),
Filter("el_2122", "сёдзё"),
Filter("el_2128", "сёдзё-ай"),
Filter("el_2134", "сёнэн"),
Filter("el_2139", "сёнэн-ай"),
Filter("el_2129", "спорт"),
Filter("el_2138", "сэйнэн"),
Filter("el_2153", "трагедия"),
Filter("el_2150", "триллер"),
Filter("el_2125", "ужасы"),
Filter("el_2140", "фантастика"),
Filter("el_2131", "фэнтези"),
Filter("el_2127", "школа"),
Filter("el_2149", "этти"),
Filter("el_2123", "юри")
)
}

View File

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.data.track
import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
class TrackManager(private val context: Context) {
companion object {
const val MYANIMELIST = 1
const val ANILIST = 2
const val KITSU = 3
}
val myAnimeList = Myanimelist(context, MYANIMELIST)
val aniList = Anilist(context, ANILIST)
val kitsu = Kitsu(context, KITSU)
val services = listOf(myAnimeList, aniList, kitsu)
fun getService(id: Int) = services.find { it.id == id }
fun hasLoggedServices() = services.any { it.isLogged }
}

View File

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.data.track
import android.support.annotation.CallSuper
import android.support.annotation.DrawableRes
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) {
val preferences: PreferencesHelper by injectLazy()
val networkService: NetworkHelper by injectLazy()
open val client: OkHttpClient
get() = networkService.client
// Name of the manga sync service to display
abstract val name: String
@DrawableRes
abstract fun getLogo(): Int
abstract fun getLogoColor(): Int
abstract fun getStatusList(): List<Int>
abstract fun getStatus(status: Int): String
abstract fun getScoreList(): List<String>
open fun indexToScore(index: Int): Float {
return index.toFloat()
}
abstract fun displayScore(track: Track): String
abstract fun add(track: Track): Observable<Track>
abstract fun update(track: Track): Observable<Track>
abstract fun bind(track: Track): Observable<Track>
abstract fun search(query: String): Observable<List<Track>>
abstract fun refresh(track: Track): Observable<Track>
abstract fun login(username: String, password: String): Completable
@CallSuper
open fun logout() {
preferences.setTrackCredentials(this, "", "")
}
open val isLogged: Boolean
get() = !getUsername().isEmpty() &&
!getPassword().isEmpty()
fun getUsername() = preferences.trackUsername(this)
fun getPassword() = preferences.trackPassword(this)
fun saveCredentials(username: String, password: String) {
preferences.setTrackCredentials(this, username, password)
}
}

View File

@ -0,0 +1,160 @@
package eu.kanade.tachiyomi.data.track.anilist
import android.content.Context
import android.graphics.Color
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable
import rx.Observable
class Anilist(private val context: Context, id: Int) : TrackService(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(client, interceptor) }
override fun getLogo() = R.drawable.al
override fun getLogoColor() = Color.rgb(18, 25, 35)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
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 -> ""
}
}
override fun getScoreList(): List<String> {
return when (preferences.anilistScoreType().getOrDefault()) {
// 10 point
0 -> IntRange(0, 10).map(Int::toString)
// 100 point
1 -> IntRange(0, 100).map(Int::toString)
// 5 stars
2 -> IntRange(0, 5).map { "$it" }
// Smiley
3 -> listOf("-", "😦", "😐", "😊")
// 10 point decimal
4 -> IntRange(0, 100).map { (it / 10f).toString() }
else -> throw Exception("Unknown score type")
}
}
override fun indexToScore(index: Int): Float {
return when (preferences.anilistScoreType().getOrDefault()) {
// 10 point
0 -> index * 10f
// 100 point
1 -> index.toFloat()
// 5 stars
2 -> index * 20f
// Smiley
3 -> index * 30f
// 10 point decimal
4 -> index.toFloat()
else -> throw Exception("Unknown score type")
}
}
override fun displayScore(track: Track): String {
val score = track.score
return when (preferences.anilistScoreType().getOrDefault()) {
2 -> "${(score / 20).toInt()}"
3 -> when {
score == 0f -> "0"
score <= 30 -> "😦"
score <= 60 -> "😐"
else -> "😊"
}
else -> track.toAnilistScore()
}
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<Track>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String) = login(password)
fun login(authCode: String): Completable {
return api.login(authCode)
// Save the token in the interceptor.
.doOnNext { interceptor.setAuth(it) }
// Obtain the authenticated user from the API.
.zipWith(api.getCurrentUser().map { pair ->
preferences.anilistScoreType().set(pair.second)
pair.first
}, { 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)
}
}

View File

@ -0,0 +1,161 @@
package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri
import com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.string
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.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
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val rest = restBuilder()
.client(client.newBuilder().addInterceptor(interceptor).build())
.build()
.create(Rest::class.java)
fun addLibManga(track: Track): Observable<Track> {
return rest.addLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus())
.map { response ->
response.body().close()
if (!response.isSuccessful) {
throw Exception("Could not add manga")
}
track
}
}
fun updateLibManga(track: Track): Observable<Track> {
return rest.updateLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus(),
track.toAnilistScore())
.map { response ->
response.body().close()
if (!response.isSuccessful) {
throw Exception("Could not update manga")
}
track
}
}
fun search(query: String): Observable<List<Track>> {
return rest.search(query, 1)
.map { list ->
list.filter { it.type != "Novel" }.map { it.toTrack() }
}
.onErrorReturn { emptyList() }
}
fun getList(username: String): Observable<List<Track>> {
return rest.getLib(username)
.map { lib ->
lib.flatten().map { it.toTrack() }
}
}
fun findLibManga(track: Track, username: String) : Observable<Track?> {
// TODO avoid getting the entire list
return getList(username)
.map { list -> list.find { it.remote_id == track.remote_id } }
}
fun getLibManga(track: Track, username: String): Observable<Track> {
return findLibManga(track, username)
.map { it ?: throw Exception("Could not find manga") }
}
fun login(authCode: String): Observable<OAuth> {
return restBuilder()
.client(client)
.build()
.create(Rest::class.java)
.requestAccessToken(authCode)
}
fun getCurrentUser(): Observable<Pair<String, Int>> {
return rest.getCurrentUser()
.map { it["id"].string to it["score_type"].int }
}
private fun restBuilder() = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
private interface Rest {
@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 getLib(
@Path("username") username: String
): Observable<ALUserLists>
@FormUrlEncoded
@PUT("mangalist")
fun addLibManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String
) : Observable<Response<ResponseBody>>
@FormUrlEncoded
@PUT("mangalist")
fun updateLibManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String,
@Field("score") score_raw: String
) : Observable<Response<ResponseBody>>
}
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())
}
}

View File

@ -1,61 +1,60 @@
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
}
package eu.kanade.tachiyomi.data.track.anilist
import com.google.gson.Gson
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,86 @@
package eu.kanade.tachiyomi.data.track.anilist
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackManager
import uy.kohesive.injekt.injectLazy
data class ALManga(
val id: Int,
val title_romaji: String,
val type: String,
val total_chapters: Int) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
remote_id = this@ALManga.id
title = title_romaji
total_chapters = this@ALManga.total_chapters
}
}
data class ALUserManga(
val id: Int,
val list_status: String,
val score_raw: Int,
val chapters_read: Int,
val manga: ALManga) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
remote_id = manga.id
status = toTrackStatus()
score = score_raw.toFloat()
last_chapter_read = chapters_read
}
fun toTrackStatus() = 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")
}
}
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
fun flatten() = lists.values.flatten()
}
fun Track.toAnilistStatus() = when (status) {
Anilist.READING -> "reading"
Anilist.COMPLETED -> "completed"
Anilist.ON_HOLD -> "on-hold"
Anilist.DROPPED -> "dropped"
Anilist.PLAN_TO_READ -> "plan to read"
else -> throw NotImplementedError("Unknown status")
}
private val preferences: PreferencesHelper by injectLazy()
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
// 10 point
0 -> (score.toInt() / 10).toString()
// 100 point
1 -> score.toInt().toString()
// 5 stars
2 -> when {
score == 0f -> "0"
score < 30 -> "1"
score < 50 -> "2"
score < 70 -> "3"
score < 90 -> "4"
else -> "5"
}
// Smiley
3 -> when {
score == 0f -> "0"
score <= 30 -> ":("
score <= 60 -> ":|"
else -> ":)"
}
// 10 point decimal
4 -> (score / 10).toString()
else -> throw Exception("Unknown score type")
}

View File

@ -1,11 +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
package eu.kanade.tachiyomi.data.track.anilist
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

@ -0,0 +1,136 @@
package eu.kanade.tachiyomi.data.track.kitsu
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Kitsu(private val context: Context, id: Int) : TrackService(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 = 0f
}
override val name = "Kitsu"
private val gson: Gson by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this, gson) }
private val api by lazy { KitsuApi(client, interceptor) }
override fun getLogo(): Int {
return R.drawable.kitsu
}
override fun getLogoColor(): Int {
return Color.rgb(51, 37, 50)
}
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
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 -> ""
}
}
override fun getScoreList(): List<String> {
return IntRange(0, 10).map { (it.toFloat() / 2).toString() }
}
override fun displayScore(track: Track): String {
return track.toKitsuScore()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUserId())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUserId())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.remote_id = remoteTrack.remote_id
update(track)
} else {
track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<Track>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String): Completable {
return api.login(username, password)
.doOnNext { interceptor.newAuth(it) }
.flatMap { api.getCurrentUser() }
.doOnNext { userId -> saveCredentials(username, userId) }
.doOnError { logout() }
.toCompletable()
}
override fun logout() {
super.logout()
interceptor.newAuth(null)
}
private fun getUserId(): String {
return getPassword()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
}

View File

@ -0,0 +1,201 @@
package eu.kanade.tachiyomi.data.track.kitsu
import com.github.salomonbrys.kotson.*
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.network.POST
import okhttp3.FormBody
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import rx.Observable
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val rest = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client.newBuilder().addInterceptor(interceptor).build())
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.Rest::class.java)
fun addLibManga(track: Track, userId: String): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.remote_id,
"type" to "manga"
)
)
)
)
// @formatter:on
rest.addLibManga(jsonObject("data" to data))
.map { json ->
track.remote_id = json["data"]["id"].int
track
}
}
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.remote_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
"rating" to track.toKitsuScore()
)
)
// @formatter:on
rest.updateLibManga(track.remote_id, jsonObject("data" to data))
.map { track }
}
}
fun search(query: String): Observable<List<Track>> {
return rest.search(query)
.map { json ->
val data = json["data"].array
data.map { KitsuManga(it.obj) }
.filter { it.type != "novel" }
.map { it.toTrack() }
}
}
fun findLibManga(track: Track, userId: String): Observable<Track?> {
return rest.findLibManga(track.remote_id, userId)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
null
}
}
}
fun getLibManga(track: Track): Observable<Track> {
return rest.getLibManga(track.remote_id)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
throw Exception("Could not find manga")
}
}
}
fun login(username: String, password: String): Observable<OAuth> {
return Retrofit.Builder()
.baseUrl(loginUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.LoginRest::class.java)
.requestAccessToken(username, password)
}
fun getCurrentUser(): Observable<String> {
return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
}
private interface Rest {
@Headers("Content-Type: application/vnd.api+json")
@POST("library-entries")
fun addLibManga(
@Body data: JsonObject
): Observable<JsonObject>
@Headers("Content-Type: application/vnd.api+json")
@PATCH("library-entries/{id}")
fun updateLibManga(
@Path("id") remoteId: Int,
@Body data: JsonObject
): Observable<JsonObject>
@GET("manga")
fun search(
@Query("filter[text]", encoded = true) query: String
): Observable<JsonObject>
@GET("library-entries")
fun findLibManga(
@Query("filter[media_id]", encoded = true) remoteId: Int,
@Query("filter[user_id]", encoded = true) userId: String,
@Query("page[limit]", encoded = true) limit: Int = 10000,
@Query("include") includes: String = "manga"
): Observable<JsonObject>
@GET("library-entries")
fun getLibManga(
@Query("filter[id]", encoded = true) remoteId: Int,
@Query("include") includes: String = "manga"
): Observable<JsonObject>
@GET("users")
fun getCurrentUser(
@Query("filter[self]", encoded = true) self: Boolean = true
): Observable<JsonObject>
}
private interface LoginRest {
@FormUrlEncoded
@POST("oauth/token")
fun requestAccessToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String = "password",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret
): Observable<OAuth>
}
companion object {
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/"
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
}
}

View File

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.data.track.kitsu
import com.google.gson.Gson
import okhttp3.Interceptor
import okhttp3.Response
class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = kitsu.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
val refreshToken = currAuth.refresh_token!!
// Refresh access token if expired.
if (currAuth.isExpired()) {
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body().string(), OAuth::class.java))
} else {
response.close()
}
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.header("Accept", "application/vnd.api+json")
.header("Content-Type", "application/vnd.api+json")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth?) {
this.oauth = oauth
kitsu.saveToken(oauth)
}
}

View File

@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.data.track.kitsu
import android.support.annotation.CallSuper
import com.github.salomonbrys.kotson.*
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
open class KitsuManga(obj: JsonObject) {
val id by obj.byInt
val canonicalTitle by obj["attributes"].byString
val chapterCount = obj["attributes"].obj.get("chapterCount").nullInt
val type = obj["attributes"].obj.get("mangaType").nullString
@CallSuper
open fun toTrack() = Track.create(TrackManager.KITSU).apply {
remote_id = this@KitsuManga.id
title = canonicalTitle
total_chapters = chapterCount ?: 0
}
}
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
val remoteId by obj.byInt("id")
val status by obj["attributes"].byString
val rating = obj["attributes"].obj.get("rating").nullString
val progress by obj["attributes"].byInt
override fun toTrack() = super.toTrack().apply {
remote_id = remoteId
status = toTrackStatus()
score = rating?.let { it.toFloat() * 2 } ?: 0f
last_chapter_read = progress
}
private fun toTrackStatus() = when (status) {
"current" -> Kitsu.READING
"completed" -> Kitsu.COMPLETED
"on_hold" -> Kitsu.ON_HOLD
"dropped" -> Kitsu.DROPPED
"planned" -> Kitsu.PLAN_TO_READ
else -> throw Exception("Unknown status")
}
}
fun Track.toKitsuStatus() = when (status) {
Kitsu.READING -> "current"
Kitsu.COMPLETED -> "completed"
Kitsu.ON_HOLD -> "on_hold"
Kitsu.DROPPED -> "dropped"
Kitsu.PLAN_TO_READ -> "planned"
else -> throw Exception("Unknown status")
}
fun Track.toKitsuScore(): String {
return if (score > 0) (score / 2).toString() else ""
}

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.data.track.kitsu
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -0,0 +1,104 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import android.content.Context
import android.graphics.Color
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable
import rx.Observable
class Myanimelist(private val context: Context, id: Int) : TrackService(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 = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) }
override val name: String
get() = "MyAnimeList"
override fun getLogo() = R.drawable.mal
override fun getLogoColor() = Color.rgb(46, 81, 162)
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 -> ""
}
}
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<Track>> {
return api.search(query, getUsername())
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String): Completable {
return api.login(username, password)
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
}
}

View File

@ -0,0 +1,187 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri
import android.util.Xml
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText
import okhttp3.*
import org.jsoup.Jsoup
import org.xmlpull.v1.XmlSerializer
import rx.Observable
import java.io.StringWriter
class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
private var headers = createHeaders(username, password)
fun addLibManga(track: Track): Observable<Track> {
return Observable.defer {
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun search(query: String, username: String): Observable<List<Track>> {
return if (query.startsWith(PREFIX_MY)) {
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
getList(username)
.flatMap { Observable.from(it) }
.filter { realQuery in it.title.toLowerCase() }
.toList()
} else {
client.newCall(GET(getSearchUrl(query), headers))
.asObservable()
.map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("entry")) }
.filter { it.select("type").text() != "Novel" }
.map {
Track.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("title")!!
remote_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
}
}
.toList()
}
}
fun getList(username: String): Observable<List<Track>> {
return client
.newCall(GET(getListUrl(username), headers))
.asObservable()
.map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("manga")) }
.map {
Track.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("series_title")!!
remote_id = it.selectInt("series_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = it.selectInt("my_status")
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("series_chapters")
}
}
.toList()
}
fun findLibManga(track: Track, username: String): Observable<Track?> {
return getList(username)
.map { list -> list.find { it.remote_id == track.remote_id } }
}
fun getLibManga(track: Track, username: String): Observable<Track> {
return findLibManga(track, username)
.map { it ?: throw Exception("Could not find manga") }
}
fun login(username: String, password: String): Observable<Response> {
headers = createHeaders(username, password)
return client.newCall(GET(getLoginUrl(), headers))
.asObservable()
.doOnNext { response ->
response.close()
if (response.code() != 200) throw Exception("Login error")
}
}
private fun getMangaPostPayload(track: Track): RequestBody {
val data = xml {
element(ENTRY_TAG) {
if (track.last_chapter_read != 0) {
text(CHAPTER_TAG, track.last_chapter_read.toString())
}
text(STATUS_TAG, track.status.toString())
text(SCORE_TAG, track.score.toString())
}
}
return FormBody.Builder()
.add("data", data)
.build()
}
private inline fun xml(block: XmlSerializer.() -> Unit): String {
val x = Xml.newSerializer()
val writer = StringWriter()
with(x) {
setOutput(writer)
startDocument("UTF-8", false)
block()
endDocument()
}
return writer.toString()
}
private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) {
startTag("", tag)
block()
endTag("", tag)
}
private fun XmlSerializer.text(tag: String, body: String) {
startTag("", tag)
text(body)
endTag("", tag)
}
fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/account/verify_credentials.xml")
.toString()
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/manga/search.xml")
.appendQueryParameter("q", query)
.toString()
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
.appendPath("malappinfo.php")
.appendQueryParameter("u", username)
.appendQueryParameter("status", "all")
.appendQueryParameter("type", "manga")
.toString()
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/update")
.appendPath("${track.remote_id}.xml")
.toString()
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${track.remote_id}.xml")
.toString()
fun createHeaders(username: String, password: String): Headers {
return Headers.Builder()
.add("Authorization", Credentials.basic(username, password))
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
.build()
}
companion object {
const val baseUrl = "https://myanimelist.net"
private val ENTRY_TAG = "entry"
private val CHAPTER_TAG = "chapter"
private val SCORE_TAG = "score"
private val STATUS_TAG = "status"
const val PREFIX_MY = "my:"
}
}

View File

@ -0,0 +1,68 @@
package eu.kanade.tachiyomi.data.updater
import android.app.PendingIntent
import android.content.Intent
import android.support.v4.app.NotificationCompat
import com.evernote.android.job.Job
import com.evernote.android.job.JobManager
import com.evernote.android.job.JobRequest
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.notificationManager
class UpdateCheckerJob : Job() {
override fun onRunJob(params: Params): Result {
return GithubUpdateChecker()
.checkForUpdate()
.map { result ->
if (result is GithubUpdateResult.NewUpdate) {
val url = result.release.downloadLink
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url)
}
NotificationCompat.Builder(context).update {
setContentTitle(context.getString(R.string.app_name))
setContentText(context.getString(R.string.update_check_notification_update_available))
setSmallIcon(android.R.drawable.stat_sys_download_done)
// Download action
addAction(android.R.drawable.stat_sys_download_done,
context.getString(R.string.action_download),
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
}
}
Job.Result.SUCCESS
}
.onErrorReturn { Job.Result.FAILURE }
// Sadly, the task needs to be synchronous.
.toBlocking()
.single()
}
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
block()
context.notificationManager.notify(NOTIFICATION_UPDATER_ID, build())
}
companion object {
const val TAG = "UpdateChecker"
fun setupTask() {
JobRequest.Builder(TAG)
.setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000)
.setRequiredNetworkType(JobRequest.NetworkType.CONNECTED)
.setRequirementsEnforced(true)
.setPersisted(true)
.setUpdateCurrent(true)
.build()
.schedule()
}
fun cancelTask() {
JobManager.instance().cancelAllForTag(TAG)
}
}
}

View File

@ -1,80 +0,0 @@
package eu.kanade.tachiyomi.data.updater
import android.content.Context
import android.support.v4.app.NotificationCompat
import com.google.android.gms.gcm.*
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.notificationManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class UpdateCheckerService : GcmTaskService() {
override fun onInitializeTasks() {
val preferences: PreferencesHelper = Injekt.get()
if (preferences.automaticUpdates()) {
setupTask(this)
}
}
override fun onRunTask(params: TaskParams): Int {
return checkVersion()
}
fun checkVersion(): Int {
return GithubUpdateChecker()
.checkForUpdate()
.map { result ->
if (result is GithubUpdateResult.NewUpdate) {
val url = result.release.downloadLink
NotificationCompat.Builder(this).update {
setContentTitle(getString(R.string.app_name))
setContentText(getString(R.string.update_check_notification_update_available))
setSmallIcon(android.R.drawable.stat_sys_download_done)
// Download action
addAction(android.R.drawable.stat_sys_download_done,
getString(R.string.action_download),
UpdateNotificationReceiver.downloadApkIntent(
this@UpdateCheckerService, url))
}
}
GcmNetworkManager.RESULT_SUCCESS
}
.onErrorReturn { GcmNetworkManager.RESULT_FAILURE }
// Sadly, the task needs to be synchronous.
.toBlocking()
.single()
}
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
block()
notificationManager.notify(NOTIFICATION_UPDATER_ID, build())
}
companion object {
fun setupTask(context: Context) {
val task = PeriodicTask.Builder()
.setService(UpdateCheckerService::class.java)
.setTag("Updater")
// 24 hours
.setPeriod(24 * 60 * 60)
// Run between the last two hours
.setFlex(2 * 60 * 60)
.setRequiredNetwork(Task.NETWORK_STATE_CONNECTED)
.setPersisted(true)
.setUpdateCurrent(true)
.build()
GcmNetworkManager.getInstance(context).schedule(task)
}
fun cancelTask(context: Context) {
GcmNetworkManager.getInstance(context).cancelAllTasks(UpdateCheckerService::class.java)
}
}
}

View File

@ -0,0 +1,144 @@
package eu.kanade.tachiyomi.data.updater
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.support.v4.app.NotificationCompat
import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.util.notificationManager
import java.io.File
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/**
* Local [BroadcastReceiver] that runs on UI thread
* Notification calls from [UpdateDownloaderService] should be made from here.
*/
internal class UpdateDownloaderReceiver(val context: Context) : BroadcastReceiver() {
companion object {
private const val NAME = "UpdateDownloaderReceiver"
// Called to show initial notification.
internal const val NOTIFICATION_UPDATER_INITIAL = "$ID.$NAME.UPDATER_INITIAL"
// Called to show progress notification.
internal const val NOTIFICATION_UPDATER_PROGRESS = "$ID.$NAME.UPDATER_PROGRESS"
// Called to show install notification.
internal const val NOTIFICATION_UPDATER_INSTALL = "$ID.$NAME.UPDATER_INSTALL"
// Called to show error notification
internal const val NOTIFICATION_UPDATER_ERROR = "$ID.$NAME.UPDATER_ERROR"
// Value containing action of BroadcastReceiver
internal const val EXTRA_ACTION = "$ID.$NAME.ACTION"
// Value containing progress
internal const val EXTRA_PROGRESS = "$ID.$NAME.PROGRESS"
// Value containing apk path
internal const val EXTRA_APK_PATH = "$ID.$NAME.APK_PATH"
// Value containing apk url
internal const val EXTRA_APK_URL = "$ID.$NAME.APK_URL"
}
/**
* Notification shown to user
*/
private val notification = NotificationCompat.Builder(context)
override fun onReceive(context: Context, intent: Intent) {
when (intent.getStringExtra(EXTRA_ACTION)) {
NOTIFICATION_UPDATER_INITIAL -> basicNotification()
NOTIFICATION_UPDATER_PROGRESS -> updateProgress(intent.getIntExtra(EXTRA_PROGRESS, 0))
NOTIFICATION_UPDATER_INSTALL -> installNotification(intent.getStringExtra(EXTRA_APK_PATH))
NOTIFICATION_UPDATER_ERROR -> errorNotification(intent.getStringExtra(EXTRA_APK_URL))
}
}
/**
* Called to show basic notification
*/
private fun basicNotification() {
// Create notification
with(notification) {
setContentTitle(context.getString(R.string.app_name))
setContentText(context.getString(R.string.update_check_notification_download_in_progress))
setSmallIcon(android.R.drawable.stat_sys_download)
setOngoing(true)
}
notification.show()
}
/**
* Called to show progress notification
*
* @param progress progress of download
*/
private fun updateProgress(progress: Int) {
with(notification) {
setProgress(100, progress, false)
}
notification.show()
}
/**
* Called to show install notification
*
* @param path path of file
*/
private fun installNotification(path: String) {
// Prompt the user to install the new update.
with(notification) {
setContentText(context.getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)
setProgress(0, 0, false)
// Install action
setContentIntent(NotificationHandler.installApkPendingActivity(context, File(path)))
addAction(R.drawable.ic_system_update_grey_24dp_img,
context.getString(R.string.action_install),
NotificationHandler.installApkPendingActivity(context, File(path)))
// Cancel action
addAction(R.drawable.ic_clear_grey_24dp_img,
context.getString(R.string.action_cancel),
NotificationReceiver.dismissNotificationPendingBroadcast(context, Constants.NOTIFICATION_UPDATER_ID))
}
notification.show()
}
/**
* Called to show error notification
*
* @param url url of apk
*/
private fun errorNotification(url: String) {
// Prompt the user to retry the download.
with(notification) {
setContentText(context.getString(R.string.update_check_notification_download_error))
setSmallIcon(android.R.drawable.stat_sys_warning)
setProgress(0, 0, false)
// Retry action
addAction(R.drawable.ic_refresh_grey_24dp_img,
context.getString(R.string.action_retry),
UpdateDownloaderService.downloadApkPendingService(context, url))
// Cancel action
addAction(R.drawable.ic_clear_grey_24dp_img,
context.getString(R.string.action_cancel),
NotificationReceiver.dismissNotificationPendingBroadcast(context, Constants.NOTIFICATION_UPDATER_ID))
}
notification.show()
}
/**
* Shows a notification from this builder.
*
* @param id the id of the notification.
*/
private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_UPDATER_ID) {
context.notificationManager.notify(id, build())
}
}

View File

@ -1,29 +1,162 @@
package eu.kanade.tachiyomi.data.updater
import android.app.IntentService
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.support.v4.app.NotificationCompat
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.network.ProgressListener
import eu.kanade.tachiyomi.data.network.newCallWithProgress
import eu.kanade.tachiyomi.util.notificationManager
import android.content.IntentFilter
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.ProgressListener
import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.util.registerLocalReceiver
import eu.kanade.tachiyomi.util.saveTo
import eu.kanade.tachiyomi.util.sendLocalBroadcastSync
import eu.kanade.tachiyomi.util.unregisterLocalReceiver
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File
class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.java.name) {
/**
* Network helper
*/
private val network: NetworkHelper by injectLazy()
/**
* Local [BroadcastReceiver] that runs on UI thread
*/
private val updaterNotificationReceiver = UpdateDownloaderReceiver(this)
override fun onCreate() {
super.onCreate()
// Register receiver
registerLocalReceiver(updaterNotificationReceiver, IntentFilter(INTENT_FILTER_NAME))
}
override fun onDestroy() {
// Unregister receiver
unregisterLocalReceiver(updaterNotificationReceiver)
super.onDestroy()
}
override fun onHandleIntent(intent: Intent?) {
if (intent == null) return
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return
downloadApk(url)
}
/**
* Called to start downloading apk of new update
*
* @param url url location of file
*/
fun downloadApk(url: String) {
// Show notification download starting.
sendInitialBroadcast()
val progressListener = object : ProgressListener {
// Progress of the download
var savedProgress = 0
// Keep track of the last notification sent to avoid posting too many.
var lastTick = 0L
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
val progress = (100 * bytesRead / contentLength).toInt()
val currentTime = System.currentTimeMillis()
if (progress > savedProgress && currentTime - 200 > lastTick) {
savedProgress = progress
lastTick = currentTime
sendProgressBroadcast(progress)
}
}
}
try {
// Download the new update.
val response = network.client.newCallWithProgress(GET(url), progressListener).execute()
// File where the apk will be saved.
val apkFile = File(externalCacheDir, "update.apk")
if (response.isSuccessful) {
response.body().source().saveTo(apkFile)
} else {
response.close()
throw Exception("Unsuccessful response")
}
sendInstallBroadcast(apkFile.absolutePath)
} catch (error: Exception) {
Timber.e(error)
sendErrorBroadcast(url)
}
}
/**
* Show notification download starting.
*/
private fun sendInitialBroadcast() {
val intent = Intent(INTENT_FILTER_NAME).apply {
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INITIAL)
}
sendLocalBroadcastSync(intent)
}
/**
* Show notification progress changed
*
* @param progress progress of download
*/
private fun sendProgressBroadcast(progress: Int) {
val intent = Intent(INTENT_FILTER_NAME).apply {
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_PROGRESS)
putExtra(UpdateDownloaderReceiver.EXTRA_PROGRESS, progress)
}
sendLocalBroadcastSync(intent)
}
/**
* Show install notification.
*
* @param path location of file
*/
private fun sendInstallBroadcast(path: String){
val intent = Intent(INTENT_FILTER_NAME).apply {
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_INSTALL)
putExtra(UpdateDownloaderReceiver.EXTRA_APK_PATH, path)
}
sendLocalBroadcastSync(intent)
}
/**
* Show error notification.
*
* @param url url of file
*/
private fun sendErrorBroadcast(url: String){
val intent = Intent(INTENT_FILTER_NAME).apply {
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_ERROR)
putExtra(UpdateDownloaderReceiver.EXTRA_APK_URL, url)
}
sendLocalBroadcastSync(intent)
}
companion object {
/**
* Name of Local BroadCastReceiver.
*/
private val INTENT_FILTER_NAME = UpdateDownloaderService::class.java.name
/**
* Download url.
*/
const val EXTRA_DOWNLOAD_URL = "eu.kanade.APP_DOWNLOAD_URL"
internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdateDownloaderService.DOWNLOAD_URL"
/**
* Downloads a new update and let the user install the new version from a notification.
@ -38,112 +171,18 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
}
/**
* Prompt user with apk install intent
* @param context context
* @param file file of apk that is installed
* Returns [PendingIntent] that starts a service which downloads the apk specified in url.
*
* @param url the url to the new update.
* @return [PendingIntent]
*/
fun installAPK(context: Context, file: File) {
// Prompt install interface
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
// Without this flag android returned a intent error!
flags = Intent.FLAG_ACTIVITY_NEW_TASK
internal fun downloadApkPendingService(context: Context, url: String): PendingIntent {
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
putExtra(EXTRA_DOWNLOAD_URL, url)
}
context.startActivity(intent)
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
}
}
/**
* Network helper
*/
private val network: NetworkHelper by injectLazy()
override fun onHandleIntent(intent: Intent?) {
if (intent == null) return
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return
downloadApk(url)
}
fun downloadApk(url: String) {
val progressNotification = NotificationCompat.Builder(this)
progressNotification.update {
setContentTitle(getString(R.string.app_name))
setContentText(getString(R.string.update_check_notification_download_in_progress))
setSmallIcon(android.R.drawable.stat_sys_download)
setOngoing(true)
}
// Progress of the download
var savedProgress = 0
val progressListener = object : ProgressListener {
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
val progress = (100 * bytesRead / contentLength).toInt()
if (progress > savedProgress) {
savedProgress = progress
progressNotification.update { setProgress(100, progress, false) }
}
}
}
// Reference the context for later usage inside apply blocks.
val ctx = this
try {
// Download the new update.
val response = network.client.newCallWithProgress(GET(url), progressListener).execute()
// File where the apk will be saved
val apkFile = File(externalCacheDir, "update.apk")
if (response.isSuccessful) {
response.body().source().saveTo(apkFile)
} else {
response.close()
throw Exception("Unsuccessful response")
}
// Prompt the user to install the new update.
NotificationCompat.Builder(this).update {
setContentTitle(getString(R.string.app_name))
setContentText(getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)
// Install action
addAction(R.drawable.ic_system_update_grey_24dp_img,
getString(R.string.action_install),
UpdateNotificationReceiver.installApkIntent(ctx, apkFile.absolutePath))
// Cancel action
addAction(R.drawable.ic_clear_grey_24dp_img,
getString(R.string.action_cancel),
UpdateNotificationReceiver.cancelNotificationIntent(ctx))
}
} catch (error: Exception) {
Timber.e(error)
// Prompt the user to retry the download.
NotificationCompat.Builder(this).update {
setContentTitle(getString(R.string.app_name))
setContentText(getString(R.string.update_check_notification_download_error))
setSmallIcon(android.R.drawable.stat_sys_download_done)
// Retry action
addAction(R.drawable.ic_refresh_grey_24dp_img,
getString(R.string.action_retry),
UpdateNotificationReceiver.downloadApkIntent(ctx, url))
// Cancel action
addAction(R.drawable.ic_clear_grey_24dp_img,
getString(R.string.action_cancel),
UpdateNotificationReceiver.cancelNotificationIntent(ctx))
}
}
}
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
block()
notificationManager.notify(NOTIFICATION_UPDATER_ID, build())
}
}

View File

@ -1,67 +0,0 @@
package eu.kanade.tachiyomi.data.updater
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
import eu.kanade.tachiyomi.util.notificationManager
import java.io.File
class UpdateNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_INSTALL_APK -> {
UpdateDownloaderService.installAPK(context,
File(intent.getStringExtra(EXTRA_FILE_LOCATION)))
cancelNotification(context)
}
ACTION_DOWNLOAD_UPDATE -> UpdateDownloaderService.downloadUpdate(context,
intent.getStringExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL))
ACTION_CANCEL_NOTIFICATION -> cancelNotification(context)
}
}
fun cancelNotification(context: Context) {
context.notificationManager.cancel(NOTIFICATION_UPDATER_ID)
}
companion object {
// Install apk action
const val ACTION_INSTALL_APK = "eu.kanade.INSTALL_APK"
// Download apk action
const val ACTION_DOWNLOAD_UPDATE = "eu.kanade.RETRY_DOWNLOAD"
// Cancel notification action
const val ACTION_CANCEL_NOTIFICATION = "eu.kanade.CANCEL_NOTIFICATION"
// Absolute path of apk file
const val EXTRA_FILE_LOCATION = "file_location"
fun cancelNotificationIntent(context: Context): PendingIntent {
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
action = ACTION_CANCEL_NOTIFICATION
}
return PendingIntent.getBroadcast(context, 0, intent, 0)
}
fun installApkIntent(context: Context, path: String): PendingIntent {
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
action = ACTION_INSTALL_APK
putExtra(EXTRA_FILE_LOCATION, path)
}
return PendingIntent.getBroadcast(context, 0, intent, 0)
}
fun downloadApkIntent(context: Context, url: String): PendingIntent {
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
action = ACTION_DOWNLOAD_UPDATE
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url)
}
return PendingIntent.getBroadcast(context, 0, intent, 0)
}
}
}

View File

@ -1,80 +1,77 @@
package eu.kanade.tachiyomi.data.network
import com.squareup.duktape.Duktape
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
class CloudflareInterceptor(private val cookies: PersistentCookieStore) : Interceptor {
//language=RegExp
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
//language=RegExp
private val passPattern = Regex("""name="pass" value="(.+?)"""")
//language=RegExp
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
// Check if we already solved a challenge
if (response.code() != 503 &&
cookies.get(response.request().url()).any { it.name() == "cf_clearance" }) {
return response
}
// Check if Cloudflare anti-bot is on
if ("URL=/cdn-cgi/" in response.header("Refresh", "")
&& response.header("Server", "") == "cloudflare-nginx") {
return chain.proceed(resolveChallenge(response))
}
return response
}
private fun resolveChallenge(response: Response): Request {
val duktape = Duktape.create()
try {
val originalRequest = response.request()
val domain = originalRequest.url().host()
val content = response.body().string()
// CloudFlare requires waiting 4 seconds before resolving the challenge
Thread.sleep(4000)
val operation = operationPattern.find(content)?.groups?.get(1)?.value
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
val pass = passPattern.find(content)?.groups?.get(1)?.value
if (operation == null || challenge == null || pass == null) {
throw RuntimeException("Failed resolving Cloudflare challenge")
}
val js = operation
//language=RegExp
.replace(Regex("""a\.value =(.+?) \+.*"""), "$1")
//language=RegExp
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("\n", "")
val result = (duktape.evaluate(js) as Double).toInt()
val answer = "${result + domain.length}"
val url = HttpUrl.parse("http://$domain/cdn-cgi/l/chk_jschl").newBuilder()
.addQueryParameter("jschl_vc", challenge)
.addQueryParameter("pass", pass)
.addQueryParameter("jschl_answer", answer)
.toString()
val referer = originalRequest.url().toString()
return GET(url, originalRequest.headers().newBuilder().add("Referer", referer).build())
} finally {
duktape.close()
}
}
package eu.kanade.tachiyomi.network
import com.squareup.duktape.Duktape
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
class CloudflareInterceptor : Interceptor {
//language=RegExp
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
//language=RegExp
private val passPattern = Regex("""name="pass" value="(.+?)"""")
//language=RegExp
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
@Synchronized
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
// Check if Cloudflare anti-bot is on
if (response.code() == 503 && "cloudflare-nginx" == response.header("Server")) {
return chain.proceed(resolveChallenge(response))
}
return response
}
private fun resolveChallenge(response: Response): Request {
Duktape.create().use { duktape ->
val originalRequest = response.request()
val url = originalRequest.url()
val domain = url.host()
val content = response.body().string()
// CloudFlare requires waiting 4 seconds before resolving the challenge
Thread.sleep(4000)
val operation = operationPattern.find(content)?.groups?.get(1)?.value
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
val pass = passPattern.find(content)?.groups?.get(1)?.value
if (operation == null || challenge == null || pass == null) {
throw RuntimeException("Failed resolving Cloudflare challenge")
}
val js = operation
//language=RegExp
.replace(Regex("""a\.value =(.+?) \+.*"""), "$1")
//language=RegExp
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
.replace("\n", "")
val result = (duktape.evaluate(js) as Double).toInt()
val answer = "${result + domain.length}"
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")
.newBuilder()
.addQueryParameter("jschl_vc", challenge)
.addQueryParameter("pass", pass)
.addQueryParameter("jschl_answer", answer)
.toString()
val cloudflareHeaders = originalRequest.headers()
.newBuilder()
.add("Referer", url.toString())
.build()
return GET(cloudflareUrl, cloudflareHeaders)
}
}
}

View File

@ -1,38 +1,38 @@
package eu.kanade.tachiyomi.data.network
import android.content.Context
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
private val cookieManager = PersistentCookieJar(context)
val client = OkHttpClient.Builder()
.cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize))
.build()
val forceCacheClient = client.newBuilder()
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.removeHeader("Pragma")
.header("Cache-Control", "max-age=600")
.build()
}
.build()
val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor(cookies))
.build()
val cookies: PersistentCookieStore
get() = cookieManager.store
}
package eu.kanade.tachiyomi.network
import android.content.Context
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
private val cookieManager = PersistentCookieJar(context)
val client = OkHttpClient.Builder()
.cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize))
.build()
val forceCacheClient = client.newBuilder()
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.removeHeader("Pragma")
.header("Cache-Control", "max-age=600")
.build()
}
.build()
val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor())
.build()
val cookies: PersistentCookieStore
get() = cookieManager.store
}

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