Compare commits

..

96 Commits

Author SHA1 Message Date
82141cec6e Release 0.8.5 2020-02-29 16:42:11 -05:00
218313428f Add warning on update check for Android 4.x users 2020-02-29 16:30:40 -05:00
44b47b49bc Hide null file path on backup creation (closes #1515)
(cherry picked from commit 48d9ad00e1)
2020-02-29 16:24:19 -05:00
2f69317f5d Enforce maximum extension lib version of 1.2 2020-02-29 13:35:07 -05:00
e1eff7b744 Remove FAB animation files left over from bad cherry picking 2020-02-29 13:23:42 -05:00
3aa12281c3 Avoid crash on loading invalid extension
(cherry picked from commit 460fbb18c7)
2020-02-29 13:19:50 -05:00
72920130c0 CloudflareInterceptor update (#2537) dcd3c709 Mike <51273546+SnakeDoc83@users.noreply.github.com> Jan 25, 2020 at 16:37 2020-02-29 13:17:12 -05:00
0fd00331e1 Directly pass read chapter when updating tracker
(cherry picked from commit b642e019e8)
2020-02-29 13:13:49 -05:00
7a4763ee68 minor reader bugs: (#2491)
- fix preload on last page for R2L reader
 - page 3 bug

(cherry picked from commit 8b0458cdf6)
2020-02-29 13:12:31 -05:00
Jay
647a78b791 Build time now opens changelog
(cherry picked from commit 22bb3463593c060405694da39a0eb1f5ca1d6ba1)
(cherry picked from commit d1db9fb659)
2020-02-29 13:12:21 -05:00
82faa91ce3 Tweak reader seekbar height for Android 5 UI bug (closes #2487)
(cherry picked from commit 81418a7712)
2020-02-29 13:12:04 -05:00
ad664dfb9f Remove clickable attributes from unclickable text in reader
(cherry picked from commit d4c25359bd)
2020-02-29 13:11:58 -05:00
005ac9e732 fix bangumi track will override record to 0 after every track search(bind) (#2486)
* fix bangumi track : the update status api must be called before update chapter api

* fix bangumi track will override record to 0 after every track search(bind)

(cherry picked from commit 427d2fed8c)
2020-02-29 13:11:50 -05:00
d8e3fe542d Remove unused FAB animations
(cherry picked from commit ab2bdfc508)
2020-02-29 13:11:43 -05:00
c40e4f6c5a Provide more human readable error when downloading to invalid directory (#2462)
(cherry picked from commit 13a2d3dfdd)
2020-02-29 13:10:55 -05:00
9bb3195bca Remove up/down animation for FAB, add list padding (#2456) e411f542 arkon <arkon@users.noreply.github.com> Jan 8, 2020 at 21:33 2020-02-29 13:10:25 -05:00
eee0bc4985 Remove unused color resource
(cherry picked from commit ea226a1697)
2020-02-29 13:09:06 -05:00
a24d670f54 Made 'Default' category selectable in global update settings (#2318)
(cherry picked from commit b55814a1c0)
2020-02-29 13:08:51 -05:00
51e049ab78 fix bangumi tracker crash in searching english manga title (#2452) eb5382e0 mutsumi <4182301+mutsumi63@users.noreply.github.com> Jan 6, 2020 at 20:02 2020-02-29 13:08:41 -05:00
01e37dfab8 Remove repository for Conductor snapshot (#2441)
(cherry picked from commit 39d509a756)
2020-02-29 13:07:47 -05:00
74087edebb match transition text used by other readers (#2439) 708525ef Carlos <cargo8005@gmail.com> Jan 5, 2020 at 17:59 2020-02-29 13:07:35 -05:00
db58c9b77f fix DOWNLOADED text showing after chapters are marked as read (#2434) df14e6d4 Carlos <cargo8005@gmail.com> Jan 5, 2020 at 16:36 2020-02-29 13:06:41 -05:00
c4dad1c20b Unix line endings 2020-02-29 13:03:29 -05:00
cae04656b9 Improve Loading Speed When Skipping Pages in a Chapter (#2426)
* cancel queued loads when the page that requested the queue is destroyed

* use page.status for optimizing removal

(cherry picked from commit dd1e6402c9)
2020-02-29 12:59:37 -05:00
aa57b1bc77 adjust so downloader doesnt autostart when queue was paused (#2413)
adjust so downloader doesnt autostart when queue was paused
2020-01-03 15:33:17 -05:00
491d476cac auto attempt a login refresh once if MAL returns http 400 (#32) (#2403) 2019-12-29 17:45:58 -05:00
f0053a2f78 add width and height to listview for browseCatalogueController (#2406)
* add width and height to listview for browseCatalogueController

* readd recycler has fixed size
add width and height to list view
2019-12-28 14:57:44 -05:00
10e7a3b35b Update JSoup (#2400) 2019-12-28 14:11:18 -05:00
4147fd6b19 recycler is not fixed size (#2402) 2019-12-28 14:10:34 -05:00
2bb903088e Tweak FAB sizing method (fixes #2398)
Ref: https://stackoverflow.com/questions/56945314/floating-action-button-fab-icon-size-problems-after-migrating-to-sdk-28
2019-12-27 20:53:04 -05:00
c90f985fcc Add REQUEST_DELETE_PACKAGES permission for uninstalling extensions 2019-12-27 07:24:19 -05:00
2ebaacfc89 Replace dependency for case insensitive natural sorting (#2389)
Replace dependency for case insensitive natural sorting
2019-12-27 07:18:30 -05:00
c339bd49d0 Address minor Kotlin compiler warnings 2019-12-26 17:48:39 -05:00
c349fb0e37 Enable Java 8 language feature support 2019-12-26 16:47:33 -05:00
bc825bdefa Minor dependency updates 2019-12-26 16:47:01 -05:00
ed49ce8e1d Update project-level dependencies 2019-12-26 16:06:37 -05:00
ad2ecd538d Allow cleartext traffic
Certain catalogues (e.g. Mangakakalot) do not use HTTPS
2019-12-26 16:06:28 -05:00
ff8e3f0af4 Update to SDK 28 (#2394) 2019-12-26 16:01:16 -05:00
698e17178a Increase default text size of the transition chapter page (#2285) 2019-12-26 12:40:56 -05:00
ebeee70931 Allow back button to navigate to previous URL in WebView, add Forward, Refresh, and Close menu options (#2176) 2019-12-26 12:40:11 -05:00
b8b118bdeb Add .nomedia file in each chapter download folder (#2199)
* Move .nomedia creation to directory fetch

* Add .nomedia file to all chapter download directories
2019-12-26 12:39:20 -05:00
5ddd7d1b14 Remove minSdkVersion 21 for dev builds (no longer needed for multidex) 2019-12-23 22:23:05 -05:00
450b23436f Update DB architecture component
Needs to eventually be replaced by androidx.sqlite.db
2019-12-23 22:20:59 -05:00
89793ac338 Update to Kotlin 1.3.61 2019-12-23 22:18:11 -05:00
c456812a46 Update coroutines 2019-12-23 22:14:20 -05:00
6f8f6e9233 Update Gradle 2019-12-23 22:03:07 -05:00
5770d00f81 Upgrade Kotlin (to 1.3), Coroutines, Gradle, and Android Gradle… (#2239)
Upgrade Kotlin (to 1.3), Coroutines, Gradle, and Android Gradle plugin
2019-12-23 22:02:07 -05:00
a0fb1eff4a Merge branch 'master' into update-kotlin-coroutines-gradle 2019-12-23 09:49:53 -05:00
89dc240a22 Clean up Anilist GraphQL query formatting 2019-12-22 22:19:15 -05:00
ee4f069341 fix: Don't send newlines and whitespace in API calls (#2348) 2019-12-22 22:13:27 -05:00
011bb9f5b1 Update to build tools v29.0.2 (#2385) 2019-12-22 16:14:09 -05:00
08b06e1b4e Update to latest version of Android support libraries
Should make migration to AndroidX a bit smoother
2019-12-22 15:56:25 -05:00
0416a2ff15 Extract some hardcoded strings (closes #1989) 2019-12-22 15:48:36 -05:00
3a7cdfcaa4 Update README.md (#2372)
changed  Chat to support server
fixed grammar mistake
2019-12-21 10:45:43 -05:00
c36a47576d Update README.md (#2351)
Update README.md
2019-12-03 09:34:58 -05:00
6c9135c093 Improve Issue reporting experience (#2189)
* Improve issue reporting workflow.

* Add meta request template

* Remove old template

* Fix label for bug

* Add template text.

* Remove meta request

As per [review](https://github.com/inorichi/tachiyomi/pull/2189#discussion_r321668645)

* Remove Acceptance Criteria

As per [review](https://github.com/inorichi/tachiyomi/pull/2189#discussion_r321665449)

* Requested changes from arkon

All except the default template.

* Revert "Remove old template"

This reverts commit b9ef01f655e13e2582af68d3a454bd2db4723534.
2019-12-01 13:49:47 -05:00
80ea9001b3 Allow 'Default' category as the default for adding manga (#2292) 2019-11-03 13:52:33 +01:00
24bb94ceac Implemented extension search functionality. (#2211) 2019-10-14 11:15:00 +02:00
0f16351f5f Set glide to use the gif loop count (#2263) 2019-10-14 11:11:11 +02:00
b60b26bbd0 fix jitpack cause it's shitting out (#2274)
fix jitpack shitting out
2019-10-13 01:09:58 -04:00
86e53e08de Group available extensions by language (#2210) 2019-10-11 19:07:55 +02:00
d3cb10a74e Merge pull request #2271 from mezzode/patch-1
Update link in FAQ
2019-10-10 14:17:32 -04:00
7d6cfff719 Update link in FAQ
Wiki has been replaced by website.
2019-10-10 11:10:48 -07:00
cc0fe0a1a9 Change "Help" link from Github Wiki to Website (#2265)
Change "Help" link from Github Wiki to Website
2019-10-04 11:44:52 -04:00
25327342fb Changed README's app screenshot (#2229)
* Delete old screenshot

* Add new screenshots
2019-09-21 11:01:26 -04:00
e02cf67f85 Update translation files (#2238)
Updated by "Cleanup translation files" hook in Weblate.

Translation: Tachiyomi/Strings
Translate-URL: https://hosted.weblate.org/projects/tachiyomi/strings/
2019-09-21 11:00:45 -04:00
bf4bef6d62 Translations (#2011)
Translations
2019-09-21 09:50:38 -04:00
76645bce6e Remove redundant "publishNonDefault" setting
I think I've seen the following message from just about every single
gradle run throughout the upgrade process:

publishNonDefault is deprecated and has no effect anymore. All variants are now published.
2019-09-20 12:42:41 -04:00
9276c491bc Upgrade Kotlin (to 1.3), Coroutines, Gradle and Android gradle plugin.
Kotlin:                1.2.71 -> 1.3.50
Coroutines:            0.30.2 -> 1.3.1
Gradle:                4.6    -> 5.4.1
Android gradle plugin: 3.2.1  -> 3.5.0

This brings us down to *one* experimental coroutine API, and we've
opted in to using it in just *one* place.

(The fact that the API to opt-in to using an experimental API in a
specific place is *also* experimental surely will not come back to
bite us later.)
2019-09-18 22:45:54 -04:00
fa59b4f8a7 Fix coroutine deprecations again 2019-09-18 17:41:09 -04:00
934a37c36b Update to kotlinx.coroutines 0.30.2
Almost done, honest!
2019-09-18 13:37:57 -04:00
5362f62078 Update deprecated coroutines code 2019-09-18 13:32:42 -04:00
ccd360687e Update to kotlinx.coroutines 0.26.0 2019-09-18 13:20:39 -04:00
5a2e8a838c Update to kotlinx.coroutines 0.23.4 2019-09-18 01:31:15 -04:00
3abae1cc75 Add chinese track website "bangumi" (#2032)
* copy from shikimori and change parmater

* add login activity

* fix

* login sucess

* search

* add...

* auth fix

* save status

* revert shikimori

* fix oauth error

* add bangumi info

* update read chapter index

* refersh token

* remove outdate file

* drop comment

* change icon

* drop search result which type not comic

* fix bind logic

* set status

* add ep status

* format code

* disable cache for `collection` api
2019-07-23 12:35:38 +02:00
b68ef8c983 Update info about auto updates in README (#2132) 2019-07-23 12:29:01 +02:00
d5f5ba95bb Add automatic updates for dev builds (#2128) 2019-07-13 19:36:30 +02:00
e8638cb0b3 Hide Empty Search Results in Catalogues (#2066)
* test2

* remove nothing_found view and associated resources
2019-07-01 13:06:19 +02:00
62f9071adc Avoid infinite loading in global search if a single catalogue fails (#2097) 2019-06-29 22:27:58 +02:00
1d079dd9a4 use dist: trusty (#2085) 2019-06-18 13:53:47 +02:00
cccb56bda1 Change default update priorization 2019-06-09 14:35:24 +02:00
5d8dc241d8 Update ranking (#1772)
* Add LibraryUpdateRanker

This class provides various functions to generate Comparators that can 
be used to order the manga to update.

One such ordering is by relevance:
It prioritises manga that were updated more recently.

Another Ordering is by lexicographic order:
This is the default behaviour.

* Use relevanceRanking scheme

Instead of default(noRanking/lex ranking) now mangaList is sorted with 
relevanceRanking.

* Add UI and associated variables & strings for Update Ranking.

* Use user preferences to determine update ranking scheme.

* Refactor relevanceRanking to latestFirstranking.

This name seems to better reflect the ranking scheme and frees up the 
name relevanceRanking for future use.

* Set latestFirst scheme as default.

(Changing over from lexicographic scheme)

* Fix 1

[Convert LibraryUpdateRanker to a object.](./files/82f263749f0ae775385b23dd919f1865360db969#r287513539)

[Nitpick: Add lines](./files/82f263749f0ae775385b23dd919f1865360db969#r287540256)

[Replace Java comparator](./files/82f263749f0ae775385b23dd919f1865360db969#r287539976)

[Nitpick: Add local variable](./files/82f263749f0ae775385b23dd919f1865360db969#r287514805)

* Fix 2

[Weird import](./files/82f263749f0ae775385b23dd919f1865360db969#r287513709)

[Default value](./files/82f263749f0ae775385b23dd919f1865360db969#r287540064)

[Use existing Strings](./files/82f263749f0ae775385b23dd919f1865360db969#r287514476)

[Use Library update order](./files/82f263749f0ae775385b23dd919f1865360db969#r287540204)
2019-06-09 14:32:12 +02:00
9ba7312caf Make MAL Tracking Slightly Less Shitty (#2042)
* * fix cookieManager not clearing cookies properly
* manually clear tracking prefs when !isLogged (e.g. cookies were cleared)

* use full url for removing cookies

* add interceptor for all non-login network calls
* attempt auto login if cookies are missing
* move handling of csrf token to interceptor

* * move methods around to improve readability
* fix TrackSearchAdapter not updating other fields if cover_url is missing
* revert accidental removal of feature in https://github.com/inorichi/tachiyomi/issues/65
* avoid login if credentials are missing

* fix eol

* *separate login flow from rxjava for reuse in sync

* *use less expensive method of finding manga

* *move variable declaration

* formatting

* set total chapters in remote track
2019-06-09 14:31:19 +02:00
8ebda219c4 Fix the category selection bug (#2052)
Fixes #2051
2019-05-26 11:37:47 +02:00
47f14e8555 Long click to manage categories (#2045) 2019-05-25 13:47:53 +02:00
974a24d03b Add help link to nav drawer (#2049) 2019-05-25 13:46:42 +02:00
15f225537e Update tracking sites after finishing chapter (#2044)
* Added second updateTrackLastChapterRead() called whenever a chapter has been read in the reader

* Removed old updateTrackLastChapterRead() so that it's not called twice.
2019-05-25 13:46:20 +02:00
a32572fc96 Ignore case while sorting Library (#2048)
* Ignore case while sorting Library

* Simplify code

As suggested by @arkon
2019-05-24 09:57:05 +02:00
be3ed9b6af Create FUNDING.yml 2019-05-24 09:56:31 +02:00
a0939e1c48 Update Shikimori (#2038)
Domain name change due to blocking by local authorities.
2019-05-16 18:21:54 +02:00
003dca9d45 Bugfix. Sharing images with very long name (#1999)
* Fix sharing with very long images name

* Fix dropLast to take
2019-05-07 11:06:38 +02:00
5c1770247c Update README.md
mangafox has been broken for months
2019-05-03 15:51:27 -04:00
021dde66eb Add color filter blend modes (#2013)
* Add color filter blend modes

* Only show modes supported by currently used API level.

* Fix arrays.xml for API level <=27.
2019-04-29 19:32:49 +02:00
5840a3e1e2 Shikomori -> Shikimori. Fix update chapters (#1996)
* Shikomori -> Shikimori. Fix update chapters

* Removed logs and format code
2019-04-29 18:40:26 +02:00
7c6478fe6b Force Migration to display titles from source rather than from local DB, and update local titles when migrated (#1670) 2019-04-29 18:38:59 +02:00
68aca55e6f Add options to open catalogue in browser/webview (#1979) 2019-04-16 17:34:52 +02:00
255 changed files with 14299 additions and 11189 deletions

24
.gitattributes vendored Normal file
View File

@ -0,0 +1,24 @@
* text=auto
* text eol=lf
# Windows forced line-endings
/.idea/* text eol=crlf
# Gradle wrapper
*.jar binary
# Images
*.webp binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.eot binary
*.woff binary
*.pyc binary
*.swp binary

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
github: inorichi
ko_fi: inorichi

24
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,24 @@
---
name: "🐞 Bug report"
about: Report a bug
title: "[Bug] Write short description here"
labels: "bug"
---
### Device information
* Tachiyomi version: ?
* Android version: ?
## Steps to reproduce
1. First step
2. Second step
### Expected behavior
This should happen.
### Actual behavior
This happened instead.
### Other details
Additional details and attachments.

View File

@ -0,0 +1,12 @@
---
name: "🌟 Feature request"
about: Suggest a feature to improve Tachiyomi
title: "[Feature Request] Write short description here"
labels: "feature"
---
### Why/User Benefit/User Problem
(explain why this feature should be added)
### What/Requirements
(explain how this feature would behave)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -1,8 +1,9 @@
dist: trusty
language: android language: android
android: android:
components: components:
- build-tools-28.0.3 - build-tools-29.0.2
- android-27 - android-28
- extra-android-m2repository - extra-android-m2repository
- extra-google-m2repository - extra-google-m2repository
- extra-android-support - extra-android-support
@ -10,7 +11,7 @@ android:
licenses: licenses:
- android-sdk-license-.+ - android-sdk-license-.+
before_install: before_install:
- yes | sdkmanager "platforms;android-27" # workaround for accepting the license - yes | sdkmanager "platforms;android-28" # workaround for accepting the license
- if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then - if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then
openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d; openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d;
tar xf secrets.tar; tar xf secrets.tar;

View File

@ -1,6 +1,6 @@
| Build | Stable | Dev | Contribute | Contact | | Build | Stable | Dev | Contribute | Support Server |
|-------|----------|---------|------------|---------| |-------|----------|---------|------------|---------|
| [![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=download%20(autoupdate%20included))](https://github.com/inorichi/tachiyomi/releases) | [![latest dev build](https://img.shields.io/badge/download-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/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=download)](https://github.com/inorichi/tachiyomi/releases) | [![latest dev build](https://img.shields.io/badge/download-latest%20build-blue.svg)](http://tachiyomi.kanade.eu/latest) | [![Translation status](https://hosted.weblate.org/widgets/tachiyomi/-/svg-badge.svg)](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [![Discord](https://img.shields.io/discord/349436576037732353.svg)](https://discord.gg/tachiyomi) |
# ![app icon](./.github/readme-images/app-icon.png)Tachiyomi # ![app icon](./.github/readme-images/app-icon.png)Tachiyomi
@ -11,10 +11,10 @@ Tachiyomi is a free and open source manga reader for Android.
## Features ## Features
Features include: Features include:
* Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions) * Online reading from sources such as KissManga, MangaDex, [and more](https://github.com/inorichi/tachiyomi-extensions)
* Local reading of downloaded manga * Local reading of downloaded manga
* Configurable reader with multiple viewers, reading directions and other settings * A configurable reader with multiple viewers, reading directions and other settings.
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), and [Kitsu](https://kitsu.io/explore/anime) support * [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.io/explore/anime), and [Shikimori](https://shikimori.one) support
* Categories to organize your library * Categories to organize your library
* Light and dark themes * Light and dark themes
* Schedule updating your library for new chapters * Schedule updating your library for new chapters
@ -23,7 +23,7 @@ Features include:
## Download ## Download
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases). Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest). (auto-updates not included) If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest).
## Issues, Feature Requests and Contributing ## Issues, Feature Requests and Contributing
@ -63,7 +63,7 @@ Catalogue requests should be created at https://github.com/inorichi/tachiyomi-ex
## FAQ ## FAQ
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ) [See our website.](https://tachiyomi.org/)
You can also reach out to us on [Discord](https://discord.gg/tachiyomi). You can also reach out to us on [Discord](https://discord.gg/tachiyomi).
## License ## License

View File

@ -29,17 +29,17 @@ ext {
} }
android { android {
compileSdkVersion 27 compileSdkVersion 28
buildToolsVersion '28.0.3' buildToolsVersion '29.0.2'
publishNonDefault true publishNonDefault true
defaultConfig { defaultConfig {
applicationId "eu.kanade.tachiyomi" applicationId "eu.kanade.tachiyomi"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 27 targetSdkVersion 28
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 41 versionCode 42
versionName "0.8.4" versionName "0.8.5"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\"" buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\"" buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -73,7 +73,6 @@ android {
dimension "default" dimension "default"
} }
dev { dev {
minSdkVersion 21
resConfigs "en", "xxhdpi" resConfigs "en", "xxhdpi"
dimension "default" dimension "default"
} }
@ -92,6 +91,14 @@ android {
checkReleaseBuilds false checkReleaseBuilds false
} }
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = "1.8"
}
} }
dependencies { dependencies {
@ -101,7 +108,7 @@ dependencies {
implementation 'com.github.inorichi:junrar-android:634c1f5' implementation 'com.github.inorichi:junrar-android:634c1f5'
// Android support library // Android support library
final support_library_version = '27.0.2' final support_library_version = '28.0.0'
implementation "com.android.support:support-v4:$support_library_version" implementation "com.android.support:support-v4:$support_library_version"
implementation "com.android.support:appcompat-v7:$support_library_version" implementation "com.android.support:appcompat-v7:$support_library_version"
implementation "com.android.support:cardview-v7:$support_library_version" implementation "com.android.support:cardview-v7:$support_library_version"
@ -111,7 +118,7 @@ dependencies {
implementation "com.android.support:support-annotations:$support_library_version" implementation "com.android.support:support-annotations:$support_library_version"
implementation "com.android.support:customtabs:$support_library_version" implementation "com.android.support:customtabs:$support_library_version"
implementation 'com.android.support.constraint:constraint-layout:1.1.2' implementation 'com.android.support.constraint:constraint-layout:1.1.3'
implementation 'com.android.support:multidex:1.0.3' implementation 'com.android.support:multidex:1.0.3'
@ -119,10 +126,10 @@ dependencies {
// ReactiveX // ReactiveX
implementation 'io.reactivex:rxandroid:1.2.1' implementation 'io.reactivex:rxandroid:1.2.1'
implementation 'io.reactivex:rxjava:1.3.6' implementation 'io.reactivex:rxjava:1.3.8'
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0' implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2' implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
implementation 'com.github.pwittchen:reactivenetwork:0.7.0' implementation 'com.github.pwittchen:reactivenetwork:0.13.0'
// Network client // Network client
implementation "com.squareup.okhttp3:okhttp:3.10.0" implementation "com.squareup.okhttp3:okhttp:3.10.0"
@ -146,7 +153,7 @@ dependencies {
implementation 'com.github.inorichi:unifile:e9ee588' implementation 'com.github.inorichi:unifile:e9ee588'
// HTML parser // HTML parser
implementation 'org.jsoup:jsoup:1.10.2' implementation 'org.jsoup:jsoup:1.12.1'
// Job scheduling // Job scheduling
implementation 'com.evernote:android-job:1.2.5' implementation 'com.evernote:android-job:1.2.5'
@ -156,7 +163,7 @@ dependencies {
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0' implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// Database // Database
implementation 'android.arch.persistence:db:1.0.0' implementation 'android.arch.persistence:db:1.1.1'
implementation 'com.github.inorichi.storio:storio-common:8be19de@aar' implementation 'com.github.inorichi.storio:storio-common:8be19de@aar'
implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar' implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar'
implementation 'io.requery:sqlite-android:3.25.2' implementation 'io.requery:sqlite-android:3.25.2'
@ -179,14 +186,11 @@ dependencies {
implementation 'jp.wasabeef:glide-transformations:3.1.1' implementation 'jp.wasabeef:glide-transformations:3.1.1'
// Logging // Logging
implementation 'com.jakewharton.timber:timber:4.6.1' implementation 'com.jakewharton.timber:timber:4.7.1'
// Crash reports // Crash reports
implementation 'ch.acra:acra:4.9.2' implementation 'ch.acra:acra:4.9.2'
// Sort
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
// UI // UI
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4' implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
@ -228,13 +232,12 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
final coroutines_version = '0.22.2' final coroutines_version = '1.3.3'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
} }
buildscript { buildscript {
ext.kotlin_version = '1.2.71' ext.kotlin_version = '1.3.61'
repositories { repositories {
mavenCentral() mavenCentral()
} }
@ -247,10 +250,9 @@ repositories {
mavenCentral() mavenCentral()
} }
kotlin { // See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers
experimental { tasks.withType(org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompile).all {
coroutines 'enable' kotlinOptions.freeCompilerArgs += ["-Xuse-experimental=kotlin.Experimental"]
}
} }
androidExtensions { androidExtensions {

View File

@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" /> <uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<application <application
@ -16,6 +18,7 @@
android:allowBackup="true" android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
@ -62,8 +65,8 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".ui.setting.ShikomoriLoginActivity" android:name=".ui.setting.ShikimoriLoginActivity"
android:label="Shikomori"> android:label="Shikimori">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -75,6 +78,20 @@
android:scheme="tachiyomi" /> android:scheme="tachiyomi" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.setting.BangumiLoginActivity"
android:label="Bangumi">
<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="bangumi-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity <activity
android:name=".extension.util.ExtensionInstallActivity" android:name=".extension.util.ExtensionInstallActivity"

View File

@ -1,23 +1,23 @@
package eu.kanade.tachiyomi.data.backup.models package eu.kanade.tachiyomi.data.backup.models
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
/** /**
* Json values * Json values
*/ */
object Backup { object Backup {
const val CURRENT_VERSION = 2 const val CURRENT_VERSION = 2
const val MANGA = "manga" const val MANGA = "manga"
const val MANGAS = "mangas" const val MANGAS = "mangas"
const val TRACK = "track" const val TRACK = "track"
const val CHAPTERS = "chapters" const val CHAPTERS = "chapters"
const val CATEGORIES = "categories" const val CATEGORIES = "categories"
const val HISTORY = "history" const val HISTORY = "history"
const val VERSION = "version" const val VERSION = "version"
fun getDefaultFilename(): String { fun getDefaultFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "tachiyomi_$date.json" return "tachiyomi_$date.json"
} }
} }

View File

@ -60,7 +60,7 @@ class CoverCache(private val context: Context) {
return false return false
// Remove file. // Remove file.
val file = getCoverFile(thumbnailUrl!!) val file = getCoverFile(thumbnailUrl)
return file.exists() && file.delete() return file.exists() && file.delete()
} }

View File

@ -82,6 +82,11 @@ interface MangaQueries : DbProvider {
.withPutResolver(MangaViewerPutResolver()) .withPutResolver(MangaViewerPutResolver())
.prepare() .prepare()
fun updateMangaTitle(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaTitlePutResolver())
.prepare()
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare() fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare() fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()

View File

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

View File

@ -0,0 +1,32 @@
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 MangaTitlePutResolver : 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_TITLE, manga.title)
}
}

View File

@ -20,10 +20,10 @@ class MangaViewerPutResolver : PutResolver<Manga>() {
} }
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
.table(MangaTable.TABLE) .table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?") .where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id) .whereArgs(manga.id)
.build() .build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply { fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_VIEWER, manga.viewer) put(MangaTable.COL_VIEWER, manga.viewer)

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -28,7 +29,9 @@ class DownloadProvider(private val context: Context) {
* The root directory for downloads. * The root directory for downloads.
*/ */
private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let { private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let {
UniFile.fromUri(context, Uri.parse(it)) val dir = UniFile.fromUri(context, Uri.parse(it))
DiskUtil.createNoMediaFile(dir, context)
dir
} }
init { init {
@ -44,9 +47,13 @@ class DownloadProvider(private val context: Context) {
* @param source the source of the manga. * @param source the source of the manga.
*/ */
internal fun getMangaDir(manga: Manga, source: Source): UniFile { internal fun getMangaDir(manga: Manga, source: Source): UniFile {
return downloadsDir try {
.createDirectory(getSourceDirName(source)) return downloadsDir
.createDirectory(getMangaDirName(manga)) .createDirectory(getSourceDirName(source))
.createDirectory(getMangaDirName(manga))
} catch (e: NullPointerException) {
throw Exception(context.getString(R.string.invalid_download_dir))
}
} }
/** /**

View File

@ -132,7 +132,7 @@ class DownloadService : Service() {
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe({ state -> onNetworkStateChanged(state) .subscribe({ state -> onNetworkStateChanged(state)
}, { _ -> }, {
toast(R.string.download_queue_error) toast(R.string.download_queue_error)
stopSelf() stopSelf()
}) })

View File

@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
import eu.kanade.tachiyomi.util.* import eu.kanade.tachiyomi.util.*
import kotlinx.coroutines.experimental.async import kotlinx.coroutines.async
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
@ -102,7 +102,7 @@ class Downloader(
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE } pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
downloadsRelay.call(pending) downloadsRelay.call(pending)
return !pending.isEmpty() return pending.isNotEmpty()
} }
/** /**
@ -199,7 +199,7 @@ class Downloader(
*/ */
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI { fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI {
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI
val wasEmpty = queue.isEmpty()
// Called in background thread, the operation can be slow with SAF. // Called in background thread, the operation can be slow with SAF.
val chaptersWithoutDir = async { val chaptersWithoutDir = async {
val mangaDir = provider.findMangaDir(manga, source) val mangaDir = provider.findMangaDir(manga, source)
@ -232,7 +232,7 @@ class Downloader(
} }
// Start downloader if needed // Start downloader if needed
if (autoStart) { if (autoStart && wasEmpty) {
DownloadService.start(this@Downloader.context) DownloadService.start(this@Downloader.context)
} }
} }
@ -407,6 +407,8 @@ class Downloader(
if (download.status == Download.DOWNLOADED) { if (download.status == Download.DOWNLOADED) {
tmpDir.renameTo(dirname) tmpDir.renameTo(dirname)
cache.addChapter(dirname, mangaDir, download.manga) cache.addChapter(dirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(tmpDir, context)
} }
} }

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.data.library
import eu.kanade.tachiyomi.data.database.models.Manga
/**
* This class will provide various functions to Rank mangas to efficiently schedule mangas to update.
*/
object LibraryUpdateRanker {
val rankingScheme = listOf(
(this::lexicographicRanking)(),
(this::latestFirstRanking)())
/**
* Provides a total ordering over all the Mangas.
*
* Assumption: An active [Manga] mActive is expected to have been last updated after an
* inactive [Manga] mInactive.
*
* Using this insight, function returns a Comparator for which mActive appears before mInactive.
* @return a Comparator that ranks manga based on relevance.
*/
fun latestFirstRanking(): Comparator<Manga> {
return Comparator { mangaFirst: Manga,
mangaSecond: Manga ->
compareValues(mangaSecond.last_update, mangaFirst.last_update)
}
}
/**
* Provides a total ordering over all the Mangas.
*
* Order the manga lexicographically.
* @return a Comparator that ranks manga lexicographically based on the title.
*/
fun lexicographicRanking(): Comparator<Manga> {
return Comparator { mangaFirst: Manga,
mangaSecond: Manga ->
compareValues(mangaFirst.title, mangaSecond.title)
}
}
}

View File

@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
@ -204,7 +205,9 @@ class LibraryUpdateService(
// Update favorite manga. Destroy service when completed or in case of an error. // Update favorite manga. Destroy service when completed or in case of an error.
subscription = Observable subscription = Observable
.defer { .defer {
val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault()
val mangaList = getMangaToUpdate(intent, target) val mangaList = getMangaToUpdate(intent, target)
.sortedWith(rankingScheme[selectedScheme])
// Update either chapter list or manga details. // Update either chapter list or manga details.
when (target) { when (target) {
@ -246,7 +249,6 @@ class LibraryUpdateService(
else else
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id } db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
} }
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) { if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED } listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
} }

View File

@ -1,128 +1,132 @@
package eu.kanade.tachiyomi.data.preference package eu.kanade.tachiyomi.data.preference
/** /**
* This class stores the keys for the preferences in the application. * This class stores the keys for the preferences in the application.
*/ */
object PreferenceKeys { object PreferenceKeys {
const val theme = "pref_theme_key" const val theme = "pref_theme_key"
const val rotation = "pref_rotation_type_key" const val rotation = "pref_rotation_type_key"
const val enableTransitions = "pref_enable_transitions_key" const val enableTransitions = "pref_enable_transitions_key"
const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
const val showPageNumber = "pref_show_page_number_key" const val showPageNumber = "pref_show_page_number_key"
const val trueColor = "pref_true_color_key" const val trueColor = "pref_true_color_key"
const val fullscreen = "fullscreen" const val fullscreen = "fullscreen"
const val keepScreenOn = "pref_keep_screen_on_key" const val keepScreenOn = "pref_keep_screen_on_key"
const val customBrightness = "pref_custom_brightness_key" const val customBrightness = "pref_custom_brightness_key"
const val customBrightnessValue = "custom_brightness_value" const val customBrightnessValue = "custom_brightness_value"
const val colorFilter = "pref_color_filter_key" const val colorFilter = "pref_color_filter_key"
const val colorFilterValue = "color_filter_value" const val colorFilterValue = "color_filter_value"
const val defaultViewer = "pref_default_viewer_key" const val colorFilterMode = "color_filter_mode"
const val imageScaleType = "pref_image_scale_type_key" const val defaultViewer = "pref_default_viewer_key"
const val zoomStart = "pref_zoom_start_key" const val imageScaleType = "pref_image_scale_type_key"
const val readerTheme = "pref_reader_theme_key" const val zoomStart = "pref_zoom_start_key"
const val cropBorders = "crop_borders" const val readerTheme = "pref_reader_theme_key"
const val cropBordersWebtoon = "crop_borders_webtoon" const val cropBorders = "crop_borders"
const val readWithTapping = "reader_tap" const val cropBordersWebtoon = "crop_borders_webtoon"
const val readWithLongTap = "reader_long_tap" const val readWithTapping = "reader_tap"
const val readWithVolumeKeys = "reader_volume_keys" const val readWithLongTap = "reader_long_tap"
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" const val readWithVolumeKeys = "reader_volume_keys"
const val portraitColumns = "pref_library_columns_portrait_key" const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
const val landscapeColumns = "pref_library_columns_landscape_key" const val portraitColumns = "pref_library_columns_portrait_key"
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" const val landscapeColumns = "pref_library_columns_landscape_key"
const val autoUpdateTrack = "pref_auto_update_manga_sync_key" const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
const val lastUsedCatalogueSource = "last_catalogue_source" const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
const val lastUsedCategory = "last_used_category" const val lastUsedCatalogueSource = "last_catalogue_source"
const val catalogueAsList = "pref_display_catalogue_as_list" const val lastUsedCategory = "last_used_category"
const val enabledLanguages = "source_languages" const val catalogueAsList = "pref_display_catalogue_as_list"
const val backupDirectory = "backup_directory" const val enabledLanguages = "source_languages"
const val downloadsDirectory = "download_directory" const val backupDirectory = "backup_directory"
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" const val downloadsDirectory = "download_directory"
const val numberOfBackups = "backup_slots" const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
const val backupInterval = "backup_interval" const val numberOfBackups = "backup_slots"
const val removeAfterReadSlots = "remove_after_read_slots" const val backupInterval = "backup_interval"
const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" const val removeAfterReadSlots = "remove_after_read_slots"
const val libraryUpdateInterval = "pref_library_update_interval_key" const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key"
const val libraryUpdateRestriction = "library_update_restriction" const val libraryUpdateInterval = "pref_library_update_interval_key"
const val libraryUpdateCategories = "library_update_categories" const val libraryUpdateRestriction = "library_update_restriction"
const val filterDownloaded = "pref_filter_downloaded_key" const val libraryUpdateCategories = "library_update_categories"
const val filterUnread = "pref_filter_unread_key" const val libraryUpdatePrioritization = "library_update_prioritization"
const val filterCompleted = "pref_filter_completed_key" const val filterDownloaded = "pref_filter_downloaded_key"
const val librarySortingMode = "library_sorting_mode" const val filterUnread = "pref_filter_unread_key"
const val automaticUpdates = "automatic_updates" const val filterCompleted = "pref_filter_completed_key"
const val startScreen = "start_screen" const val librarySortingMode = "library_sorting_mode"
const val downloadNew = "download_new" const val automaticUpdates = "automatic_updates"
const val downloadNewCategories = "download_new_categories" const val startScreen = "start_screen"
const val libraryAsList = "pref_display_library_as_list" const val downloadNew = "download_new"
const val lang = "app_language" const val downloadNewCategories = "download_new_categories"
const val defaultCategory = "default_category" const val libraryAsList = "pref_display_library_as_list"
const val skipRead = "skip_read" const val lang = "app_language"
const val downloadBadge = "display_download_badge" const val defaultCategory = "default_category"
@Deprecated("Use the preferences of the source") const val skipRead = "skip_read"
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
const val downloadBadge = "display_download_badge"
@Deprecated("Use the preferences of the source")
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" @Deprecated("Use the preferences of the source")
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
@Deprecated("Use the preferences of the source")
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
fun trackToken(syncId: Int) = "track_token_$syncId" fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
} fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
fun trackToken(syncId: Int) = "track_token_$syncId"
}

View File

@ -57,6 +57,8 @@ class PreferencesHelper(val context: Context) {
fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0) fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0)
fun colorFilterMode() = rxPrefs.getInteger(Keys.colorFilterMode, 0)
fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1) fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1)
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1) fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
@ -141,6 +143,8 @@ class PreferencesHelper(val context: Context) {
fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet()) fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet())
fun libraryUpdatePrioritization() = rxPrefs.getInteger(Keys.libraryUpdatePrioritization, 0)
fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false) fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false)
fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false) fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false)

View File

@ -45,11 +45,11 @@ class SharedPreferencesDataStore(private val prefs: SharedPreferences) : Prefere
prefs.edit().putString(key, value).apply() prefs.edit().putString(key, value).apply()
} }
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> { override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
return prefs.getStringSet(key, defValues) return prefs.getStringSet(key, defValues)
} }
override fun putStringSet(key: String?, values: MutableSet<String>?) { override fun putStringSet(key: String?, values: MutableSet<String>?) {
prefs.edit().putStringSet(key, values).apply() prefs.edit().putStringSet(key, values).apply()
} }
} }

View File

@ -1,32 +1,36 @@
package eu.kanade.tachiyomi.data.track package eu.kanade.tachiyomi.data.track
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
import eu.kanade.tachiyomi.data.track.shikomori.Shikomori import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
class TrackManager(private val context: Context) {
class TrackManager(private val context: Context) {
companion object {
const val MYANIMELIST = 1 companion object {
const val ANILIST = 2 const val MYANIMELIST = 1
const val KITSU = 3 const val ANILIST = 2
const val SHIKOMORI = 4 const val KITSU = 3
} const val SHIKIMORI = 4
const val BANGUMI = 5
val myAnimeList = Myanimelist(context, MYANIMELIST) }
val aniList = Anilist(context, ANILIST) val myAnimeList = Myanimelist(context, MYANIMELIST)
val kitsu = Kitsu(context, KITSU) val aniList = Anilist(context, ANILIST)
val shikomori = Shikomori(context, SHIKOMORI) val kitsu = Kitsu(context, KITSU)
val services = listOf(myAnimeList, aniList, kitsu, shikomori) val shikimori = Shikimori(context, SHIKIMORI)
fun getService(id: Int) = services.find { it.id == id } val bangumi = Bangumi(context, BANGUMI)
fun hasLoggedServices() = services.any { it.isLogged } val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi)
} fun getService(id: Int) = services.find { it.id == id }
fun hasLoggedServices() = services.any { it.isLogged }
}

View File

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

View File

@ -1,214 +1,214 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import com.google.gson.Gson import com.google.gson.Gson
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class Anilist(private val context: Context, id: Int) : TrackService(id) { class Anilist(private val context: Context, id: Int) : TrackService(id) {
companion object { companion object {
const val READING = 1 const val READING = 1
const val COMPLETED = 2 const val COMPLETED = 2
const val ON_HOLD = 3 const val ON_HOLD = 3
const val DROPPED = 4 const val DROPPED = 4
const val PLANNING = 5 const val PLANNING = 5
const val REPEATING = 6 const val REPEATING = 6
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0 const val DEFAULT_SCORE = 0
const val POINT_100 = "POINT_100" const val POINT_100 = "POINT_100"
const val POINT_10 = "POINT_10" const val POINT_10 = "POINT_10"
const val POINT_10_DECIMAL = "POINT_10_DECIMAL" const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
const val POINT_5 = "POINT_5" const val POINT_5 = "POINT_5"
const val POINT_3 = "POINT_3" const val POINT_3 = "POINT_3"
} }
override val name = "AniList" override val name = "AniList"
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
private val api by lazy { AnilistApi(client, interceptor) } private val api by lazy { AnilistApi(client, interceptor) }
private val scorePreference = preferences.anilistScoreType() private val scorePreference = preferences.anilistScoreType()
init { init {
// If the preference is an int from APIv1, logout user to force using APIv2 // If the preference is an int from APIv1, logout user to force using APIv2
try { try {
scorePreference.get() scorePreference.get()
} catch (e: ClassCastException) { } catch (e: ClassCastException) {
logout() logout()
scorePreference.delete() scorePreference.delete()
} }
} }
override fun getLogo() = R.drawable.al override fun getLogo() = R.drawable.al
override fun getLogoColor() = Color.rgb(18, 25, 35) override fun getLogoColor() = Color.rgb(18, 25, 35)
override fun getStatusList(): List<Int> { override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
} }
override fun getStatus(status: Int): String = with(context) { override fun getStatus(status: Int): String = with(context) {
when (status) { when (status) {
READING -> getString(R.string.reading) READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed) COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold) ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped) DROPPED -> getString(R.string.dropped)
PLANNING -> getString(R.string.plan_to_read) PLANNING -> getString(R.string.plan_to_read)
REPEATING -> getString(R.string.repeating) REPEATING -> getString(R.string.repeating)
else -> "" else -> ""
} }
} }
override fun getScoreList(): List<String> { override fun getScoreList(): List<String> {
return when (scorePreference.getOrDefault()) { return when (scorePreference.getOrDefault()) {
// 10 point // 10 point
POINT_10 -> IntRange(0, 10).map(Int::toString) POINT_10 -> IntRange(0, 10).map(Int::toString)
// 100 point // 100 point
POINT_100 -> IntRange(0, 100).map(Int::toString) POINT_100 -> IntRange(0, 100).map(Int::toString)
// 5 stars // 5 stars
POINT_5 -> IntRange(0, 5).map { "$it" } POINT_5 -> IntRange(0, 5).map { "$it" }
// Smiley // Smiley
POINT_3 -> listOf("-", "😦", "😐", "😊") POINT_3 -> listOf("-", "😦", "😐", "😊")
// 10 point decimal // 10 point decimal
POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() }
else -> throw Exception("Unknown score type") else -> throw Exception("Unknown score type")
} }
} }
override fun indexToScore(index: Int): Float { override fun indexToScore(index: Int): Float {
return when (scorePreference.getOrDefault()) { return when (scorePreference.getOrDefault()) {
// 10 point // 10 point
POINT_10 -> index * 10f POINT_10 -> index * 10f
// 100 point // 100 point
POINT_100 -> index.toFloat() POINT_100 -> index.toFloat()
// 5 stars // 5 stars
POINT_5 -> when { POINT_5 -> when {
index == 0 -> 0f index == 0 -> 0f
else -> index * 20f - 10f else -> index * 20f - 10f
} }
// Smiley // Smiley
POINT_3 -> when { POINT_3 -> when {
index == 0 -> 0f index == 0 -> 0f
else -> index * 25f + 10f else -> index * 25f + 10f
} }
// 10 point decimal // 10 point decimal
POINT_10_DECIMAL -> index.toFloat() POINT_10_DECIMAL -> index.toFloat()
else -> throw Exception("Unknown score type") else -> throw Exception("Unknown score type")
} }
} }
override fun displayScore(track: Track): String { override fun displayScore(track: Track): String {
val score = track.score val score = track.score
return when (scorePreference.getOrDefault()) { return when (scorePreference.getOrDefault()) {
POINT_5 -> when { POINT_5 -> when {
score == 0f -> "0 ★" score == 0f -> "0 ★"
else -> "${((score + 10) / 20).toInt()}" else -> "${((score + 10) / 20).toInt()}"
} }
POINT_3 -> when { POINT_3 -> when {
score == 0f -> "0" score == 0f -> "0"
score <= 35 -> "😦" score <= 35 -> "😦"
score <= 60 -> "😐" score <= 60 -> "😐"
else -> "😊" else -> "😊"
} }
else -> track.toAnilistScore() else -> track.toAnilistScore()
} }
} }
override fun add(track: Track): Observable<Track> { override fun add(track: Track): Observable<Track> {
return api.addLibManga(track) return api.addLibManga(track)
} }
override fun update(track: Track): Observable<Track> { override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
// If user was using API v1 fetch library_id // If user was using API v1 fetch library_id
if (track.library_id == null || track.library_id!! == 0L){ if (track.library_id == null || track.library_id!! == 0L){
return api.findLibManga(track, getUsername().toInt()).flatMap { return api.findLibManga(track, getUsername().toInt()).flatMap {
if (it == null) { if (it == null) {
throw Exception("$track not found on user library") throw Exception("$track not found on user library")
} }
track.library_id = it.library_id track.library_id = it.library_id
api.updateLibManga(track) api.updateLibManga(track)
} }
} }
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername().toInt()) return api.findLibManga(track, getUsername().toInt())
.flatMap { remoteTrack -> .flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
update(track) update(track)
} else { } else {
// Set default fields if it's not found in the list // Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat() track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) add(track)
} }
} }
} }
override fun search(query: String): Observable<List<TrackSearch>> { override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername().toInt()) return api.getLibManga(track, getUsername().toInt())
.map { remoteTrack -> .map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track track
} }
} }
override fun login(username: String, password: String) = login(password) override fun login(username: String, password: String) = login(password)
fun login(token: String): Completable { fun login(token: String): Completable {
val oauth = api.createOAuth(token) val oauth = api.createOAuth(token)
interceptor.setAuth(oauth) interceptor.setAuth(oauth)
return api.getCurrentUser().map { (username, scoreType) -> return api.getCurrentUser().map { (username, scoreType) ->
scorePreference.set(scoreType) scorePreference.set(scoreType)
saveCredentials(username.toString(), oauth.access_token) saveCredentials(username.toString(), oauth.access_token)
}.doOnError{ }.doOnError{
logout() logout()
}.toCompletable() }.toCompletable()
} }
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).set(null) preferences.trackToken(this).set(null)
interceptor.setAuth(null) interceptor.setAuth(null)
} }
fun saveOAuth(oAuth: OAuth?) { fun saveOAuth(oAuth: OAuth?) {
preferences.trackToken(this).set(gson.toJson(oAuth)) preferences.trackToken(this).set(gson.toJson(oAuth))
} }
fun loadOAuth(): OAuth? { fun loadOAuth(): OAuth? {
return try { return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
} }

View File

@ -1,286 +1,286 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri import android.net.Uri
import com.github.salomonbrys.kotson.* import com.github.salomonbrys.kotson.*
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import rx.Observable import rx.Observable
import java.util.Calendar import java.util.Calendar
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val parser = JsonParser() private val parser = JsonParser()
private val jsonMime = MediaType.parse("application/json; charset=utf-8") private val jsonMime = MediaType.parse("application/json; charset=utf-8")
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> {
fun addLibManga(track: Track): Observable<Track> { val query = """
val query = """ |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) | id
{ id status } } | status
""" |}
val variables = jsonObject( |}
"mangaId" to track.media_id, |""".trimMargin()
"progress" to track.last_chapter_read, val variables = jsonObject(
"status" to track.toAnilistStatus() "mangaId" to track.media_id,
) "progress" to track.last_chapter_read,
val payload = jsonObject( "status" to track.toAnilistStatus()
"query" to query, )
"variables" to variables val payload = jsonObject(
) "query" to query,
val body = RequestBody.create(jsonMime, payload.toString()) "variables" to variables
val request = Request.Builder() )
.url(apiUrl) val body = RequestBody.create(jsonMime, payload.toString())
.post(body) val request = Request.Builder()
.build() .url(apiUrl)
return authClient.newCall(request) .post(body)
.asObservableSuccess() .build()
.map { netResponse -> return authClient.newCall(request)
val responseBody = netResponse.body()?.string().orEmpty() .asObservableSuccess()
netResponse.close() .map { netResponse ->
if (responseBody.isEmpty()) { val responseBody = netResponse.body()?.string().orEmpty()
throw Exception("Null Response") netResponse.close()
} if (responseBody.isEmpty()) {
val response = parser.parse(responseBody).obj throw Exception("Null Response")
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong }
track val response = parser.parse(responseBody).obj
} track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
} track
}
fun updateLibManga(track: Track): Observable<Track> { }
val query = """
mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { fun updateLibManga(track: Track): Observable<Track> {
SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { val query = """
id |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
status |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
progress |id
} |status
} |progress
""" |}
val variables = jsonObject( |}
"listId" to track.library_id, |""".trimMargin()
"progress" to track.last_chapter_read, val variables = jsonObject(
"status" to track.toAnilistStatus(), "listId" to track.library_id,
"score" to track.score.toInt() "progress" to track.last_chapter_read,
) "status" to track.toAnilistStatus(),
val payload = jsonObject( "score" to track.score.toInt()
"query" to query, )
"variables" to variables val payload = jsonObject(
) "query" to query,
val body = RequestBody.create(jsonMime, payload.toString()) "variables" to variables
val request = Request.Builder() )
.url(apiUrl) val body = RequestBody.create(jsonMime, payload.toString())
.post(body) val request = Request.Builder()
.build() .url(apiUrl)
return authClient.newCall(request) .post(body)
.asObservableSuccess() .build()
.map { return authClient.newCall(request)
track .asObservableSuccess()
} .map {
} track
}
fun search(search: String): Observable<List<TrackSearch>> { }
val query = """
query Search(${'$'}query: String) { fun search(search: String): Observable<List<TrackSearch>> {
Page (perPage: 50) { val query = """
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { |query Search(${'$'}query: String) {
id |Page (perPage: 50) {
title { |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
romaji |id
} |title {
coverImage { |romaji
large |}
} |coverImage {
type |large
status |}
chapters |type
description |status
startDate { |chapters
year |description
month |startDate {
day |year
} |month
} |day
} |}
} |}
""" |}
val variables = jsonObject( |}
"query" to search |""".trimMargin()
) val variables = jsonObject(
val payload = jsonObject( "query" to search
"query" to query, )
"variables" to variables val payload = jsonObject(
) "query" to query,
val body = RequestBody.create(jsonMime, payload.toString()) "variables" to variables
val request = Request.Builder() )
.url(apiUrl) val body = RequestBody.create(jsonMime, payload.toString())
.post(body) val request = Request.Builder()
.build() .url(apiUrl)
return authClient.newCall(request) .post(body)
.asObservableSuccess() .build()
.map { netResponse -> return authClient.newCall(request)
val responseBody = netResponse.body()?.string().orEmpty() .asObservableSuccess()
if (responseBody.isEmpty()) { .map { netResponse ->
throw Exception("Null Response") val responseBody = netResponse.body()?.string().orEmpty()
} if (responseBody.isEmpty()) {
val response = parser.parse(responseBody).obj throw Exception("Null Response")
val data = response["data"]!!.obj }
val page = data["Page"].obj val response = parser.parse(responseBody).obj
val media = page["media"].array val data = response["data"]!!.obj
val entries = media.map { jsonToALManga(it.obj) } val page = data["Page"].obj
entries.map { it.toTrack() } val media = page["media"].array
} val entries = media.map { jsonToALManga(it.obj) }
} entries.map { it.toTrack() }
}
}
fun findLibManga(track: Track, userid: Int) : Observable<Track?> {
val query = """
query (${'$'}id: Int!, ${'$'}manga_id: Int!) { fun findLibManga(track: Track, userid: Int): Observable<Track?> {
Page { val query = """
mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { |query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
id |Page {
status |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
scoreRaw: score(format: POINT_100) |id
progress |status
media{ |scoreRaw: score(format: POINT_100)
id |progress
title { |media {
romaji |id
} |title {
coverImage { |romaji
large |}
} |coverImage {
type |large
status |}
chapters |type
description |status
startDate { |chapters
year |description
month |startDate {
day |year
} |month
} |day
} |}
} |}
} |}
""" |}
val variables = jsonObject( |}
"id" to userid, |""".trimMargin()
"manga_id" to track.media_id val variables = jsonObject(
) "id" to userid,
val payload = jsonObject( "manga_id" to track.media_id
"query" to query, )
"variables" to variables val payload = jsonObject(
) "query" to query,
val body = RequestBody.create(jsonMime, payload.toString()) "variables" to variables
val request = Request.Builder() )
.url(apiUrl) val body = RequestBody.create(jsonMime, payload.toString())
.post(body) val request = Request.Builder()
.build() .url(apiUrl)
return authClient.newCall(request) .post(body)
.asObservableSuccess() .build()
.map { netResponse -> return authClient.newCall(request)
val responseBody = netResponse.body()?.string().orEmpty() .asObservableSuccess()
if (responseBody.isEmpty()) { .map { netResponse ->
throw Exception("Null Response") val responseBody = netResponse.body()?.string().orEmpty()
} if (responseBody.isEmpty()) {
val response = parser.parse(responseBody).obj throw Exception("Null Response")
val data = response["data"]!!.obj }
val page = data["Page"].obj val response = parser.parse(responseBody).obj
val media = page["mediaList"].array val data = response["data"]!!.obj
val entries = media.map { jsonToALUserManga(it.obj) } val page = data["Page"].obj
entries.firstOrNull()?.toTrack() val media = page["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) }
} entries.firstOrNull()?.toTrack()
}
}
fun getLibManga(track: Track, userid: Int): Observable<Track> { }
return findLibManga(track, userid)
.map { it ?: throw Exception("Could not find manga") } fun getLibManga(track: Track, userid: Int): Observable<Track> {
} return findLibManga(track, userid)
.map { it ?: throw Exception("Could not find manga") }
fun createOAuth(token: String): OAuth { }
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
} fun createOAuth(token: String): OAuth {
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
fun getCurrentUser(): Observable<Pair<Int, String>> { }
val query = """
query User fun getCurrentUser(): Observable<Pair<Int, String>> {
{ val query = """
Viewer { |query User {
id |Viewer {
mediaListOptions { |id
scoreFormat |mediaListOptions {
} |scoreFormat
} |}
} |}
""" |}
val payload = jsonObject( |""".trimMargin()
"query" to query val payload = jsonObject(
) "query" to query
val body = RequestBody.create(jsonMime, payload.toString()) )
val request = Request.Builder() val body = RequestBody.create(jsonMime, payload.toString())
.url(apiUrl) val request = Request.Builder()
.post(body) .url(apiUrl)
.build() .post(body)
return authClient.newCall(request) .build()
.asObservableSuccess() return authClient.newCall(request)
.map { netResponse -> .asObservableSuccess()
val responseBody = netResponse.body()?.string().orEmpty() .map { netResponse ->
if (responseBody.isEmpty()) { val responseBody = netResponse.body()?.string().orEmpty()
throw Exception("Null Response") if (responseBody.isEmpty()) {
} throw Exception("Null Response")
val response = parser.parse(responseBody).obj }
val data = response["data"]!!.obj val response = parser.parse(responseBody).obj
val viewer = data["Viewer"].obj val data = response["data"]!!.obj
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) val viewer = data["Viewer"].obj
} Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
} }
}
fun jsonToALManga(struct: JsonObject): ALManga{
val date = try { private fun jsonToALManga(struct: JsonObject): ALManga {
val date = Calendar.getInstance() val date = try {
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1, val date = Calendar.getInstance()
struct["startDate"]["day"].nullInt ?: 0) date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
date.timeInMillis struct["startDate"]["day"].nullInt ?: 0)
} catch (_: Exception) { date.timeInMillis
0L } catch (_: Exception) {
} 0L
}
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
date, struct["chapters"].nullInt ?: 0) struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
} date, struct["chapters"].nullInt ?: 0)
}
fun jsonToALUserManga(struct: JsonObject): ALUserManga{
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj) ) private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
} return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj))
}
companion object { companion object {
private const val clientId = "385" private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth" private const val clientUrl = "tachiyomi://anilist-auth"
private const val apiUrl = "https://graphql.anilist.co/" private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/" private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/" private const val baseMangaUrl = "https://anilist.co/manga/"
fun mangaUrl(mediaId: Int): String { fun mangaUrl(mediaId: Int): String {
return baseMangaUrl + mediaId return baseMangaUrl + mediaId
} }
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon() fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
.appendQueryParameter("client_id", clientId) .appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "token") .appendQueryParameter("response_type", "token")
.build() .build()
} }
} }

View File

@ -1,58 +1,58 @@
package eu.kanade.tachiyomi.data.track.anilist package eu.kanade.tachiyomi.data.track.anilist
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * OAuth object used for authenticated requests.
* *
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date. * before its original expiration date.
*/ */
private var oauth: OAuth? = null private var oauth: OAuth? = null
set(value) { set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000) field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
} }
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
if (token.isNullOrEmpty()) { if (token.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist") throw Exception("Not authenticated with Anilist")
} }
if (oauth == null){ if (oauth == null){
oauth = anilist.loadOAuth() oauth = anilist.loadOAuth()
} }
// Refresh access token if null or expired. // Refresh access token if null or expired.
if (oauth!!.isExpired()) { if (oauth!!.isExpired()) {
anilist.logout() anilist.logout()
throw Exception("Token expired") throw Exception("Token expired")
} }
// Throw on null auth. // Throw on null auth.
if (oauth == null) { if (oauth == null) {
throw Exception("No authentication token") throw Exception("No authentication token")
} }
// Add the authorization header to the original request. // Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder() val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}") .addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build() .build()
return chain.proceed(authRequest) return chain.proceed(authRequest)
} }
/** /**
* Called when the user authenticates with Anilist for the first time. Sets the refresh token * Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object. * and the oauth object.
*/ */
fun setAuth(oauth: OAuth?) { fun setAuth(oauth: OAuth?) {
token = oauth?.access_token token = oauth?.access_token
this.oauth = oauth this.oauth = oauth
anilist.saveOAuth(oauth) anilist.saveOAuth(oauth)
} }
} }

View File

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

View File

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Avatar(
val large: String? = "",
val medium: String? = "",
val small: String? = ""
)

View File

@ -0,0 +1,144 @@
package eu.kanade.tachiyomi.data.track.bangumi
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 eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Bangumi(private val context: Context, id: Int) : TrackService(id) {
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.statusLibManga(track)
.flatMap {
api.findLibManga(track).flatMap { remoteTrack ->
if (remoteTrack != null && it != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
track.status = remoteTrack.status
track.last_chapter_read = remoteTrack.last_chapter_read
refresh(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
update(track)
}
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
track.copyPersonalFrom(it!!)
api.findLibManga(track)
.map { remoteTrack ->
if (remoteTrack != null) {
track.total_chapters = remoteTrack.total_chapters
track.status = remoteTrack.status
}
track
}
}
}
companion object {
const val READING = 3
const val COMPLETED = 2
const val ON_HOLD = 4
const val DROPPED = 5
const val PLANNING = 1
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Bangumi"
private val gson: Gson by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this, gson) }
private val api by lazy { BangumiApi(client, interceptor) }
override fun getLogo() = R.drawable.bangumi
override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING)
}
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)
PLANNING -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth)
if (oauth != null) {
saveCredentials(oauth.user_id.toString(), oauth.access_token)
}
}.doOnError {
logout()
}.toCompletable()
}
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
}
}
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
}
}

View File

@ -0,0 +1,211 @@
package eu.kanade.tachiyomi.data.track.bangumi
import android.net.Uri
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.obj
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.CacheControl
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) {
private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> {
val body = FormBody.Builder()
.add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus())
.build()
val request = Request.Builder()
.url("$apiUrl/collection/${track.media_id}/update")
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun updateLibManga(track: Track): Observable<Track> {
// chapter update
val body = FormBody.Builder()
.add("watched_eps", track.last_chapter_read.toString())
.build()
val request = Request.Builder()
.url("$apiUrl/subject/${track.media_id}/update/watched_eps")
.post(body)
.build()
// read status update
val sbody = FormBody.Builder()
.add("status", track.toBangumiStatus())
.build()
val srequest = Request.Builder()
.url("$apiUrl/collection/${track.media_id}/update")
.post(sbody)
.build()
return authClient.newCall(srequest)
.asObservableSuccess()
.map {
track
}.flatMap {
authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
}
fun search(search: String): Observable<List<TrackSearch>> {
val url = Uri.parse(
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon()
.appendQueryParameter("max_results", "20")
.build()
val request = Request.Builder()
.url(url.toString())
.get()
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
var responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
if(responseBody.contains("\"code\":404")){
responseBody = "{\"results\":0,\"list\":[]}"
}
val response = parser.parse(responseBody).obj["list"]?.array
response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) }
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.BANGUMI).apply {
media_id = obj["id"].asInt
title = obj["name_cn"].asString
cover_url = obj["images"].obj["common"].asString
summary = obj["name"].asString
tracking_url = obj["url"].asString
}
}
private fun jsonToTrack(mangas: JsonObject): Track {
return Track.create(TrackManager.BANGUMI).apply {
title = mangas["name"].asString
media_id = mangas["id"].asInt
score = if (mangas["rating"] != null)
(if (mangas["rating"].isJsonObject) mangas["rating"].obj["score"].asFloat else 0f)
else 0f
status = Bangumi.DEFAULT_STATUS
tracking_url = mangas["url"].asString
}
}
fun findLibManga(track: Track): Observable<Track?> {
val urlMangas = "$apiUrl/subject/${track.media_id}"
val requestMangas = Request.Builder()
.url(urlMangas)
.get()
.build()
return authClient.newCall(requestMangas)
.asObservableSuccess()
.map { netResponse ->
// get comic info
val responseBody = netResponse.body()?.string().orEmpty()
jsonToTrack(parser.parse(responseBody).obj)
}
}
fun statusLibManga(track: Track): Observable<Track?> {
val urlUserRead = "$apiUrl/collection/${track.media_id}"
val requestUserRead = Request.Builder()
.url(urlUserRead)
.cacheControl(CacheControl.FORCE_NETWORK)
.get()
.build()
// todo get user readed chapter here
return authClient.newCall(requestUserRead)
.asObservableSuccess()
.map { netResponse ->
val resp = netResponse.body()?.string()
val coll = gson.fromJson(resp, Collection::class.java)
track.status = coll.status?.id!!
track.last_chapter_read = coll.ep_status!!
track
}
}
fun accessToken(code: String): Observable<OAuth> {
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
gson.fromJson(responseBody, OAuth::class.java)
}
}
private fun accessTokenRequest(code: String) = POST(oauthUrl,
body = FormBody.Builder()
.add("grant_type", "authorization_code")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("code", code)
.add("redirect_uri", redirectUrl)
.build()
)
companion object {
private const val clientId = "bgm10555cda0762e80ca"
private const val clientSecret = "8fff394a8627b4c388cbf349ec865775"
private const val baseUrl = "https://bangumi.org"
private const val apiUrl = "https://api.bgm.tv"
private const val oauthUrl = "https://bgm.tv/oauth/access_token"
private const val loginUrl = "https://bgm.tv/oauth/authorize"
private const val redirectUrl = "tachiyomi://bangumi-auth"
private const val baseMangaUrl = "$apiUrl/mangas"
fun mangaUrl(remoteId: Int): String {
return "$baseMangaUrl/$remoteId"
}
fun authUrl() =
Uri.parse(loginUrl).buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", redirectUrl)
.build()
fun refreshTokenRequest(token: String) = POST(oauthUrl,
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.add("redirect_uri", redirectUrl)
.build())
}
}

View File

@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.data.track.bangumi
import com.google.gson.Gson
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.Response
class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = bangumi.restoreToken()
fun addTocken(tocken: String, oidFormBody: FormBody): FormBody {
val newFormBody = FormBody.Builder()
for (i in 0 until oidFormBody.size()) {
newFormBody.add(oidFormBody.name(i), oidFormBody.value(i))
}
newFormBody.add("access_token", tocken)
return newFormBody.build()
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
if (currAuth.isExpired()) {
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
} else {
response.close()
}
}
var authRequest = if (originalRequest.method() == "GET") originalRequest.newBuilder()
.header("User-Agent", "Tachiyomi")
.url(originalRequest.url().newBuilder()
.addQueryParameter("access_token", currAuth.access_token).build())
.build() else originalRequest.newBuilder()
.post(addTocken(currAuth.access_token, originalRequest.body() as FormBody))
.header("User-Agent", "Tachiyomi")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth?) {
this.oauth = if (oauth == null) null else OAuth(
oauth.access_token,
oauth.token_type,
System.currentTimeMillis() / 1000,
oauth.expires_in,
oauth.refresh_token,
this.oauth?.user_id)
bangumi.saveToken(oauth)
}
}

View File

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.data.track.bangumi
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toBangumiStatus() = when (status) {
Bangumi.READING -> "do"
Bangumi.COMPLETED -> "collect"
Bangumi.ON_HOLD -> "on_hold"
Bangumi.DROPPED -> "dropped"
Bangumi.PLANNING -> "wish"
else -> throw NotImplementedError("Unknown status")
}
fun toTrackStatus(status: String) = when (status) {
"do" -> Bangumi.READING
"collect" -> Bangumi.COMPLETED
"on_hold" -> Bangumi.ON_HOLD
"dropped" -> Bangumi.DROPPED
"wish" -> Bangumi.PLANNING
else -> throw Exception("Unknown status")
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Collection(
val `private`: Int? = 0,
val comment: String? = "",
val ep_status: Int? = 0,
val lasttouch: Int? = 0,
val rating: Int? = 0,
val status: Status? = Status(),
val tag: List<String?>? = listOf(),
val user: User? = User(),
val vol_status: Int? = 0
)

View File

@ -0,0 +1,16 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?,
val user_id: Long?
) {
// Access token refersh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -0,0 +1,7 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Status(
val id: Int? = 0,
val name: String? = "",
val type: String? = ""
)

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class User(
val avatar: Avatar? = Avatar(),
val id: Int? = 0,
val nickname: String? = "",
val sign: String? = "",
val url: String? = "",
val usergroup: Int? = 0,
val username: String? = ""
)

View File

@ -1,144 +1,144 @@
package eu.kanade.tachiyomi.data.track.kitsu package eu.kanade.tachiyomi.data.track.kitsu
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import com.google.gson.Gson import com.google.gson.Gson
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat import java.text.DecimalFormat
class Kitsu(private val context: Context, id: Int) : TrackService(id) { class Kitsu(private val context: Context, id: Int) : TrackService(id) {
companion object { companion object {
const val READING = 1 const val READING = 1
const val COMPLETED = 2 const val COMPLETED = 2
const val ON_HOLD = 3 const val ON_HOLD = 3
const val DROPPED = 4 const val DROPPED = 4
const val PLAN_TO_READ = 5 const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0f const val DEFAULT_SCORE = 0f
} }
override val name = "Kitsu" override val name = "Kitsu"
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this, gson) } private val interceptor by lazy { KitsuInterceptor(this, gson) }
private val api by lazy { KitsuApi(client, interceptor) } private val api by lazy { KitsuApi(client, interceptor) }
override fun getLogo(): Int { override fun getLogo(): Int {
return R.drawable.kitsu return R.drawable.kitsu
} }
override fun getLogoColor(): Int { override fun getLogoColor(): Int {
return Color.rgb(51, 37, 50) return Color.rgb(51, 37, 50)
} }
override fun getStatusList(): List<Int> { override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
} }
override fun getStatus(status: Int): String = with(context) { override fun getStatus(status: Int): String = with(context) {
when (status) { when (status) {
READING -> getString(R.string.reading) READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed) COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold) ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped) DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read) PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> "" else -> ""
} }
} }
override fun getScoreList(): List<String> { override fun getScoreList(): List<String> {
val df = DecimalFormat("0.#") val df = DecimalFormat("0.#")
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
} }
override fun indexToScore(index: Int): Float { override fun indexToScore(index: Int): Float {
return if (index > 0) (index + 1) / 2f else 0f return if (index > 0) (index + 1) / 2f else 0f
} }
override fun displayScore(track: Track): String { override fun displayScore(track: Track): String {
val df = DecimalFormat("0.#") val df = DecimalFormat("0.#")
return df.format(track.score) return df.format(track.score)
} }
override fun add(track: Track): Observable<Track> { override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUserId()) return api.addLibManga(track, getUserId())
} }
override fun update(track: Track): Observable<Track> { override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED track.status = COMPLETED
} }
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUserId()) return api.findLibManga(track, getUserId())
.flatMap { remoteTrack -> .flatMap { remoteTrack ->
if (remoteTrack != null) { if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id track.media_id = remoteTrack.media_id
update(track) update(track)
} else { } else {
track.score = DEFAULT_SCORE track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) add(track)
} }
} }
} }
override fun search(query: String): Observable<List<TrackSearch>> { override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track) return api.getLibManga(track)
.map { remoteTrack -> .map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track track
} }
} }
override fun login(username: String, password: String): Completable { override fun login(username: String, password: String): Completable {
return api.login(username, password) return api.login(username, password)
.doOnNext { interceptor.newAuth(it) } .doOnNext { interceptor.newAuth(it) }
.flatMap { api.getCurrentUser() } .flatMap { api.getCurrentUser() }
.doOnNext { userId -> saveCredentials(username, userId) } .doOnNext { userId -> saveCredentials(username, userId) }
.doOnError { logout() } .doOnError { logout() }
.toCompletable() .toCompletable()
} }
override fun logout() { override fun logout() {
super.logout() super.logout()
interceptor.newAuth(null) interceptor.newAuth(null)
} }
private fun getUserId(): String { private fun getUserId(): String {
return getPassword() return getPassword()
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth) val json = gson.toJson(oauth)
preferences.trackToken(this).set(json) preferences.trackToken(this).set(json)
} }
fun restoreToken(): OAuth? { fun restoreToken(): OAuth? {
return try { return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
} }

View File

@ -18,13 +18,12 @@ class KitsuSearchManga(obj: JsonObject) {
private val synopsis by obj.byString private val synopsis by obj.byString
private var startDate = obj.get("startDate").nullString?.let { private var startDate = obj.get("startDate").nullString?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(Date(it!!.toLong() * 1000)) outputDf.format(Date(it.toLong() * 1000))
} }
private val endDate = obj.get("endDate").nullString private val endDate = obj.get("endDate").nullString
@CallSuper @CallSuper
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply { fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = this@KitsuSearchManga.id media_id = this@KitsuSearchManga.id
title = canonicalTitle title = canonicalTitle
total_chapters = chapterCount ?: 0 total_chapters = chapterCount ?: 0
@ -55,7 +54,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
val progress by obj["attributes"].byInt val progress by obj["attributes"].byInt
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply { fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = libraryId media_id = libraryId
title = canonicalTitle title = canonicalTitle
total_chapters = chapterCount ?: 0 total_chapters = chapterCount ?: 0

View File

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

View File

@ -1,141 +1,164 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl import okhttp3.HttpUrl
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import java.lang.Exception
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
companion object {
companion object {
const val READING = 1 const val READING = 1
const val COMPLETED = 2 const val COMPLETED = 2
const val ON_HOLD = 3 const val ON_HOLD = 3
const val DROPPED = 4 const val DROPPED = 4
const val PLAN_TO_READ = 6 const val PLAN_TO_READ = 6
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0 const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net" const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID" const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in" const val LOGGED_IN_COOKIE = "is_logged_in"
} }
private val api by lazy { MyanimelistApi(client) } private val interceptor by lazy { MyAnimeListInterceptor(this) }
private val api by lazy { MyanimelistApi(client, interceptor) }
override val name: String
get() = "MyAnimeList" override val name: String
get() = "MyAnimeList"
override fun getLogo() = R.drawable.mal
override fun getLogo() = R.drawable.mal
override fun getLogoColor() = Color.rgb(46, 81, 162)
override fun getLogoColor() = Color.rgb(46, 81, 162)
override fun getStatus(status: Int): String = with(context) {
when (status) { override fun getStatus(status: Int): String = with(context) {
READING -> getString(R.string.reading) when (status) {
COMPLETED -> getString(R.string.completed) READING -> getString(R.string.reading)
ON_HOLD -> getString(R.string.on_hold) COMPLETED -> getString(R.string.completed)
DROPPED -> getString(R.string.dropped) ON_HOLD -> getString(R.string.on_hold)
PLAN_TO_READ -> getString(R.string.plan_to_read) DROPPED -> getString(R.string.dropped)
else -> "" 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 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 getScoreList(): List<String> {
} return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString() override fun displayScore(track: Track): String {
} return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getCSRF()) 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) { override fun update(track: Track): Observable<Track> {
track.status = COMPLETED if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
} track.status = COMPLETED
}
return api.updateLibManga(track, getCSRF())
} return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getCSRF()) override fun bind(track: Track): Observable<Track> {
.flatMap { remoteTrack -> return api.findLibManga(track)
if (remoteTrack != null) { .flatMap { remoteTrack ->
track.copyPersonalFrom(remoteTrack) if (remoteTrack != null) {
update(track) track.copyPersonalFrom(remoteTrack)
} else { update(track)
// Set default fields if it's not found in the list } else {
track.score = DEFAULT_SCORE.toFloat() // Set default fields if it's not found in the list
track.status = DEFAULT_STATUS track.score = DEFAULT_SCORE.toFloat()
add(track) track.status = DEFAULT_STATUS
} add(track)
} }
} }
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query) override fun search(query: String): Observable<List<TrackSearch>> {
} return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getCSRF()) override fun refresh(track: Track): Observable<Track> {
.map { remoteTrack -> return api.getLibManga(track)
track.copyPersonalFrom(remoteTrack) .map { remoteTrack ->
track.total_chapters = remoteTrack.total_chapters track.copyPersonalFrom(remoteTrack)
track track.total_chapters = remoteTrack.total_chapters
} track
} }
}
override fun login(username: String, password: String): Completable {
logout() override fun login(username: String, password: String): Completable {
logout()
return api.login(username, password)
.doOnNext { csrf -> saveCSRF(csrf) } return Observable.fromCallable { api.login(username, password) }
.doOnNext { saveCredentials(username, password) } .doOnNext { csrf -> saveCSRF(csrf) }
.doOnError { logout() } .doOnNext { saveCredentials(username, password) }
.toCompletable() .doOnError { logout() }
} .toCompletable()
}
override fun logout() {
super.logout() fun refreshLogin() {
preferences.trackToken(this).delete() val username = getUsername()
networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!) val password = getPassword()
} logout()
override val isLogged: Boolean try {
get() = !getUsername().isEmpty() && val csrf = api.login(username, password)
!getPassword().isEmpty() && saveCSRF(csrf)
checkCookies() && saveCredentials(username, password)
!getCSRF().isEmpty() } catch (e: Exception) {
logout()
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault() throw e
}
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf) }
private fun checkCookies(): Boolean { // Attempt to login again if cookies have been cleared but credentials are still filled
var ckCount = 0 fun ensureLoggedIn() {
val url = HttpUrl.parse(BASE_URL)!! if (isAuthorized) return
for (ck in networkService.cookieManager.get(url)) { if (!isLogged) throw Exception("MAL Login Credentials not found")
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
ckCount++ refreshLogin()
} }
return ckCount == 2 override fun logout() {
} super.logout()
preferences.trackToken(this).delete()
} networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!)
}
val isAuthorized: Boolean
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
private fun checkCookies(): Boolean {
var ckCount = 0
val url = HttpUrl.parse(BASE_URL)!!
for (ck in networkService.cookieManager.get(url)) {
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
ckCount++
}
return ckCount == 2
}
}

View File

@ -0,0 +1,59 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okio.Buffer
import org.json.JSONObject
class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
myanimelist.ensureLoggedIn()
val request = chain.request()
var response = chain.proceed(updateRequest(request))
if (response.code() == 400){
myanimelist.refreshLogin()
response = chain.proceed(updateRequest(request))
}
return response
}
private fun updateRequest(request: Request): Request {
return request.body()?.let {
val contentType = it.contentType().toString()
val updatedBody = when {
contentType.contains("x-www-form-urlencoded") -> updateFormBody(it)
contentType.contains("json") -> updateJsonBody(it)
else -> it
}
request.newBuilder().post(updatedBody).build()
} ?: request
}
private fun bodyToString(requestBody: RequestBody): String {
Buffer().use {
requestBody.writeTo(it)
return it.readUtf8()
}
}
private fun updateFormBody(requestBody: RequestBody): RequestBody {
val formString = bodyToString(requestBody)
return RequestBody.create(requestBody.contentType(),
"$formString${if (formString.isNotEmpty()) "&" else ""}${MyanimelistApi.CSRF}=${myanimelist.getCSRF()}")
}
private fun updateJsonBody(requestBody: RequestBody): RequestBody {
val jsonString = bodyToString(requestBody)
val newBody = JSONObject(jsonString)
.put(MyanimelistApi.CSRF, myanimelist.getCSRF())
return RequestBody.create(requestBody.contentType(), newBody.toString())
}
}

View File

@ -22,61 +22,122 @@ import java.io.InputStreamReader
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
class MyanimelistApi(private val client: OkHttpClient) { class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
fun addLibManga(track: Track, csrf: String): Observable<Track> { private val authClient = client.newBuilder().addInterceptor(interceptor).build()
return Observable.defer {
client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf)))
.asObservableSuccess()
.map { track }
}
}
fun updateLibManga(track: Track, csrf: String): Observable<Track> {
return Observable.defer {
client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf)))
.asObservableSuccess()
.map { track }
}
}
fun search(query: String): Observable<List<TrackSearch>> { fun search(query: String): Observable<List<TrackSearch>> {
return client.newCall(GET(getSearchUrl(query))) return if (query.startsWith(PREFIX_MY)) {
.asObservable() val realQuery = query.removePrefix(PREFIX_MY)
.flatMap { response -> getList()
Observable.from(Jsoup.parse(response.consumeBody()) .flatMap { Observable.from(it) }
.select("div.js-categories-seasonal.js-block-list.list") .filter { it.title.contains(realQuery, true) }
.select("table").select("tbody") .toList()
.select("tr").drop(1)) }
} else {
.filter { row -> client.newCall(GET(searchUrl(query)))
row.select(TD)[2].text() != "Novel" .asObservable()
} .flatMap { response ->
.map { row -> Observable.from(Jsoup.parse(response.consumeBody())
TrackSearch.create(TrackManager.MYANIMELIST).apply { .select("div.js-categories-seasonal.js-block-list.list")
title = row.searchTitle() .select("table").select("tbody")
media_id = row.searchMediaId() .select("tr").drop(1))
total_chapters = row.searchTotalChapters()
summary = row.searchSummary()
cover_url = row.searchCoverUrl()
tracking_url = mangaUrl(media_id)
publishing_status = row.searchPublishingStatus()
publishing_type = row.searchPublishingType()
start_date = row.searchStartDate()
} }
} .filter { row ->
.toList() row.select(TD)[2].text() != "Novel"
}
.map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = row.searchTitle()
media_id = row.searchMediaId()
total_chapters = row.searchTotalChapters()
summary = row.searchSummary()
cover_url = row.searchCoverUrl()
tracking_url = mangaUrl(media_id)
publishing_status = row.searchPublishingStatus()
publishing_type = row.searchPublishingType()
start_date = row.searchStartDate()
}
}
.toList()
}
} }
private fun getList(csrf: String): Observable<List<TrackSearch>> { fun addLibManga(track: Track): Observable<Track> {
return getListUrl(csrf) return Observable.defer {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun findLibManga(track: Track): Observable<Track?> {
return authClient.newCall(GET(url = listEntryUrl(track.media_id)))
.asObservable()
.map {response ->
var libTrack: Track? = null
response.use {
if (it.priorResponse()?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody())
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
total_chapters = trackForm.select("#totalChap").text().toInt()
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f
}
}
}
libTrack
}
}
fun getLibManga(track: Track): Observable<Track> {
return findLibManga(track)
.map { it ?: throw Exception("Could not find manga") }
}
fun login(username: String, password: String): String {
val csrf = getSessionInfo()
login(username, password, csrf)
return csrf
}
private fun getSessionInfo(): String {
val response = client.newCall(GET(loginUrl())).execute()
return Jsoup.parse(response.consumeBody())
.select("meta[name=csrf_token]")
.attr("content")
}
private fun login(username: String, password: String, csrf: String) {
val response = client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf))).execute()
response.use {
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
}
}
private fun getList(): Observable<List<TrackSearch>> {
return getListUrl()
.flatMap { url -> .flatMap { url ->
getListXml(url) getListXml(url)
} }
.flatMap { doc -> .flatMap { doc ->
Observable.from(doc.select("manga")) Observable.from(doc.select("manga"))
} }
.map { it -> .map {
TrackSearch.create(TrackManager.MYANIMELIST).apply { TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!! title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id") media_id = it.selectInt("manga_mangadb_id")
@ -90,107 +151,8 @@ class MyanimelistApi(private val client: OkHttpClient) {
.toList() .toList()
} }
private fun getListXml(url: String): Observable<Document> { private fun getListUrl(): Observable<String> {
return client.newCall(GET(url)) return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
fun findLibManga(track: Track, csrf: String): Observable<Track?> {
return getList(csrf)
.map { list -> list.find { it.media_id == track.media_id } }
}
fun getLibManga(track: Track, csrf: String): Observable<Track> {
return findLibManga(track, csrf)
.map { it ?: throw Exception("Could not find manga") }
}
fun login(username: String, password: String): Observable<String> {
return getSessionInfo()
.flatMap { csrf ->
login(username, password, csrf)
}
}
private fun getSessionInfo(): Observable<String> {
return client.newCall(GET(getLoginUrl()))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeBody())
.select("meta[name=csrf_token]")
.attr("content")
}
}
private fun login(username: String, password: String, csrf: String): Observable<String> {
return client.newCall(POST(url = getLoginUrl(), body = getLoginPostBody(username, password, csrf)))
.asObservable()
.map { response ->
response.use {
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
}
csrf
}
}
private fun getLoginPostBody(username: String, password: String, csrf: String): RequestBody {
return FormBody.Builder()
.add("user_name", username)
.add("password", password)
.add("cookie", "1")
.add("sublogin", "Login")
.add("submit", "1")
.add(CSRF, csrf)
.build()
}
private fun getExportPostBody(csrf: String): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
.add(CSRF, csrf)
.build()
}
private fun getMangaPostPayload(track: Track, csrf: String): RequestBody {
val body = JSONObject()
.put("manga_id", track.media_id)
.put("status", track.status)
.put("score", track.score)
.put("num_read_chapters", track.last_chapter_read)
.put(CSRF, csrf)
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
}
private fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("login.php")
.toString()
private fun getSearchUrl(query: String): String {
val col = "c[]"
return Uri.parse(baseUrl).buildUpon()
.appendPath("manga.php")
.appendQueryParameter("q", query)
.appendQueryParameter(col, "a")
.appendQueryParameter(col, "b")
.appendQueryParameter(col, "c")
.appendQueryParameter(col, "d")
.appendQueryParameter(col, "e")
.appendQueryParameter(col, "g")
.toString()
}
private fun getExportListUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
private fun getListUrl(csrf: String): Observable<String> {
return client.newCall(POST(url = getExportListUrl(), body = getExportPostBody(csrf)))
.asObservable() .asObservable()
.map {response -> .map {response ->
baseUrl + Jsoup.parse(response.consumeBody()) baseUrl + Jsoup.parse(response.consumeBody())
@ -200,17 +162,17 @@ class MyanimelistApi(private val client: OkHttpClient) {
} }
} }
private fun getUpdateUrl() = Uri.parse(baseModifyListUrl).buildUpon() private fun getListXml(url: String): Observable<Document> {
.appendPath("edit.json") return authClient.newCall(GET(url))
.toString() .asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json")
.toString()
private fun Response.consumeBody(): String? { private fun Response.consumeBody(): String? {
use { use {
if (it.code() != 200) throw Exception("Login error") if (it.code() != 200) throw Exception("HTTP error ${it.code()}")
return it.body()?.string() return it.body()?.string()
} }
} }
@ -229,37 +191,105 @@ class MyanimelistApi(private val client: OkHttpClient) {
} }
companion object { companion object {
const val baseUrl = "https://myanimelist.net" const val CSRF = "csrf_token"
private const val baseUrl = "https://myanimelist.net"
private const val baseMangaUrl = "$baseUrl/manga/" private const val baseMangaUrl = "$baseUrl/manga/"
private const val baseModifyListUrl = "$baseUrl/ownlist/manga" private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
private const val PREFIX_MY = "my:"
private const val TD = "td"
fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
fun Element.searchTitle() = select("strong").text()!! private fun loginUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("login.php")
.toString()
fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt() private fun searchUrl(query: String): String {
val col = "c[]"
return Uri.parse(baseUrl).buildUpon()
.appendPath("manga.php")
.appendQueryParameter("q", query)
.appendQueryParameter(col, "a")
.appendQueryParameter(col, "b")
.appendQueryParameter(col, "c")
.appendQueryParameter(col, "d")
.appendQueryParameter(col, "e")
.appendQueryParameter(col, "g")
.toString()
}
fun Element.searchCoverUrl() = select("img") private fun exportListUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath("edit.json")
.toString()
private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json")
.toString()
private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath(mediaId.toString())
.appendPath("edit")
.toString()
private fun loginPostBody(username: String, password: String, csrf: String): RequestBody {
return FormBody.Builder()
.add("user_name", username)
.add("password", password)
.add("cookie", "1")
.add("sublogin", "Login")
.add("submit", "1")
.add(CSRF, csrf)
.build()
}
private fun exportPostBody(): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
.build()
}
private fun mangaPostPayload(track: Track): RequestBody {
val body = JSONObject()
.put("manga_id", track.media_id)
.put("status", track.status)
.put("score", track.score)
.put("num_read_chapters", track.last_chapter_read)
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
}
private fun Element.searchTitle() = select("strong").text()!!
private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
private fun Element.searchCoverUrl() = select("img")
.attr("data-src") .attr("data-src")
.split("\\?")[0] .split("\\?")[0]
.replace("/r/50x70/", "/") .replace("/r/50x70/", "/")
fun Element.searchMediaId() = select("div.picSurround") private fun Element.searchMediaId() = select("div.picSurround")
.select("a").attr("id") .select("a").attr("id")
.replace("sarea", "") .replace("sarea", "")
.toInt() .toInt()
fun Element.searchSummary() = select("div.pt4") private fun Element.searchSummary() = select("div.pt4")
.first() .first()
.ownText()!! .ownText()!!
fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") PUBLISHING else FINISHED private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished"
fun Element.searchPublishingType() = select(TD)[2].text()!! private fun Element.searchPublishingType() = select(TD)[2].text()!!
fun Element.searchStartDate() = select(TD)[6].text()!! private fun Element.searchStartDate() = select(TD)[6].text()!!
fun getStatus(status: String) = when (status) { private fun getStatus(status: String) = when (status) {
"Reading" -> 1 "Reading" -> 1
"Completed" -> 2 "Completed" -> 2
"On-Hold" -> 3 "On-Hold" -> 3
@ -267,10 +297,5 @@ class MyanimelistApi(private val client: OkHttpClient) {
"Plan to Read" -> 6 "Plan to Read" -> 6
else -> 1 else -> 1
} }
const val CSRF = "csrf_token"
const val TD = "td"
private const val FINISHED = "Finished"
private const val PUBLISHING = "Publishing"
} }
} }

View File

@ -1,13 +1,13 @@
package eu.kanade.tachiyomi.data.track.shikomori package eu.kanade.tachiyomi.data.track.shikimori
data class OAuth( data class OAuth(
val access_token: String, val access_token: String,
val token_type: String, val token_type: String,
val created_at: Long, val created_at: Long,
val expires_in: Long, val expires_in: Long,
val refresh_token: String?) { val refresh_token: String?) {
// Access token lives 1 day // Access token lives 1 day
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
} }

View File

@ -1,138 +1,139 @@
package eu.kanade.tachiyomi.data.track.shikomori package eu.kanade.tachiyomi.data.track.shikimori
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import com.google.gson.Gson import android.util.Log
import eu.kanade.tachiyomi.R import com.google.gson.Gson
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Observable import rx.Completable
import uy.kohesive.injekt.injectLazy import rx.Observable
import uy.kohesive.injekt.injectLazy
class Shikomori(private val context: Context, id: Int) : TrackService(id) {
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString) 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 displayScore(track: Track): String {
} return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUsername()) override fun add(track: Track): Observable<Track> {
} return api.addLibManga(track, getUsername())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { override fun update(track: Track): Observable<Track> {
track.status = COMPLETED if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
} track.status = COMPLETED
return api.updateLibManga(track, getUsername()) }
} return api.updateLibManga(track, getUsername())
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername()) override fun bind(track: Track): Observable<Track> {
.flatMap { remoteTrack -> return api.findLibManga(track, getUsername())
if (remoteTrack != null) { .flatMap { remoteTrack ->
track.copyPersonalFrom(remoteTrack) if (remoteTrack != null) {
track.library_id = remoteTrack.library_id track.copyPersonalFrom(remoteTrack)
update(track) track.library_id = remoteTrack.library_id
} else { update(track)
// Set default fields if it's not found in the list } else {
track.score = DEFAULT_SCORE.toFloat() // Set default fields if it's not found in the list
track.status = DEFAULT_STATUS track.score = DEFAULT_SCORE.toFloat()
add(track) track.status = DEFAULT_STATUS
} add(track)
} }
} }
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query) override fun search(query: String): Observable<List<TrackSearch>> {
} return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername()) override fun refresh(track: Track): Observable<Track> {
.map { remoteTrack -> return api.findLibManga(track, getUsername())
if (remoteTrack != null) { .map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) if (remoteTrack != null) {
track.total_chapters = remoteTrack.total_chapters track.copyPersonalFrom(remoteTrack)
} track.total_chapters = remoteTrack.total_chapters
track }
} track
} }
}
companion object {
const val READING = 1 companion object {
const val COMPLETED = 2 const val READING = 1
const val ON_HOLD = 3 const val COMPLETED = 2
const val DROPPED = 4 const val ON_HOLD = 3
const val PLANNING = 5 const val DROPPED = 4
const val REPEATING = 6 const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0 const val DEFAULT_STATUS = READING
} const val DEFAULT_SCORE = 0
}
override val name = "Shikomori"
override val name = "Shikimori"
private val gson: Gson by injectLazy()
private val gson: Gson by injectLazy()
private val interceptor by lazy { ShikomoriInterceptor(this, gson) }
private val interceptor by lazy { ShikimoriInterceptor(this, gson) }
private val api by lazy { ShikomoriApi(client, interceptor) }
private val api by lazy { ShikimoriApi(client, interceptor) }
override fun getLogo() = R.drawable.shikomori
override fun getLogo() = R.drawable.shikimori
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) override fun getStatusList(): List<Int> {
} return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
}
override fun getStatus(status: Int): String = with(context) {
when (status) { override fun getStatus(status: Int): String = with(context) {
READING -> getString(R.string.reading) when (status) {
COMPLETED -> getString(R.string.completed) READING -> getString(R.string.reading)
ON_HOLD -> getString(R.string.on_hold) COMPLETED -> getString(R.string.completed)
DROPPED -> getString(R.string.dropped) ON_HOLD -> getString(R.string.on_hold)
PLANNING -> getString(R.string.plan_to_read) DROPPED -> getString(R.string.dropped)
REPEATING -> getString(R.string.repeating) PLANNING -> getString(R.string.plan_to_read)
else -> "" REPEATING -> getString(R.string.repeating)
} else -> ""
} }
}
override fun login(username: String, password: String) = login(password)
override fun login(username: String, password: String) = login(password)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? -> fun login(code: String): Completable {
interceptor.newAuth(oauth) return api.accessToken(code).map { oauth: OAuth? ->
if (oauth != null) { interceptor.newAuth(oauth)
val user = api.getCurrentUser() if (oauth != null) {
saveCredentials(user.toString(), oauth.access_token) val user = api.getCurrentUser()
} saveCredentials(user.toString(), oauth.access_token)
}.doOnError { }
logout() }.doOnError {
}.toCompletable() logout()
} }.toCompletable()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth) fun saveToken(oauth: OAuth?) {
preferences.trackToken(this).set(json) val json = gson.toJson(oauth)
} preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try { fun restoreToken(): OAuth? {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) return try {
} catch (e: Exception) { gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
null } catch (e: Exception) {
} null
} }
}
override fun logout() {
super.logout() override fun logout() {
preferences.trackToken(this).set(null) super.logout()
interceptor.newAuth(null) preferences.trackToken(this).set(null)
} interceptor.newAuth(null)
} }
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.track.shikomori package eu.kanade.tachiyomi.data.track.shikimori
import android.net.Uri import android.net.Uri
import com.github.salomonbrys.kotson.array import com.github.salomonbrys.kotson.array
@ -18,7 +18,7 @@ import okhttp3.*
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInterceptor) { class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
private val parser = JsonParser() private val parser = JsonParser()
@ -33,7 +33,7 @@ class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInter
"target_type" to "Manga", "target_type" to "Manga",
"chapters" to track.last_chapter_read, "chapters" to track.last_chapter_read,
"score" to track.score.toInt(), "score" to track.score.toInt(),
"status" to track.toShikomoriStatus() "status" to track.toShikimoriStatus()
) )
) )
val body = RequestBody.create(jsonime, payload.toString()) val body = RequestBody.create(jsonime, payload.toString())
@ -74,7 +74,7 @@ class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInter
} }
private fun jsonToSearch(obj: JsonObject): TrackSearch { private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.SHIKOMORI).apply { return TrackSearch.create(TrackManager.SHIKIMORI).apply {
media_id = obj["id"].asInt media_id = obj["id"].asInt
title = obj["name"].asString title = obj["name"].asString
total_chapters = obj["chapters"].asInt total_chapters = obj["chapters"].asInt
@ -87,14 +87,15 @@ class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInter
} }
} }
private fun jsonToTrack(obj: JsonObject): Track { private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
return Track.create(TrackManager.SHIKOMORI).apply { return Track.create(TrackManager.SHIKIMORI).apply {
title = mangas["name"].asString
media_id = obj["id"].asInt media_id = obj["id"].asInt
title = "" total_chapters = mangas["chapters"].asInt
last_chapter_read = obj["chapters"].asInt last_chapter_read = obj["chapters"].asInt
total_chapters = obj["chapters"].asInt
score = (obj["score"].asInt).toFloat() score = (obj["score"].asInt).toFloat()
status = toTrackStatus(obj["status"].asString) status = toTrackStatus(obj["status"].asString)
tracking_url = baseUrl + mangas["url"].asString
} }
} }
@ -108,21 +109,36 @@ class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInter
.url(url.toString()) .url(url.toString())
.get() .get()
.build() .build()
return authClient.newCall(request)
val urlMangas = Uri.parse("$apiUrl/mangas").buildUpon()
.appendPath(track.media_id.toString())
.build()
val requestMangas = Request.Builder()
.url(urlMangas.toString())
.get()
.build()
return authClient.newCall(requestMangas)
.asObservableSuccess() .asObservableSuccess()
.map { netResponse -> .map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty() val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) { parser.parse(responseBody).obj
throw Exception("Null Response") }.flatMap { mangas ->
} authClient.newCall(request)
val response = parser.parse(responseBody).array .asObservableSuccess()
if (response.size() > 1) { .map { netResponse ->
throw Exception("Too much mangas in response") val responseBody = netResponse.body()?.string().orEmpty()
} if (responseBody.isEmpty()) {
val entry = response.map { throw Exception("Null Response")
jsonToTrack(it.obj) }
} val response = parser.parse(responseBody).array
entry.firstOrNull() if (response.size() > 1) {
throw Exception("Too much mangas in response")
}
val entry = response.map {
jsonToTrack(it.obj, mangas)
}
entry.firstOrNull()
}
} }
} }
@ -156,10 +172,10 @@ class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInter
private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc" private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0" private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val baseUrl = "https://shikimori.org" private const val baseUrl = "https://shikimori.one"
private const val apiUrl = "https://shikimori.org/api" private const val apiUrl = "https://shikimori.one/api"
private const val oauthUrl = "https://shikimori.org/oauth/token" private const val oauthUrl = "https://shikimori.one/oauth/token"
private const val loginUrl = "https://shikimori.org/oauth/authorize" private const val loginUrl = "https://shikimori.one/oauth/authorize"
private const val redirectUrl = "tachiyomi://shikimori-auth" private const val redirectUrl = "tachiyomi://shikimori-auth"
private const val baseMangaUrl = "$apiUrl/mangas" private const val baseMangaUrl = "$apiUrl/mangas"

View File

@ -1,26 +1,26 @@
package eu.kanade.tachiyomi.data.track.shikomori package eu.kanade.tachiyomi.data.track.shikimori
import com.google.gson.Gson import com.google.gson.Gson
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
class ShikomoriInterceptor(val shikomori: Shikomori, val gson: Gson) : Interceptor { class ShikimoriInterceptor(val shikimori: Shikimori, val gson: Gson) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * OAuth object used for authenticated requests.
*/ */
private var oauth: OAuth? = shikomori.restoreToken() private var oauth: OAuth? = shikimori.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Shikomori") val currAuth = oauth ?: throw Exception("Not authenticated with Shikimori")
val refreshToken = currAuth.refresh_token!! val refreshToken = currAuth.refresh_token!!
// Refresh access token if expired. // Refresh access token if expired.
if (currAuth.isExpired()) { if (currAuth.isExpired()) {
val response = chain.proceed(ShikomoriApi.refreshTokenRequest(refreshToken)) val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) { if (response.isSuccessful) {
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java)) newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
} else { } else {
@ -38,6 +38,6 @@ class ShikomoriInterceptor(val shikomori: Shikomori, val gson: Gson) : Intercept
fun newAuth(oauth: OAuth?) { fun newAuth(oauth: OAuth?) {
this.oauth = oauth this.oauth = oauth
shikomori.saveToken(oauth) shikimori.saveToken(oauth)
} }
} }

View File

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.data.track.shikimori
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toShikimoriStatus() = when (status) {
Shikimori.READING -> "watching"
Shikimori.COMPLETED -> "completed"
Shikimori.ON_HOLD -> "on_hold"
Shikimori.DROPPED -> "dropped"
Shikimori.PLANNING -> "planned"
Shikimori.REPEATING -> "rewatching"
else -> throw NotImplementedError("Unknown status")
}
fun toTrackStatus(status: String) = when (status) {
"watching" -> Shikimori.READING
"completed" -> Shikimori.COMPLETED
"on_hold" -> Shikimori.ON_HOLD
"dropped" -> Shikimori.DROPPED
"planned" -> Shikimori.PLANNING
"rewatching" -> Shikimori.REPEATING
else -> throw Exception("Unknown status")
}

View File

@ -1,24 +0,0 @@
package eu.kanade.tachiyomi.data.track.shikomori
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toShikomoriStatus() = when (status) {
Shikomori.READING -> "watching"
Shikomori.COMPLETED -> "completed"
Shikomori.ON_HOLD -> "on_hold"
Shikomori.DROPPED -> "dropped"
Shikomori.PLANNING -> "planned"
Shikomori.REPEATING -> "rewatching"
else -> throw NotImplementedError("Unknown status")
}
fun toTrackStatus(status: String) = when (status) {
"watching" -> Shikomori.READING
"completed" -> Shikomori.COMPLETED
"on_hold" -> Shikomori.ON_HOLD
"dropped" -> Shikomori.DROPPED
"planned" -> Shikomori.PLANNING
"rewatching" -> Shikomori.REPEATING
else -> throw Exception("Unknown status")
}

View File

@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.data.updater
sealed class GithubUpdateResult {
class NewUpdate(val release: GithubRelease): GithubUpdateResult()
class NoNewUpdate : GithubUpdateResult()
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.updater
interface Release {
val info: String
/**
* Get download link of latest release.
* @return download link of latest release.
*/
val downloadLink: String
}

View File

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.data.updater
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.devrepo.DevRepoUpdateChecker
import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker
import rx.Observable
abstract class UpdateChecker {
companion object {
fun getUpdateChecker(): UpdateChecker {
return if (BuildConfig.DEBUG) {
DevRepoUpdateChecker()
} else {
GithubUpdateChecker()
}
}
}
/**
* Returns observable containing release information
*/
abstract fun checkForUpdate(): Observable<UpdateResult>
}

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.data.updater
abstract class UpdateResult {
open class NewUpdate<T : Release>(val release: T): UpdateResult()
open class NoNewUpdate: UpdateResult()
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.updater
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Build
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
import com.evernote.android.job.Job import com.evernote.android.job.Job
import com.evernote.android.job.JobManager import com.evernote.android.job.JobManager
@ -13,10 +14,15 @@ import eu.kanade.tachiyomi.util.notificationManager
class UpdaterJob : Job() { class UpdaterJob : Job() {
override fun onRunJob(params: Params): Result { override fun onRunJob(params: Params): Result {
return GithubUpdateChecker() // Android 4.x is no longer supported
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return Result.SUCCESS
}
return UpdateChecker.getUpdateChecker()
.checkForUpdate() .checkForUpdate()
.map { result -> .map { result ->
if (result is GithubUpdateResult.NewUpdate) { if (result is UpdateResult.NewUpdate<*>) {
val url = result.release.downloadLink val url = result.release.downloadLink
val intent = Intent(context, UpdaterService::class.java).apply { val intent = Intent(context, UpdaterService::class.java).apply {
@ -33,9 +39,9 @@ class UpdaterJob : Job() {
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
} }
} }
Job.Result.SUCCESS Result.SUCCESS
} }
.onErrorReturn { Job.Result.FAILURE } .onErrorReturn { Result.FAILURE }
// Sadly, the task needs to be synchronous. // Sadly, the task needs to be synchronous.
.toBlocking() .toBlocking()
.single() .single()
@ -64,4 +70,4 @@ class UpdaterJob : Job() {
} }
} }
} }

View File

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.updater.devrepo
import eu.kanade.tachiyomi.data.updater.Release
class DevRepoRelease(override val info: String) : Release {
override val downloadLink: String
get() = LATEST_URL
companion object {
const val LATEST_URL = "https://tachiyomi.kanade.eu/latest"
}
}

View File

@ -0,0 +1,40 @@
package eu.kanade.tachiyomi.data.updater.devrepo
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.UpdateChecker
import eu.kanade.tachiyomi.data.updater.UpdateResult
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservable
import okhttp3.OkHttpClient
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class DevRepoUpdateChecker : UpdateChecker() {
private val client: OkHttpClient by lazy {
Injekt.get<NetworkHelper>().client.newBuilder()
.followRedirects(false)
.build()
}
private val versionRegex: Regex by lazy {
Regex("tachiyomi-r(\\d+).apk")
}
override fun checkForUpdate(): Observable<UpdateResult> {
return client.newCall(GET(DevRepoRelease.LATEST_URL)).asObservable()
.map { response ->
// Get latest repo version number from header in format "Location: tachiyomi-r1512.apk"
val latestVersionNumber: String = versionRegex.find(response.header("Location")!!)!!.groupValues[1]
if (latestVersionNumber.toInt() > BuildConfig.COMMIT_COUNT.toInt()) {
DevRepoUpdateResult.NewUpdate(DevRepoRelease("v$latestVersionNumber"))
} else {
DevRepoUpdateResult.NoNewUpdate()
}
}
}
}

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.updater.devrepo
import eu.kanade.tachiyomi.data.updater.UpdateResult
sealed class DevRepoUpdateResult : UpdateResult() {
class NewUpdate(release: DevRepoRelease): UpdateResult.NewUpdate<DevRepoRelease>(release)
class NoNewUpdate: UpdateResult.NoNewUpdate()
}

View File

@ -1,24 +1,25 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater.github
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import eu.kanade.tachiyomi.data.updater.Release
/** /**
* Release object. * Release object.
* Contains information about the latest release from Github. * Contains information about the latest release from Github.
* *
* @param version version of latest release. * @param version version of latest release.
* @param changeLog log of latest release. * @param info log of latest release.
* @param assets assets of latest release. * @param assets assets of latest release.
*/ */
class GithubRelease(@SerializedName("tag_name") val version: String, class GithubRelease(@SerializedName("tag_name") val version: String,
@SerializedName("body") val changeLog: String, @SerializedName("body") override val info: String,
@SerializedName("assets") private val assets: List<Assets>) { @SerializedName("assets") private val assets: List<Assets>): Release {
/** /**
* Get download link of latest release from the assets. * Get download link of latest release from the assets.
* @return download link of latest release. * @return download link of latest release.
*/ */
val downloadLink: String override val downloadLink: String
get() = assets[0].downloadLink get() = assets[0].downloadLink
/** /**

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater.github
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import retrofit2.Retrofit import retrofit2.Retrofit
@ -30,4 +30,4 @@ interface GithubService {
@GET("/repos/inorichi/tachiyomi/releases/latest") @GET("/repos/inorichi/tachiyomi/releases/latest")
fun getLatestVersion(): Observable<GithubRelease> fun getLatestVersion(): Observable<GithubRelease>
} }

View File

@ -1,16 +1,15 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater.github
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.UpdateChecker
import eu.kanade.tachiyomi.data.updater.UpdateResult
import rx.Observable import rx.Observable
class GithubUpdateChecker { class GithubUpdateChecker : UpdateChecker() {
private val service: GithubService = GithubService.create() private val service: GithubService = GithubService.create()
/** override fun checkForUpdate(): Observable<UpdateResult> {
* Returns observable containing release information
*/
fun checkForUpdate(): Observable<GithubUpdateResult> {
return service.getLatestVersion().map { release -> return service.getLatestVersion().map { release ->
val newVersion = release.version.replace("[^\\d.]".toRegex(), "") val newVersion = release.version.replace("[^\\d.]".toRegex(), "")
@ -22,4 +21,5 @@ class GithubUpdateChecker {
} }
} }
} }
}
}

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.updater.github
import eu.kanade.tachiyomi.data.updater.UpdateResult
sealed class GithubUpdateResult : UpdateResult() {
class NewUpdate(release: GithubRelease): UpdateResult.NewUpdate<GithubRelease>(release)
class NoNewUpdate : UpdateResult.NoNewUpdate()
}

View File

@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.launchNow import eu.kanade.tachiyomi.util.launchNow
import kotlinx.coroutines.experimental.async import kotlinx.coroutines.async
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers

View File

@ -7,6 +7,7 @@ import com.github.salomonbrys.kotson.string
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonArray import com.google.gson.JsonArray
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
@ -36,17 +37,23 @@ internal class ExtensionGithubApi {
val json = gson.fromJson<JsonArray>(text) val json = gson.fromJson<JsonArray>(text)
return json.map { element -> return json
val name = element["name"].string.substringAfter("Tachiyomi: ") .filter { element ->
val pkgName = element["pkg"].string val versionName = element["version"].string
val apkName = element["apk"].string val libVersion = versionName.substringBeforeLast('.').toDouble()
val versionName = element["version"].string libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
val versionCode = element["code"].int }
val lang = element["lang"].string .map { element ->
val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}" val name = element["name"].string.substringAfter("Tachiyomi: ")
val pkgName = element["pkg"].string
val apkName = element["apk"].string
val versionName = element["version"].string
val versionCode = element["code"].int
val lang = element["lang"].string
val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}"
Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon) Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon)
} }
} }
fun getApkUrl(extension: Extension.Available): String { fun getApkUrl(extension: Extension.Available): String {

View File

@ -39,7 +39,7 @@ class ExtensionInstallActivity : Activity() {
} }
private fun checkInstallationResult(resultCode: Int) { private fun checkInstallationResult(resultCode: Int) {
val downloadId = intent.extras.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID) val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
val success = resultCode == RESULT_OK val success = resultCode == RESULT_OK
val extensionManager = Injekt.get<ExtensionManager>() val extensionManager = Injekt.get<ExtensionManager>()

View File

@ -7,7 +7,10 @@ import android.content.IntentFilter
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.util.launchNow import eu.kanade.tachiyomi.util.launchNow
import kotlinx.coroutines.experimental.async import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
/** /**
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only * Broadcast receiver that listens for the system's packages installed, updated or removed, and only
@ -91,7 +94,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult { private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
val pkgName = getPackageNameFromIntent(intent) ?: val pkgName = getPackageNameFromIntent(intent) ?:
return LoadResult.Error("Package name not found") return LoadResult.Error("Package name not found")
return async { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await() return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT, { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }).await()
} }
/** /**

View File

@ -13,8 +13,8 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.Hash import eu.kanade.tachiyomi.util.Hash
import kotlinx.coroutines.experimental.async import kotlinx.coroutines.async
import kotlinx.coroutines.experimental.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -27,8 +27,8 @@ internal object ExtensionLoader {
private const val EXTENSION_FEATURE = "tachiyomi.extension" private const val EXTENSION_FEATURE = "tachiyomi.extension"
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
private const val LIB_VERSION_MIN = 1 const val LIB_VERSION_MIN = 1.0
private const val LIB_VERSION_MAX = 1 const val LIB_VERSION_MAX = 1.2
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
@ -100,10 +100,16 @@ internal object ExtensionLoader {
val versionName = pkgInfo.versionName val versionName = pkgInfo.versionName
val versionCode = pkgInfo.versionCode val versionCode = pkgInfo.versionCode
if (versionName.isNullOrEmpty()) {
val exception = Exception("Missing versionName for extension $extName")
Timber.w(exception)
return LoadResult.Error(exception)
}
// Validate lib version // Validate lib version
val majorLibVersion = versionName.substringBefore('.').toInt() val libVersion = versionName.substringBeforeLast('.').toDouble()
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) { if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
val exception = Exception("Lib version is $majorLibVersion, while only versions " + val exception = Exception("Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed") "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
Timber.w(exception) Timber.w(exception)
return LoadResult.Error(exception) return LoadResult.Error(exception)
@ -121,7 +127,7 @@ internal object ExtensionLoader {
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader) val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS) val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
.split(";") .split(";")
.map { .map {
val sourceClass = it.trim() val sourceClass = it.trim()

View File

@ -46,12 +46,22 @@ class AndroidCookieJar(context: Context) : CookieJar {
} }
} }
fun remove(url: HttpUrl) { fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1) {
val cookies = manager.getCookie(url.toString()) ?: return val urlString = url.toString()
val domain = ".${url.host()}" val cookies = manager.getCookie(urlString) ?: return
fun List<String>.filterNames(): List<String> {
return if (cookieNames != null) {
this.filter { it in cookieNames }
} else {
this
}
}
cookies.split(";") cookies.split(";")
.map { it.substringBefore("=") } .map { it.substringBefore("=") }
.onEach { manager.setCookie(domain, "$it=;Max-Age=-1") } .filterNames()
.onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
syncManager.sync() syncManager.sync()
@ -66,5 +76,4 @@ class AndroidCookieJar(context: Context) : CookieJar {
syncManager.sync() syncManager.sync()
} }
} }
} }

View File

@ -1,154 +1,140 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.webkit.WebResourceResponse import android.webkit.WebSettings
import android.webkit.WebSettings import android.webkit.WebView
import android.webkit.WebView import eu.kanade.tachiyomi.util.WebViewClientCompat
import eu.kanade.tachiyomi.util.WebViewClientCompat import okhttp3.*
import okhttp3.Interceptor import uy.kohesive.injekt.injectLazy
import okhttp3.Request import java.io.IOException
import okhttp3.Response import java.util.concurrent.CountDownLatch
import java.io.IOException import java.util.concurrent.TimeUnit
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit class CloudflareInterceptor(private val context: Context) : Interceptor {
class CloudflareInterceptor(private val context: Context) : Interceptor { private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare") private val handler = Handler(Looper.getMainLooper())
private val handler = Handler(Looper.getMainLooper()) private val networkHelper: NetworkHelper by injectLazy()
/** /**
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid * When this is called, it initializes the WebView if it wasn't already. We use this to avoid
* blocking the main thread too much. If used too often we could consider moving it to the * blocking the main thread too much. If used too often we could consider moving it to the
* Application class. * Application class.
*/ */
private val initWebView by lazy { private val initWebView by lazy {
if (Build.VERSION.SDK_INT >= 17) { if (Build.VERSION.SDK_INT >= 17) {
WebSettings.getDefaultUserAgent(context) WebSettings.getDefaultUserAgent(context)
} else { } else {
null null
} }
} }
@Synchronized @Synchronized
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
initWebView initWebView
val response = chain.proceed(chain.request()) val originalRequest = chain.request()
val response = chain.proceed(originalRequest)
// Check if Cloudflare anti-bot is on
if (response.code() == 503 && response.header("Server") in serverCheck) { // Check if Cloudflare anti-bot is on
try { if (response.code() == 503 && response.header("Server") in serverCheck) {
response.close() try {
val solutionRequest = resolveWithWebView(chain.request()) response.close()
return chain.proceed(solutionRequest) networkHelper.cookieManager.remove(originalRequest.url(), listOf("__cfduid", "cf_clearance"), 0)
} catch (e: Exception) { val oldCookie = networkHelper.cookieManager.get(originalRequest.url())
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that .firstOrNull { it.name() == "cf_clearance" }
// we don't crash the entire app return if (resolveWithWebView(originalRequest, oldCookie)) {
throw IOException(e) chain.proceed(originalRequest)
} } else {
} throw IOException("Failed to bypass Cloudflare!")
}
return response } catch (e: Exception) {
} // Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
private fun isChallengeSolutionUrl(url: String): Boolean { throw IOException(e)
return "chk_jschl" in url }
} }
@SuppressLint("SetJavaScriptEnabled") return response
private fun resolveWithWebView(request: Request): Request { }
// We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors. @SuppressLint("SetJavaScriptEnabled")
val latch = CountDownLatch(1) private fun resolveWithWebView(request: Request, oldCookie: Cookie?): Boolean {
// We need to lock this thread until the WebView finds the challenge solution url, because
var webView: WebView? = null // OkHttp doesn't support asynchronous interceptors.
var solutionUrl: String? = null val latch = CountDownLatch(1)
var challengeFound = false
var webView: WebView? = null
val origRequestUrl = request.url().toString() var challengeFound = false
val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" } var cloudflareBypassed = false
handler.post { val origRequestUrl = request.url().toString()
val view = WebView(context) val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
webView = view
view.settings.javaScriptEnabled = true handler.post {
view.settings.userAgentString = request.header("User-Agent") val view = WebView(context)
view.webViewClient = object : WebViewClientCompat() { webView = view
view.settings.javaScriptEnabled = true
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean { view.settings.userAgentString = request.header("User-Agent")
if (isChallengeSolutionUrl(url)) { view.webViewClient = object : WebViewClientCompat() {
solutionUrl = url
latch.countDown() override fun onPageFinished(view: WebView, url: String) {
} fun isCloudFlareBypassed(): Boolean {
return solutionUrl != null return networkHelper.cookieManager.get(HttpUrl.parse(origRequestUrl)!!)
} .firstOrNull { it.name() == "cf_clearance" }
.let { it != null && it != oldCookie }
override fun shouldInterceptRequestCompat( }
view: WebView,
url: String if (isCloudFlareBypassed()) {
): WebResourceResponse? { cloudflareBypassed = true
if (solutionUrl != null) { latch.countDown()
// Intercept any request when we have the solution. }
return WebResourceResponse("text/plain", "UTF-8", null) // Http error codes are only received since M
} if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
return null url == origRequestUrl && !challengeFound
} ) {
// The first request didn't return the challenge, abort.
override fun onPageFinished(view: WebView, url: String) { latch.countDown()
// Http error codes are only received since M }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && }
url == origRequestUrl && !challengeFound
) { override fun onReceivedErrorCompat(
// The first request didn't return the challenge, abort. view: WebView,
latch.countDown() errorCode: Int,
} description: String?,
} failingUrl: String,
isMainFrame: Boolean
override fun onReceivedErrorCompat( ) {
view: WebView, if (isMainFrame) {
errorCode: Int, if (errorCode == 503) {
description: String?, // Found the cloudflare challenge page.
failingUrl: String, challengeFound = true
isMainFrame: Boolean } else {
) { // Unlock thread, the challenge wasn't found.
if (isMainFrame) { latch.countDown()
if (errorCode == 503) { }
// Found the cloudflare challenge page. }
challengeFound = true }
} else { }
// Unlock thread, the challenge wasn't found. webView?.loadUrl(origRequestUrl, headers)
latch.countDown() }
}
} // Wait a reasonable amount of time to retrieve the solution. The minimum should be
} // around 4 seconds but it can take more due to slow networks or server issues.
} latch.await(12, TimeUnit.SECONDS)
webView?.loadUrl(origRequestUrl, headers)
} handler.post {
webView?.stopLoading()
// Wait a reasonable amount of time to retrieve the solution. The minimum should be webView?.destroy()
// around 4 seconds but it can take more due to slow networks or server issues. }
latch.await(12, TimeUnit.SECONDS)
return cloudflareBypassed
handler.post { }
webView?.stopLoading()
webView?.destroy() }
}
val solution = solutionUrl ?: throw Exception("Challenge not found")
return Request.Builder().get()
.url(solution)
.headers(request.headers())
.addHeader("Referer", origRequestUrl)
.addHeader("Accept", "text/html,application/xhtml+xml,application/xml")
.addHeader("Accept-Language", "en")
.build()
}
}

View File

@ -1,117 +1,117 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import okhttp3.* import okhttp3.*
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress import java.net.InetAddress
import java.net.Socket import java.net.Socket
import java.net.UnknownHostException import java.net.UnknownHostException
import java.security.KeyManagementException import java.security.KeyManagementException
import java.security.KeyStore import java.security.KeyStore
import java.security.NoSuchAlgorithmException import java.security.NoSuchAlgorithmException
import javax.net.ssl.* import javax.net.ssl.*
class NetworkHelper(context: Context) { class NetworkHelper(context: Context) {
private val cacheDir = File(context.cacheDir, "network_cache") private val cacheDir = File(context.cacheDir, "network_cache")
private val cacheSize = 5L * 1024 * 1024 // 5 MiB private val cacheSize = 5L * 1024 * 1024 // 5 MiB
val cookieManager = AndroidCookieJar(context) val cookieManager = AndroidCookieJar(context)
val client = OkHttpClient.Builder() val client = OkHttpClient.Builder()
.cookieJar(cookieManager) .cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize)) .cache(Cache(cacheDir, cacheSize))
.enableTLS12() .enableTLS12()
.build() .build()
val cloudflareClient = client.newBuilder() val cloudflareClient = client.newBuilder()
.addInterceptor(CloudflareInterceptor(context)) .addInterceptor(CloudflareInterceptor(context))
.build() .build()
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder { private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
return this return this
} }
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(null as KeyStore?) trustManagerFactory.init(null as KeyStore?)
val trustManagers = trustManagerFactory.trustManagers val trustManagers = trustManagerFactory.trustManagers
if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) { if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class) class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
constructor() : SSLSocketFactory() { constructor() : SSLSocketFactory() {
private val internalSSLSocketFactory: SSLSocketFactory private val internalSSLSocketFactory: SSLSocketFactory
init { init {
val context = SSLContext.getInstance("TLS") val context = SSLContext.getInstance("TLS")
context.init(null, null, null) context.init(null, null, null)
internalSSLSocketFactory = context.socketFactory internalSSLSocketFactory = context.socketFactory
} }
override fun getDefaultCipherSuites(): Array<String> { override fun getDefaultCipherSuites(): Array<String> {
return internalSSLSocketFactory.defaultCipherSuites return internalSSLSocketFactory.defaultCipherSuites
} }
override fun getSupportedCipherSuites(): Array<String> { override fun getSupportedCipherSuites(): Array<String> {
return internalSSLSocketFactory.supportedCipherSuites return internalSSLSocketFactory.supportedCipherSuites
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun createSocket(): Socket? { override fun createSocket(): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket()) return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? { override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)) return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose))
} }
@Throws(IOException::class, UnknownHostException::class) @Throws(IOException::class, UnknownHostException::class)
override fun createSocket(host: String, port: Int): Socket? { override fun createSocket(host: String, port: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
} }
@Throws(IOException::class, UnknownHostException::class) @Throws(IOException::class, UnknownHostException::class)
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? { override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)) return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun createSocket(host: InetAddress, port: Int): Socket? { override fun createSocket(host: InetAddress, port: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)) return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? { override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)) return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort))
} }
private fun enableTLSOnSocket(socket: Socket?): Socket? { private fun enableTLSOnSocket(socket: Socket?): Socket? {
if (socket != null && socket is SSLSocket) { if (socket != null && socket is SSLSocket) {
socket.enabledProtocols = socket.supportedProtocols socket.enabledProtocols = socket.supportedProtocols
} }
return socket return socket
} }
} }
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager) sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
} }
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
.cipherSuites( .cipherSuites(
*ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(), *ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(),
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
) )
.build() .build()
val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT) val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
connectionSpecs(specs) connectionSpecs(specs)
return this return this
} }
} }

View File

@ -1,70 +1,70 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import okhttp3.Call import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import rx.Producer import rx.Producer
import rx.Subscription import rx.Subscription
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
fun Call.asObservable(): Observable<Response> { fun Call.asObservable(): Observable<Response> {
return Observable.unsafeCreate { subscriber -> return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber. // Since Call is a one-shot type, clone it for each new subscriber.
val call = clone() val call = clone()
// Wrap the call in a helper which handles both unsubscription and backpressure. // Wrap the call in a helper which handles both unsubscription and backpressure.
val requestArbiter = object : AtomicBoolean(), Producer, Subscription { val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
override fun request(n: Long) { override fun request(n: Long) {
if (n == 0L || !compareAndSet(false, true)) return if (n == 0L || !compareAndSet(false, true)) return
try { try {
val response = call.execute() val response = call.execute()
if (!subscriber.isUnsubscribed) { if (!subscriber.isUnsubscribed) {
subscriber.onNext(response) subscriber.onNext(response)
subscriber.onCompleted() subscriber.onCompleted()
} }
} catch (error: Exception) { } catch (error: Exception) {
if (!subscriber.isUnsubscribed) { if (!subscriber.isUnsubscribed) {
subscriber.onError(error) subscriber.onError(error)
} }
} }
} }
override fun unsubscribe() { override fun unsubscribe() {
call.cancel() call.cancel()
} }
override fun isUnsubscribed(): Boolean { override fun isUnsubscribed(): Boolean {
return call.isCanceled return call.isCanceled
} }
} }
subscriber.add(requestArbiter) subscriber.add(requestArbiter)
subscriber.setProducer(requestArbiter) subscriber.setProducer(requestArbiter)
} }
} }
fun Call.asObservableSuccess(): Observable<Response> { fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response -> return asObservable().doOnNext { response ->
if (!response.isSuccessful) { if (!response.isSuccessful) {
response.close() response.close()
throw Exception("HTTP error ${response.code()}") throw Exception("HTTP error ${response.code()}")
} }
} }
} }
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder() val progressClient = newBuilder()
.cache(null) .cache(null)
.addNetworkInterceptor { chain -> .addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request()) val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder() originalResponse.newBuilder()
.body(ProgressResponseBody(originalResponse.body()!!, listener)) .body(ProgressResponseBody(originalResponse.body()!!, listener))
.build() .build()
} }
.build() .build()
return progressClient.newCall(request) return progressClient.newCall(request)
} }

View File

@ -1,5 +1,5 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
interface ProgressListener { interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean) fun update(bytesRead: Long, contentLength: Long, done: Boolean)
} }

View File

@ -1,40 +1,40 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okio.* import okio.*
import java.io.IOException import java.io.IOException
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
private val bufferedSource: BufferedSource by lazy { private val bufferedSource: BufferedSource by lazy {
Okio.buffer(source(responseBody.source())) Okio.buffer(source(responseBody.source()))
} }
override fun contentType(): MediaType { override fun contentType(): MediaType {
return responseBody.contentType()!! return responseBody.contentType()!!
} }
override fun contentLength(): Long { override fun contentLength(): Long {
return responseBody.contentLength() return responseBody.contentLength()
} }
override fun source(): BufferedSource { override fun source(): BufferedSource {
return bufferedSource return bufferedSource
} }
private fun source(source: Source): Source { private fun source(source: Source): Source {
return object : ForwardingSource(source) { return object : ForwardingSource(source) {
internal var totalBytesRead = 0L internal var totalBytesRead = 0L
@Throws(IOException::class) @Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long { override fun read(sink: Buffer, byteCount: Long): Long {
val bytesRead = super.read(sink, byteCount) val bytesRead = super.read(sink, byteCount)
// read() returns the number of bytes read, or -1 if this source is exhausted. // read() returns the number of bytes read, or -1 if this source is exhausted.
totalBytesRead += if (bytesRead != -1L) bytesRead else 0 totalBytesRead += if (bytesRead != -1L) bytesRead else 0
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
return bytesRead return bytesRead
} }
} }
} }
} }

View File

@ -1,32 +1,32 @@
package eu.kanade.tachiyomi.network package eu.kanade.tachiyomi.network
import okhttp3.* import okhttp3.*
import java.util.concurrent.TimeUnit.MINUTES import java.util.concurrent.TimeUnit.MINUTES
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build() private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
private val DEFAULT_HEADERS = Headers.Builder().build() private val DEFAULT_HEADERS = Headers.Builder().build()
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
fun GET(url: String, fun GET(url: String,
headers: Headers = DEFAULT_HEADERS, headers: Headers = DEFAULT_HEADERS,
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
return Request.Builder() return Request.Builder()
.url(url) .url(url)
.headers(headers) .headers(headers)
.cacheControl(cache) .cacheControl(cache)
.build() .build()
} }
fun POST(url: String, fun POST(url: String,
headers: Headers = DEFAULT_HEADERS, headers: Headers = DEFAULT_HEADERS,
body: RequestBody = DEFAULT_BODY, body: RequestBody = DEFAULT_BODY,
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
return Request.Builder() return Request.Builder()
.url(url) .url(url)
.post(body) .post(body)
.headers(headers) .headers(headers)
.cacheControl(cache) .cacheControl(cache)
.build() .build()
} }

View File

@ -1,46 +1,46 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import rx.Observable import rx.Observable
interface CatalogueSource : Source { interface CatalogueSource : Source {
/** /**
* An ISO 639-1 compliant language code (two letters in lower case). * An ISO 639-1 compliant language code (two letters in lower case).
*/ */
val lang: String val lang: String
/** /**
* Whether the source has support for latest updates. * Whether the source has support for latest updates.
*/ */
val supportsLatest: Boolean val supportsLatest: Boolean
/** /**
* Returns an observable containing a page with a list of manga. * Returns an observable containing a page with a list of manga.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
fun fetchPopularManga(page: Int): Observable<MangasPage> fun fetchPopularManga(page: Int): Observable<MangasPage>
/** /**
* Returns an observable containing a page with a list of manga. * Returns an observable containing a page with a list of manga.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
/** /**
* Returns an observable containing a page with a list of latest manga updates. * Returns an observable containing a page with a list of latest manga updates.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
fun fetchLatestUpdates(page: Int): Observable<MangasPage> fun fetchLatestUpdates(page: Int): Observable<MangasPage>
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
*/ */
fun getFilterList(): FilterList fun getFilterList(): FilterList
} }

View File

@ -2,20 +2,24 @@ package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.ChapterRecognition import eu.kanade.tachiyomi.util.ChapterRecognition
import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator
import eu.kanade.tachiyomi.util.DiskUtil import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.EpubFile import eu.kanade.tachiyomi.util.EpubFile
import eu.kanade.tachiyomi.util.ImageUtil import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive import junrar.Archive
import junrar.rarfile.FileHeader import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable import rx.Observable
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.Comparator
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
@ -125,7 +129,6 @@ class LocalSource(private val context: Context) : CatalogueSource {
override fun fetchMangaDetails(manga: SManga) = Observable.just(manga) override fun fetchMangaDetails(manga: SManga) = Observable.just(manga)
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
val chapters = getBaseDirectories(context) val chapters = getBaseDirectories(context)
.mapNotNull { File(it, manga.url).listFiles()?.toList() } .mapNotNull { File(it, manga.url).listFiles()?.toList() }
.flatten() .flatten()
@ -146,7 +149,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
} }
.sortedWith(Comparator { c1, c2 -> .sortedWith(Comparator { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number) val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) comparator.compare(c2.name, c1.name) else c if (c == 0) CaseInsensitiveNaturalComparator.compare(c2.name, c1.name) else c
}) })
return Observable.just(chapters) return Observable.just(chapters)
@ -189,20 +192,19 @@ class LocalSource(private val context: Context) : CatalogueSource {
private fun updateCover(chapter: SChapter, manga: SManga): File? { private fun updateCover(chapter: SChapter, manga: SManga): File? {
val format = getFormat(chapter) val format = getFormat(chapter)
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return when (format) { return when (format) {
is Format.Directory -> { is Format.Directory -> {
val entry = format.file.listFiles() val entry = format.file.listFiles()
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) }) .sortedWith(Comparator<File> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) } .find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(context, manga, it.inputStream())} entry?.let { updateCover(context, manga, it.inputStream())}
} }
is Format.Zip -> { is Format.Zip -> {
ZipFile(format.file).use { zip -> ZipFile(format.file).use { zip ->
val entry = zip.entries().toList() val entry = zip.entries().toList()
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) }) .sortedWith(Comparator<ZipEntry> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) })
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) } .find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(context, manga, zip.getInputStream(it) )} entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
} }
@ -210,8 +212,8 @@ class LocalSource(private val context: Context) : CatalogueSource {
is Format.Rar -> { is Format.Rar -> {
Archive(format.file).use { archive -> Archive(format.file).use { archive ->
val entry = archive.fileHeaders val entry = archive.fileHeaders
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) }) .sortedWith(Comparator<FileHeader> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.fileNameString, f2.fileNameString) })
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) } .find { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
entry?.let { updateCover(context, manga, archive.getInputStream(it) )} entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
} }

View File

@ -1,44 +1,44 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable import rx.Observable
/** /**
* A basic interface for creating a source. It could be an online source, a local source, etc... * A basic interface for creating a source. It could be an online source, a local source, etc...
*/ */
interface Source { interface Source {
/** /**
* Id for the source. Must be unique. * Id for the source. Must be unique.
*/ */
val id: Long val id: Long
/** /**
* Name of the source. * Name of the source.
*/ */
val name: String val name: String
/** /**
* Returns an observable with the updated details for a manga. * Returns an observable with the updated details for a manga.
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun fetchMangaDetails(manga: SManga): Observable<SManga> fun fetchMangaDetails(manga: SManga): Observable<SManga>
/** /**
* Returns an observable with all the available chapters for a manga. * Returns an observable with all the available chapters for a manga.
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
/** /**
* Returns an observable with the list of pages a chapter has. * Returns an observable with the list of pages a chapter has.
* *
* @param chapter the chapter. * @param chapter the chapter.
*/ */
fun fetchPageList(chapter: SChapter): Observable<List<Page>> fun fetchPageList(chapter: SChapter): Observable<List<Page>>
} }

View File

@ -1,74 +1,74 @@
package eu.kanade.tachiyomi.source package eu.kanade.tachiyomi.source
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import rx.Observable import rx.Observable
open class SourceManager(private val context: Context) { open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>() private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>() private val stubSourcesMap = mutableMapOf<Long, StubSource>()
init { init {
createInternalSources().forEach { registerSource(it) } createInternalSources().forEach { registerSource(it) }
} }
open fun get(sourceKey: Long): Source? { open fun get(sourceKey: Long): Source? {
return sourcesMap[sourceKey] return sourcesMap[sourceKey]
} }
fun getOrStub(sourceKey: Long): Source { fun getOrStub(sourceKey: Long): Source {
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) { return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
StubSource(sourceKey) StubSource(sourceKey)
} }
} }
fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>() fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>()
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>() fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
internal fun registerSource(source: Source, overwrite: Boolean = false) { internal fun registerSource(source: Source, overwrite: Boolean = false) {
if (overwrite || !sourcesMap.containsKey(source.id)) { if (overwrite || !sourcesMap.containsKey(source.id)) {
sourcesMap[source.id] = source sourcesMap[source.id] = source
} }
} }
internal fun unregisterSource(source: Source) { internal fun unregisterSource(source: Source) {
sourcesMap.remove(source.id) sourcesMap.remove(source.id)
} }
private fun createInternalSources(): List<Source> = listOf( private fun createInternalSources(): List<Source> = listOf(
LocalSource(context) LocalSource(context)
) )
private inner class StubSource(override val id: Long) : Source { private inner class StubSource(override val id: Long) : Source {
override val name: String override val name: String
get() = id.toString() get() = id.toString()
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return Observable.error(getSourceNotInstalledException()) return Observable.error(getSourceNotInstalledException())
} }
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.error(getSourceNotInstalledException()) return Observable.error(getSourceNotInstalledException())
} }
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return Observable.error(getSourceNotInstalledException()) return Observable.error(getSourceNotInstalledException())
} }
override fun toString(): String { override fun toString(): String {
return name return name
} }
private fun getSourceNotInstalledException(): Exception { private fun getSourceNotInstalledException(): Exception {
return Exception(context.getString(R.string.source_not_installed, id.toString())) return Exception(context.getString(R.string.source_not_installed, id.toString()))
} }
} }
} }

View File

@ -1,40 +1,40 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
sealed class Filter<T>(val name: String, var state: T) { sealed class Filter<T>(val name: String, var state: T) {
open class Header(name: String) : Filter<Any>(name, 0) open class Header(name: String) : Filter<Any>(name, 0)
open class Separator(name: String = "") : Filter<Any>(name, 0) open class Separator(name: String = "") : Filter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state) abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
abstract class Text(name: String, state: String = "") : Filter<String>(name, state) abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state) abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) { abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
fun isIgnored() = state == STATE_IGNORE fun isIgnored() = state == STATE_IGNORE
fun isIncluded() = state == STATE_INCLUDE fun isIncluded() = state == STATE_INCLUDE
fun isExcluded() = state == STATE_EXCLUDE fun isExcluded() = state == STATE_EXCLUDE
companion object { companion object {
const val STATE_IGNORE = 0 const val STATE_IGNORE = 0
const val STATE_INCLUDE = 1 const val STATE_INCLUDE = 1
const val STATE_EXCLUDE = 2 const val STATE_EXCLUDE = 2
} }
} }
abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state) abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state)
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) abstract class Sort(name: String, val values: Array<String>, state: Selection? = null)
: Filter<Sort.Selection?>(name, state) { : Filter<Sort.Selection?>(name, state) {
data class Selection(val index: Int, val ascending: Boolean) data class Selection(val index: Int, val ascending: Boolean)
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other !is Filter<*>) return false if (other !is Filter<*>) return false
return name == other.name && state == other.state return name == other.name && state == other.state
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = name.hashCode() var result = name.hashCode()
result = 31 * result + (state?.hashCode() ?: 0) result = 31 * result + (state?.hashCode() ?: 0)
return result return result
} }
} }

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list { data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList()) constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
} }

View File

@ -1,3 +1,3 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean) data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)

View File

@ -1,48 +1,48 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener import eu.kanade.tachiyomi.network.ProgressListener
import rx.subjects.Subject import rx.subjects.Subject
open class Page( open class Page(
val index: Int, val index: Int,
val url: String = "", val url: String = "",
var imageUrl: String? = null, var imageUrl: String? = null,
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions @Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener { ) : ProgressListener {
val number: Int val number: Int
get() = index + 1 get() = index + 1
@Transient @Volatile var status: Int = 0 @Transient @Volatile var status: Int = 0
set(value) { set(value) {
field = value field = value
statusSubject?.onNext(value) statusSubject?.onNext(value)
} }
@Transient @Volatile var progress: Int = 0 @Transient @Volatile var progress: Int = 0
@Transient private var statusSubject: Subject<Int, Int>? = null @Transient private var statusSubject: Subject<Int, Int>? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) { progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt() (100 * bytesRead / contentLength).toInt()
} else { } else {
-1 -1
} }
} }
fun setStatusSubject(subject: Subject<Int, Int>?) { fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject this.statusSubject = subject
} }
companion object { companion object {
const val QUEUE = 0 const val QUEUE = 0
const val LOAD_PAGE = 1 const val LOAD_PAGE = 1
const val DOWNLOAD_IMAGE = 2 const val DOWNLOAD_IMAGE = 2
const val READY = 3 const val READY = 3
const val ERROR = 4 const val ERROR = 4
} }
} }

View File

@ -1,31 +1,31 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
import java.io.Serializable import java.io.Serializable
interface SChapter : Serializable { interface SChapter : Serializable {
var url: String var url: String
var name: String var name: String
var date_upload: Long var date_upload: Long
var chapter_number: Float var chapter_number: Float
var scanlator: String? var scanlator: String?
fun copyFrom(other: SChapter) { fun copyFrom(other: SChapter) {
name = other.name name = other.name
url = other.url url = other.url
date_upload = other.date_upload date_upload = other.date_upload
chapter_number = other.chapter_number chapter_number = other.chapter_number
scanlator = other.scanlator scanlator = other.scanlator
} }
companion object { companion object {
fun create(): SChapter { fun create(): SChapter {
return SChapterImpl() return SChapterImpl()
} }
} }
} }

View File

@ -1,15 +1,15 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
class SChapterImpl : SChapter { class SChapterImpl : SChapter {
override lateinit var url: String override lateinit var url: String
override lateinit var name: String override lateinit var name: String
override var date_upload: Long = 0 override var date_upload: Long = 0
override var chapter_number: Float = -1f override var chapter_number: Float = -1f
override var scanlator: String? = null override var scanlator: String? = null
} }

View File

@ -1,58 +1,58 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
import java.io.Serializable import java.io.Serializable
interface SManga : Serializable { interface SManga : Serializable {
var url: String var url: String
var title: String var title: String
var artist: String? var artist: String?
var author: String? var author: String?
var description: String? var description: String?
var genre: String? var genre: String?
var status: Int var status: Int
var thumbnail_url: String? var thumbnail_url: String?
var initialized: Boolean var initialized: Boolean
fun copyFrom(other: SManga) { fun copyFrom(other: SManga) {
if (other.author != null) if (other.author != null)
author = other.author author = other.author
if (other.artist != null) if (other.artist != null)
artist = other.artist artist = other.artist
if (other.description != null) if (other.description != null)
description = other.description description = other.description
if (other.genre != null) if (other.genre != null)
genre = other.genre genre = other.genre
if (other.thumbnail_url != null) if (other.thumbnail_url != null)
thumbnail_url = other.thumbnail_url thumbnail_url = other.thumbnail_url
status = other.status status = other.status
if (!initialized) if (!initialized)
initialized = other.initialized initialized = other.initialized
} }
companion object { companion object {
const val UNKNOWN = 0 const val UNKNOWN = 0
const val ONGOING = 1 const val ONGOING = 1
const val COMPLETED = 2 const val COMPLETED = 2
const val LICENSED = 3 const val LICENSED = 3
fun create(): SManga { fun create(): SManga {
return SMangaImpl() return SMangaImpl()
} }
} }
} }

View File

@ -1,23 +1,23 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
class SMangaImpl : SManga { class SMangaImpl : SManga {
override lateinit var url: String override lateinit var url: String
override lateinit var title: String override lateinit var title: String
override var artist: String? = null override var artist: String? = null
override var author: String? = null override var author: String? = null
override var description: String? = null override var description: String? = null
override var genre: String? = null override var genre: String? = null
override var status: Int = 0 override var status: Int = 0
override var thumbnail_url: String? = null override var thumbnail_url: String? = null
override var initialized: Boolean = false override var initialized: Boolean = false
} }

View File

@ -1,367 +1,367 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.* import eu.kanade.tachiyomi.source.model.*
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.lang.Exception import java.lang.Exception
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import java.security.MessageDigest import java.security.MessageDigest
/** /**
* A simple implementation for sources from a website. * A simple implementation for sources from a website.
*/ */
abstract class HttpSource : CatalogueSource { abstract class HttpSource : CatalogueSource {
/** /**
* Network service. * Network service.
*/ */
protected val network: NetworkHelper by injectLazy() protected val network: NetworkHelper by injectLazy()
// /** // /**
// * Preferences that a source may need. // * Preferences that a source may need.
// */ // */
// val preferences: SharedPreferences by lazy { // val preferences: SharedPreferences by lazy {
// Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE) // Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
// } // }
/** /**
* Base url of the website without the trailing slash, like: http://mysite.com * Base url of the website without the trailing slash, like: http://mysite.com
*/ */
abstract val baseUrl: String abstract val baseUrl: String
/** /**
* Version id used to generate the source id. If the site completely changes and urls are * Version id used to generate the source id. If the site completely changes and urls are
* incompatible, you may increase this value and it'll be considered as a new source. * incompatible, you may increase this value and it'll be considered as a new source.
*/ */
open val versionId = 1 open val versionId = 1
/** /**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits) * Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId * of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0. * Note the generated id sets the sign bit to 0.
*/ */
override val id by lazy { override val id by lazy {
val key = "${name.toLowerCase()}/$lang/$versionId" val key = "${name.toLowerCase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
} }
/** /**
* Headers used for requests. * Headers used for requests.
*/ */
val headers: Headers by lazy { headersBuilder().build() } val headers: Headers by lazy { headersBuilder().build() }
/** /**
* Default network client for doing requests. * Default network client for doing requests.
*/ */
open val client: OkHttpClient open val client: OkHttpClient
get() = network.client get() = network.client
/** /**
* Headers builder for requests. Implementations can override this method for custom headers. * Headers builder for requests. Implementations can override this method for custom headers.
*/ */
open protected fun headersBuilder() = Headers.Builder().apply { open protected fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
} }
/** /**
* Visible name of the source. * Visible name of the source.
*/ */
override fun toString() = "$name (${lang.toUpperCase()})" override fun toString() = "$name (${lang.toUpperCase()})"
/** /**
* Returns an observable containing a page with a list of manga. Normally it's not needed to * Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method. * override this method.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
override fun fetchPopularManga(page: Int): Observable<MangasPage> { override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page)) return client.newCall(popularMangaRequest(page))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
popularMangaParse(response) popularMangaParse(response)
} }
} }
/** /**
* Returns the request for the popular manga given the page. * Returns the request for the popular manga given the page.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
abstract protected fun popularMangaRequest(page: Int): Request abstract protected fun popularMangaRequest(page: Int): Request
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun popularMangaParse(response: Response): MangasPage abstract protected fun popularMangaParse(response: Response): MangasPage
/** /**
* Returns an observable containing a page with a list of manga. Normally it's not needed to * Returns an observable containing a page with a list of manga. Normally it's not needed to
* override this method. * override this method.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return client.newCall(searchMangaRequest(page, query, filters)) return client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
searchMangaParse(response) searchMangaParse(response)
} }
} }
/** /**
* Returns the request for the search manga given the page. * Returns the request for the search manga given the page.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
* @param query the search query. * @param query the search query.
* @param filters the list of filters to apply. * @param filters the list of filters to apply.
*/ */
abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun searchMangaParse(response: Response): MangasPage abstract protected fun searchMangaParse(response: Response): MangasPage
/** /**
* Returns an observable containing a page with a list of latest manga updates. * Returns an observable containing a page with a list of latest manga updates.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> { override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return client.newCall(latestUpdatesRequest(page)) return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
latestUpdatesParse(response) latestUpdatesParse(response)
} }
} }
/** /**
* Returns the request for latest manga given the page. * Returns the request for latest manga given the page.
* *
* @param page the page number to retrieve. * @param page the page number to retrieve.
*/ */
abstract protected fun latestUpdatesRequest(page: Int): Request abstract protected fun latestUpdatesRequest(page: Int): Request
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun latestUpdatesParse(response: Response): MangasPage abstract protected fun latestUpdatesParse(response: Response): MangasPage
/** /**
* Returns an observable with the updated details for a manga. Normally it's not needed to * Returns an observable with the updated details for a manga. Normally it's not needed to
* override this method. * override this method.
* *
* @param manga the manga to be updated. * @param manga the manga to be updated.
*/ */
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(mangaDetailsRequest(manga)) return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
mangaDetailsParse(response).apply { initialized = true } mangaDetailsParse(response).apply { initialized = true }
} }
} }
/** /**
* Returns the request for the details of a manga. Override only if it's needed to change the * Returns the request for the details of a manga. Override only if it's needed to change the
* url, send different headers or request method like POST. * url, send different headers or request method like POST.
* *
* @param manga the manga to be updated. * @param manga the manga to be updated.
*/ */
open fun mangaDetailsRequest(manga: SManga): Request { open fun mangaDetailsRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers) return GET(baseUrl + manga.url, headers)
} }
/** /**
* Parses the response from the site and returns the details of a manga. * Parses the response from the site and returns the details of a manga.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun mangaDetailsParse(response: Response): SManga abstract protected fun mangaDetailsParse(response: Response): SManga
/** /**
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to * Returns an observable with the updated chapter list for a manga. Normally it's not needed to
* override this method. If a manga is licensed an empty chapter list observable is returned * override this method. If a manga is licensed an empty chapter list observable is returned
* *
* @param manga the manga to look for chapters. * @param manga the manga to look for chapters.
*/ */
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
if (manga.status != SManga.LICENSED) { if (manga.status != SManga.LICENSED) {
return client.newCall(chapterListRequest(manga)) return client.newCall(chapterListRequest(manga))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
chapterListParse(response) chapterListParse(response)
} }
} else { } else {
return Observable.error(Exception("Licensed - No chapters to show")) return Observable.error(Exception("Licensed - No chapters to show"))
} }
} }
/** /**
* Returns the request for updating the chapter list. Override only if it's needed to override * 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. * the url, send different headers or request method like POST.
* *
* @param manga the manga to look for chapters. * @param manga the manga to look for chapters.
*/ */
open protected fun chapterListRequest(manga: SManga): Request { open protected fun chapterListRequest(manga: SManga): Request {
return GET(baseUrl + manga.url, headers) return GET(baseUrl + manga.url, headers)
} }
/** /**
* Parses the response from the site and returns a list of chapters. * Parses the response from the site and returns a list of chapters.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun chapterListParse(response: Response): List<SChapter> abstract protected fun chapterListParse(response: Response): List<SChapter>
/** /**
* Returns an observable with the page list for a chapter. * Returns an observable with the page list for a chapter.
* *
* @param chapter the chapter whose page list has to be fetched. * @param chapter the chapter whose page list has to be fetched.
*/ */
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return client.newCall(pageListRequest(chapter)) return client.newCall(pageListRequest(chapter))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
pageListParse(response) pageListParse(response)
} }
} }
/** /**
* Returns the request for getting the page list. Override only if it's needed to override the * 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. * url, send different headers or request method like POST.
* *
* @param chapter the chapter whose page list has to be fetched. * @param chapter the chapter whose page list has to be fetched.
*/ */
open protected fun pageListRequest(chapter: SChapter): Request { open protected fun pageListRequest(chapter: SChapter): Request {
return GET(baseUrl + chapter.url, headers) return GET(baseUrl + chapter.url, headers)
} }
/** /**
* Parses the response from the site and returns a list of pages. * Parses the response from the site and returns a list of pages.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun pageListParse(response: Response): List<Page> abstract protected fun pageListParse(response: Response): List<Page>
/** /**
* Returns an observable with the page containing the source url of the image. If there's any * 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. * error, it will return null instead of throwing an exception.
* *
* @param page the page whose source image has to be fetched. * @param page the page whose source image has to be fetched.
*/ */
open fun fetchImageUrl(page: Page): Observable<String> { open fun fetchImageUrl(page: Page): Observable<String> {
return client.newCall(imageUrlRequest(page)) return client.newCall(imageUrlRequest(page))
.asObservableSuccess() .asObservableSuccess()
.map { imageUrlParse(it) } .map { imageUrlParse(it) }
} }
/** /**
* Returns the request for getting the url to the source image. Override only if it's needed to * 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. * override the url, send different headers or request method like POST.
* *
* @param page the chapter whose page list has to be fetched * @param page the chapter whose page list has to be fetched
*/ */
open protected fun imageUrlRequest(page: Page): Request { open protected fun imageUrlRequest(page: Page): Request {
return GET(page.url, headers) return GET(page.url, headers)
} }
/** /**
* Parses the response from the site and returns the absolute url to the source image. * Parses the response from the site and returns the absolute url to the source image.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
abstract protected fun imageUrlParse(response: Response): String abstract protected fun imageUrlParse(response: Response): String
/** /**
* Returns an observable with the response of the source image. * Returns an observable with the response of the source image.
* *
* @param page the page whose source image has to be downloaded. * @param page the page whose source image has to be downloaded.
*/ */
fun fetchImage(page: Page): Observable<Response> { fun fetchImage(page: Page): Observable<Response> {
return client.newCallWithProgress(imageRequest(page), page) return client.newCallWithProgress(imageRequest(page), page)
.asObservableSuccess() .asObservableSuccess()
} }
/** /**
* Returns the request for getting the source image. Override only if it's needed to override * 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. * the url, send different headers or request method like POST.
* *
* @param page the chapter whose page list has to be fetched * @param page the chapter whose page list has to be fetched
*/ */
open protected fun imageRequest(page: Page): Request { open protected fun imageRequest(page: Page): Request {
return GET(page.imageUrl!!, headers) return GET(page.imageUrl!!, headers)
} }
/** /**
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from * Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change. * database and the urls could still work after a domain change.
* *
* @param url the full url to the chapter. * @param url the full url to the chapter.
*/ */
fun SChapter.setUrlWithoutDomain(url: String) { fun SChapter.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url) this.url = getUrlWithoutDomain(url)
} }
/** /**
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from * Assigns the url of the manga without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change. * database and the urls could still work after a domain change.
* *
* @param url the full url to the manga. * @param url the full url to the manga.
*/ */
fun SManga.setUrlWithoutDomain(url: String) { fun SManga.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url) this.url = getUrlWithoutDomain(url)
} }
/** /**
* Returns the url of the given string without the scheme and domain. * Returns the url of the given string without the scheme and domain.
* *
* @param orig the full url. * @param orig the full url.
*/ */
private fun getUrlWithoutDomain(orig: String): String { private fun getUrlWithoutDomain(orig: String): String {
try { try {
val uri = URI(orig) val uri = URI(orig)
var out = uri.path var out = uri.path
if (uri.query != null) if (uri.query != null)
out += "?" + uri.query out += "?" + uri.query
if (uri.fragment != null) if (uri.fragment != null)
out += "#" + uri.fragment out += "#" + uri.fragment
return out return out
} catch (e: URISyntaxException) { } catch (e: URISyntaxException) {
return orig return orig
} }
} }
/** /**
* Called before inserting a new chapter into database. Use it if you need to override chapter * Called before inserting a new chapter into database. Use it if you need to override chapter
* fields, like the title or the chapter number. Do not change anything to [manga]. * fields, like the title or the chapter number. Do not change anything to [manga].
* *
* @param chapter the chapter to be added. * @param chapter the chapter to be added.
* @param manga the manga of the chapter. * @param manga the manga of the chapter.
*/ */
open fun prepareNewChapter(chapter: SChapter, manga: SManga) { open fun prepareNewChapter(chapter: SChapter, manga: SManga) {
} }
/** /**
* Returns the list of filters for the source. * Returns the list of filters for the source.
*/ */
override fun getFilterList() = FilterList() override fun getFilterList() = FilterList()
} }

View File

@ -1,25 +1,25 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import rx.Observable import rx.Observable
fun HttpSource.getImageUrl(page: Page): Observable<Page> { fun HttpSource.getImageUrl(page: Page): Observable<Page> {
page.status = Page.LOAD_PAGE page.status = Page.LOAD_PAGE
return fetchImageUrl(page) return fetchImageUrl(page)
.doOnError { page.status = Page.ERROR } .doOnError { page.status = Page.ERROR }
.onErrorReturn { null } .onErrorReturn { null }
.doOnNext { page.imageUrl = it } .doOnNext { page.imageUrl = it }
.map { page } .map { page }
} }
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> { fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages) return Observable.from(pages)
.filter { !it.imageUrl.isNullOrEmpty() } .filter { !it.imageUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingImageUrlsFromPageList(pages)) .mergeWith(fetchRemainingImageUrlsFromPageList(pages))
} }
fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> { fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
return Observable.from(pages) return Observable.from(pages)
.filter { it.imageUrl.isNullOrEmpty() } .filter { it.imageUrl.isNullOrEmpty() }
.concatMap { getImageUrl(it) } .concatMap { getImageUrl(it) }
} }

View File

@ -1,15 +1,15 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
interface LoginSource : Source { interface LoginSource : Source {
fun isLogged(): Boolean fun isLogged(): Boolean
fun login(username: String, password: String): Observable<Boolean> fun login(username: String, password: String): Observable<Boolean>
fun isAuthenticationSuccessful(response: Response): Boolean fun isAuthenticationSuccessful(response: Response): Boolean
} }

View File

@ -1,200 +1,200 @@
package eu.kanade.tachiyomi.source.online package eu.kanade.tachiyomi.source.online
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
/** /**
* A simple implementation for sources from a website using Jsoup, an HTML parser. * A simple implementation for sources from a website using Jsoup, an HTML parser.
*/ */
abstract class ParsedHttpSource : HttpSource() { abstract class ParsedHttpSource : HttpSource() {
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response): MangasPage {
val document = response.asJsoup() val document = response.asJsoup()
val mangas = document.select(popularMangaSelector()).map { element -> val mangas = document.select(popularMangaSelector()).map { element ->
popularMangaFromElement(element) popularMangaFromElement(element)
} }
val hasNextPage = popularMangaNextPageSelector()?.let { selector -> val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
document.select(selector).first() document.select(selector).first()
} != null } != null
return MangasPage(mangas, hasNextPage) return MangasPage(mangas, hasNextPage)
} }
/** /**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/ */
abstract protected fun popularMangaSelector(): String abstract protected fun popularMangaSelector(): String
/** /**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's * Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values. * totally fine to fill only those two values.
* *
* @param element an element obtained from [popularMangaSelector]. * @param element an element obtained from [popularMangaSelector].
*/ */
abstract protected fun popularMangaFromElement(element: Element): SManga abstract protected fun popularMangaFromElement(element: Element): SManga
/** /**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page. * there's no next page.
*/ */
abstract protected fun popularMangaNextPageSelector(): String? abstract protected fun popularMangaNextPageSelector(): String?
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun searchMangaParse(response: Response): MangasPage { override fun searchMangaParse(response: Response): MangasPage {
val document = response.asJsoup() val document = response.asJsoup()
val mangas = document.select(searchMangaSelector()).map { element -> val mangas = document.select(searchMangaSelector()).map { element ->
searchMangaFromElement(element) searchMangaFromElement(element)
} }
val hasNextPage = searchMangaNextPageSelector()?.let { selector -> val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
document.select(selector).first() document.select(selector).first()
} != null } != null
return MangasPage(mangas, hasNextPage) return MangasPage(mangas, hasNextPage)
} }
/** /**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/ */
abstract protected fun searchMangaSelector(): String abstract protected fun searchMangaSelector(): String
/** /**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's * Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values. * totally fine to fill only those two values.
* *
* @param element an element obtained from [searchMangaSelector]. * @param element an element obtained from [searchMangaSelector].
*/ */
abstract protected fun searchMangaFromElement(element: Element): SManga abstract protected fun searchMangaFromElement(element: Element): SManga
/** /**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page. * there's no next page.
*/ */
abstract protected fun searchMangaNextPageSelector(): String? abstract protected fun searchMangaNextPageSelector(): String?
/** /**
* Parses the response from the site and returns a [MangasPage] object. * Parses the response from the site and returns a [MangasPage] object.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun latestUpdatesParse(response: Response): MangasPage { override fun latestUpdatesParse(response: Response): MangasPage {
val document = response.asJsoup() val document = response.asJsoup()
val mangas = document.select(latestUpdatesSelector()).map { element -> val mangas = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element) latestUpdatesFromElement(element)
} }
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector -> val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first() document.select(selector).first()
} != null } != null
return MangasPage(mangas, hasNextPage) return MangasPage(mangas, hasNextPage)
} }
/** /**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga. * Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
*/ */
abstract protected fun latestUpdatesSelector(): String abstract protected fun latestUpdatesSelector(): String
/** /**
* Returns a manga from the given [element]. Most sites only show the title and the url, it's * Returns a manga from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values. * totally fine to fill only those two values.
* *
* @param element an element obtained from [latestUpdatesSelector]. * @param element an element obtained from [latestUpdatesSelector].
*/ */
abstract protected fun latestUpdatesFromElement(element: Element): SManga abstract protected fun latestUpdatesFromElement(element: Element): SManga
/** /**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if * Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page. * there's no next page.
*/ */
abstract protected fun latestUpdatesNextPageSelector(): String? abstract protected fun latestUpdatesNextPageSelector(): String?
/** /**
* Parses the response from the site and returns the details of a manga. * Parses the response from the site and returns the details of a manga.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
return mangaDetailsParse(response.asJsoup()) return mangaDetailsParse(response.asJsoup())
} }
/** /**
* Returns the details of the manga from the given [document]. * Returns the details of the manga from the given [document].
* *
* @param document the parsed document. * @param document the parsed document.
*/ */
abstract protected fun mangaDetailsParse(document: Document): SManga abstract protected fun mangaDetailsParse(document: Document): SManga
/** /**
* Parses the response from the site and returns a list of chapters. * Parses the response from the site and returns a list of chapters.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val document = response.asJsoup() val document = response.asJsoup()
return document.select(chapterListSelector()).map { chapterFromElement(it) } return document.select(chapterListSelector()).map { chapterFromElement(it) }
} }
/** /**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter. * Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
*/ */
abstract protected fun chapterListSelector(): String abstract protected fun chapterListSelector(): String
/** /**
* Returns a chapter from the given element. * Returns a chapter from the given element.
* *
* @param element an element obtained from [chapterListSelector]. * @param element an element obtained from [chapterListSelector].
*/ */
abstract protected fun chapterFromElement(element: Element): SChapter abstract protected fun chapterFromElement(element: Element): SChapter
/** /**
* Parses the response from the site and returns the page list. * Parses the response from the site and returns the page list.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
return pageListParse(response.asJsoup()) return pageListParse(response.asJsoup())
} }
/** /**
* Returns a page list from the given document. * Returns a page list from the given document.
* *
* @param document the parsed document. * @param document the parsed document.
*/ */
abstract protected fun pageListParse(document: Document): List<Page> abstract protected fun pageListParse(document: Document): List<Page>
/** /**
* Parse the response from the site and returns the absolute url to the source image. * Parse the response from the site and returns the absolute url to the source image.
* *
* @param response the response from the site. * @param response the response from the site.
*/ */
override fun imageUrlParse(response: Response): String { override fun imageUrlParse(response: Response): String {
return imageUrlParse(response.asJsoup()) return imageUrlParse(response.asJsoup())
} }
/** /**
* Returns the absolute url to the source image from the document. * Returns the absolute url to the source image from the document.
* *
* @param document the parsed document. * @param document the parsed document.
*/ */
abstract protected fun imageUrlParse(document: Document): String abstract protected fun imageUrlParse(document: Document): String
} }

View File

@ -1,21 +1,21 @@
package eu.kanade.tachiyomi.ui.base.controller package eu.kanade.tachiyomi.ui.base.controller
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorDelegate
import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener import eu.kanade.tachiyomi.ui.base.presenter.NucleusConductorLifecycleListener
import nucleus.factory.PresenterFactory import nucleus.factory.PresenterFactory
import nucleus.presenter.Presenter import nucleus.presenter.Presenter
@Suppress("LeakingThis") @Suppress("LeakingThis")
abstract class NucleusController<P : Presenter<*>>(val bundle: Bundle? = null) : RxController(bundle), abstract class NucleusController<P : Presenter<*>>(val bundle: Bundle? = null) : RxController(bundle),
PresenterFactory<P> { PresenterFactory<P> {
private val delegate = NucleusConductorDelegate(this) private val delegate = NucleusConductorDelegate(this)
val presenter: P val presenter: P
get() = delegate.presenter get() = delegate.presenter
init { init {
addLifecycleListener(NucleusConductorLifecycleListener(delegate)) addLifecycleListener(NucleusConductorLifecycleListener(delegate))
} }
} }

View File

@ -1,61 +1,61 @@
package eu.kanade.tachiyomi.ui.base.presenter; package eu.kanade.tachiyomi.ui.base.presenter;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import nucleus.factory.PresenterFactory; import nucleus.factory.PresenterFactory;
import nucleus.presenter.Presenter; import nucleus.presenter.Presenter;
public class NucleusConductorDelegate<P extends Presenter> { public class NucleusConductorDelegate<P extends Presenter> {
@Nullable private P presenter; @Nullable private P presenter;
@Nullable private Bundle bundle; @Nullable private Bundle bundle;
private PresenterFactory<P> factory; private PresenterFactory<P> factory;
public NucleusConductorDelegate(PresenterFactory<P> creator) { public NucleusConductorDelegate(PresenterFactory<P> creator) {
this.factory = creator; this.factory = creator;
} }
public P getPresenter() { public P getPresenter() {
if (presenter == null) { if (presenter == null) {
presenter = factory.createPresenter(); presenter = factory.createPresenter();
presenter.create(bundle); presenter.create(bundle);
bundle = null; bundle = null;
} }
return presenter; return presenter;
} }
Bundle onSaveInstanceState() { Bundle onSaveInstanceState() {
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
// getPresenter(); // Workaround a crash related to saving instance state with child routers // getPresenter(); // Workaround a crash related to saving instance state with child routers
if (presenter != null) { if (presenter != null) {
presenter.save(bundle); presenter.save(bundle);
} }
return bundle; return bundle;
} }
void onRestoreInstanceState(Bundle presenterState) { void onRestoreInstanceState(Bundle presenterState) {
bundle = presenterState; bundle = presenterState;
} }
void onTakeView(Object view) { void onTakeView(Object view) {
getPresenter(); getPresenter();
if (presenter != null) { if (presenter != null) {
//noinspection unchecked //noinspection unchecked
presenter.takeView(view); presenter.takeView(view);
} }
} }
void onDropView() { void onDropView() {
if (presenter != null) { if (presenter != null) {
presenter.dropView(); presenter.dropView();
} }
} }
void onDestroy() { void onDestroy() {
if (presenter != null) { if (presenter != null) {
presenter.destroy(); presenter.destroy();
} }
} }
} }

View File

@ -1,44 +1,44 @@
package eu.kanade.tachiyomi.ui.base.presenter; package eu.kanade.tachiyomi.ui.base.presenter;
import android.os.Bundle; import android.os.Bundle;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.view.View; import android.view.View;
import com.bluelinelabs.conductor.Controller; import com.bluelinelabs.conductor.Controller;
public class NucleusConductorLifecycleListener extends Controller.LifecycleListener { public class NucleusConductorLifecycleListener extends Controller.LifecycleListener {
private static final String PRESENTER_STATE_KEY = "presenter_state"; private static final String PRESENTER_STATE_KEY = "presenter_state";
private NucleusConductorDelegate delegate; private NucleusConductorDelegate delegate;
public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) { public NucleusConductorLifecycleListener(NucleusConductorDelegate delegate) {
this.delegate = delegate; this.delegate = delegate;
} }
@Override @Override
public void postCreateView(@NonNull Controller controller, @NonNull View view) { public void postCreateView(@NonNull Controller controller, @NonNull View view) {
delegate.onTakeView(controller); delegate.onTakeView(controller);
} }
@Override @Override
public void preDestroyView(@NonNull Controller controller, @NonNull View view) { public void preDestroyView(@NonNull Controller controller, @NonNull View view) {
delegate.onDropView(); delegate.onDropView();
} }
@Override @Override
public void preDestroy(@NonNull Controller controller) { public void preDestroy(@NonNull Controller controller) {
delegate.onDestroy(); delegate.onDestroy();
} }
@Override @Override
public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) { public void onSaveInstanceState(@NonNull Controller controller, @NonNull Bundle outState) {
outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState()); outState.putBundle(PRESENTER_STATE_KEY, delegate.onSaveInstanceState());
} }
@Override @Override
public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) { public void onRestoreInstanceState(@NonNull Controller controller, @NonNull Bundle savedInstanceState) {
delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY)); delegate.onRestoreInstanceState(savedInstanceState.getBundle(PRESENTER_STATE_KEY));
} }
} }

View File

@ -13,7 +13,7 @@ class SourceDividerItemDecoration(context: Context) : RecyclerView.ItemDecoratio
init { init {
val a = context.obtainStyledAttributes(intArrayOf(android.R.attr.listDivider)) val a = context.obtainStyledAttributes(intArrayOf(android.R.attr.listDivider))
divider = a.getDrawable(0) divider = a.getDrawable(0)!!
a.recycle() a.recycle()
} }

View File

@ -17,11 +17,13 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController import eu.kanade.tachiyomi.ui.base.controller.SecondaryDrawerController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.manga.MangaController import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.info.MangaWebViewController
import eu.kanade.tachiyomi.util.* import eu.kanade.tachiyomi.util.*
import eu.kanade.tachiyomi.widget.AutofitRecyclerView import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.catalogue_controller.* import kotlinx.android.synthetic.main.catalogue_controller.*
@ -173,6 +175,7 @@ open class BrowseCatalogueController(bundle: Bundle) :
RecyclerView(view.context).apply { RecyclerView(view.context).apply {
id = R.id.recycler id = R.id.recycler
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
} }
} else { } else {
@ -199,7 +202,7 @@ open class BrowseCatalogueController(bundle: Bundle) :
catalogue_view.addView(recycler, 1) catalogue_view.addView(recycler, 1)
if (oldPosition != RecyclerView.NO_POSITION) { if (oldPosition != RecyclerView.NO_POSITION) {
recycler.layoutManager.scrollToPosition(oldPosition) recycler.layoutManager?.scrollToPosition(oldPosition)
} }
this.recycler = recycler this.recycler = recycler
} }
@ -259,15 +262,38 @@ open class BrowseCatalogueController(bundle: Bundle) :
} }
} }
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
val isHttpSource = presenter.source is HttpSource
menu.findItem(R.id.action_open_in_browser).isVisible = isHttpSource
menu.findItem(R.id.action_open_in_web_view).isVisible = isHttpSource
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_display_mode -> swapDisplayMode() R.id.action_display_mode -> swapDisplayMode()
R.id.action_set_filter -> navView?.let { activity?.drawer?.openDrawer(Gravity.END) } R.id.action_set_filter -> navView?.let { activity?.drawer?.openDrawer(Gravity.END) }
R.id.action_open_in_browser -> openInBrowser()
R.id.action_open_in_web_view -> openInWebView()
else -> return super.onOptionsItemSelected(item) else -> return super.onOptionsItemSelected(item)
} }
return true return true
} }
private fun openInBrowser() {
val source = presenter.source as? HttpSource ?: return
activity?.openInBrowser(source.baseUrl)
}
private fun openInWebView() {
val source = presenter.source as? HttpSource ?: return
router.pushController(MangaWebViewController(source.id, source.baseUrl)
.withFadeTransaction())
}
/** /**
* Restarts the request with a new query. * Restarts the request with a new query.
* *
@ -316,19 +342,22 @@ open class BrowseCatalogueController(bundle: Bundle) :
adapter.onLoadMoreComplete(null) adapter.onLoadMoreComplete(null)
hideProgressBar() hideProgressBar()
val message = if (error is NoResultsException) "No results found" else (error.message ?: "")
snack?.dismiss() snack?.dismiss()
snack = catalogue_view?.snack(message, Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_retry) { if (catalogue_view != null) {
// If not the first page, show bottom progress bar. val message = if (error is NoResultsException) catalogue_view.context.getString(R.string.no_results_found) else (error.message ?: "")
if (adapter.mainItemCount > 0) {
val item = progressItem ?: return@setAction snack = catalogue_view.snack(message, Snackbar.LENGTH_INDEFINITE) {
adapter.addScrollableFooterWithDelay(item, 0, true) setAction(R.string.action_retry) {
} else { // If not the first page, show bottom progress bar.
showProgressBar() if (adapter.mainItemCount > 0) {
val item = progressItem ?: return@setAction
adapter.addScrollableFooterWithDelay(item, 0, true)
} else {
showProgressBar()
}
presenter.requestNext()
} }
presenter.requestNext()
} }
} }
} }
@ -475,19 +504,21 @@ open class BrowseCatalogueController(bundle: Bundle) :
adapter?.notifyItemChanged(position) adapter?.notifyItemChanged(position)
val categories = presenter.getCategories() val categories = presenter.getCategories()
val defaultCategory = categories.find { it.id == preferences.defaultCategory() } val defaultCategoryId = preferences.defaultCategory()
if (defaultCategory != null) { val defaultCategory = categories.find { it.id == defaultCategoryId }
presenter.moveMangaToCategory(manga, defaultCategory) when {
} else if (categories.size <= 1) { // default or the one from the user defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory)
presenter.moveMangaToCategory(manga, categories.firstOrNull()) defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category
} else { presenter.moveMangaToCategory(manga, null)
val ids = presenter.getMangaCategoryIds(manga) else -> {
val preselected = ids.mapNotNull { id -> val ids = presenter.getMangaCategoryIds(manga)
categories.indexOfFirst { it.id == id }.takeIf { it != -1 } val preselected = ids.mapNotNull { id ->
}.toTypedArray() categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected) ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router) .showDialog(router)
}
} }
activity?.toast(activity?.getString(R.string.manga_added_library)) activity?.toast(activity?.getString(R.string.manga_added_library))
} }

View File

@ -316,9 +316,9 @@ open class BrowseCataloguePresenter(
} }
/** /**
* Get the default, and user categories. * Get user categories.
* *
* @return List of categories, default plus user categories * @return List of categories, not including the default category
*/ */
fun getCategories(): List<Category> { fun getCategories(): List<Category> {
return db.getCategories().executeAsBlocking() return db.getCategories().executeAsBlocking()

View File

@ -28,7 +28,7 @@ class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs:
val view = inflate(R.layout.catalogue_drawer_content) val view = inflate(R.layout.catalogue_drawer_content)
((view as ViewGroup).getChildAt(1) as ViewGroup).addView(recycler) ((view as ViewGroup).getChildAt(1) as ViewGroup).addView(recycler)
addView(view) addView(view)
title.text = context?.getString(R.string.source_search_options) title.text = context.getString(R.string.source_search_options)
search_btn.setOnClickListener { onSearchClicked() } search_btn.setOnClickListener { onSearchClicked() }
reset_btn.setOnClickListener { onResetClicked() } reset_btn.setOnClickListener { onResetClicked() }
} }
@ -37,4 +37,4 @@ class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs:
adapter.updateDataSet(items) adapter.updateDataSet(items)
} }
} }

View File

@ -1,88 +1,88 @@
package eu.kanade.tachiyomi.ui.catalogue.filter package eu.kanade.tachiyomi.ui.catalogue.filter
import eu.davidea.flexibleadapter.items.ISectionable import eu.davidea.flexibleadapter.items.ISectionable
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
class TriStateSectionItem(filter: Filter.TriState) : TriStateItem(filter), ISectionable<TriStateItem.Holder, GroupItem> { class TriStateSectionItem(filter: Filter.TriState) : TriStateItem(filter), ISectionable<TriStateItem.Holder, GroupItem> {
private var head: GroupItem? = null private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) { override fun setHeader(header: GroupItem?) {
head = header head = header
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
return filter == (other as TriStateSectionItem).filter return filter == (other as TriStateSectionItem).filter
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return filter.hashCode() return filter.hashCode()
} }
} }
class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable<TextItem.Holder, GroupItem> { class TextSectionItem(filter: Filter.Text) : TextItem(filter), ISectionable<TextItem.Holder, GroupItem> {
private var head: GroupItem? = null private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) { override fun setHeader(header: GroupItem?) {
head = header head = header
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
return filter == (other as TextSectionItem).filter return filter == (other as TextSectionItem).filter
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return filter.hashCode() return filter.hashCode()
} }
} }
class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable<CheckboxItem.Holder, GroupItem> { class CheckboxSectionItem(filter: Filter.CheckBox) : CheckboxItem(filter), ISectionable<CheckboxItem.Holder, GroupItem> {
private var head: GroupItem? = null private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) { override fun setHeader(header: GroupItem?) {
head = header head = header
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
return filter == (other as CheckboxSectionItem).filter return filter == (other as CheckboxSectionItem).filter
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return filter.hashCode() return filter.hashCode()
} }
} }
class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable<SelectItem.Holder, GroupItem> { class SelectSectionItem(filter: Filter.Select<*>) : SelectItem(filter), ISectionable<SelectItem.Holder, GroupItem> {
private var head: GroupItem? = null private var head: GroupItem? = null
override fun getHeader(): GroupItem? = head override fun getHeader(): GroupItem? = head
override fun setHeader(header: GroupItem?) { override fun setHeader(header: GroupItem?) {
head = header head = header
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
return filter == (other as SelectSectionItem).filter return filter == (other as SelectSectionItem).filter
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return filter.hashCode() return filter.hashCode()
} }
} }

View File

@ -1,52 +1,52 @@
package eu.kanade.tachiyomi.ui.catalogue.filter package eu.kanade.tachiyomi.ui.catalogue.filter
import android.view.View import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem import eu.davidea.flexibleadapter.items.AbstractExpandableHeaderItem
import eu.davidea.flexibleadapter.items.ISectionable import eu.davidea.flexibleadapter.items.ISectionable
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.util.setVectorCompat import eu.kanade.tachiyomi.util.setVectorCompat
class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem<SortGroup.Holder, ISectionable<*, *>>() { class SortGroup(val filter: Filter.Sort) : AbstractExpandableHeaderItem<SortGroup.Holder, ISectionable<*, *>>() {
init { init {
isExpanded = false isExpanded = false
} }
override fun getLayoutRes(): Int { override fun getLayoutRes(): Int {
return R.layout.navigation_view_group return R.layout.navigation_view_group
} }
override fun getItemViewType(): Int { override fun getItemViewType(): Int {
return 100 return 100
} }
override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): Holder { override fun createViewHolder(view: View, adapter: FlexibleAdapter<*>): Holder {
return Holder(view, adapter) return Holder(view, adapter)
} }
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List<Any?>?) { override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: Holder, position: Int, payloads: List<Any?>?) {
holder.title.text = filter.name holder.title.text = filter.name
holder.icon.setVectorCompat(if (isExpanded) holder.icon.setVectorCompat(if (isExpanded)
R.drawable.ic_expand_more_white_24dp R.drawable.ic_expand_more_white_24dp
else else
R.drawable.ic_chevron_right_white_24dp) R.drawable.ic_chevron_right_white_24dp)
holder.itemView.setOnClickListener(holder) holder.itemView.setOnClickListener(holder)
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (javaClass != other?.javaClass) return false if (javaClass != other?.javaClass) return false
return filter == (other as SortGroup).filter return filter == (other as SortGroup).filter
} }
override fun hashCode(): Int { override fun hashCode(): Int {
return filter.hashCode() return filter.hashCode()
} }
class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter) class Holder(view: View, adapter: FlexibleAdapter<*>) : GroupItem.Holder(view, adapter)
} }

View File

@ -19,7 +19,7 @@ class CatalogueSearchAdapter(val controller: CatalogueSearchController) :
*/ */
private var bundle = Bundle() private var bundle = Bundle()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any?>?) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any?>) {
super.onBindViewHolder(holder, position, payloads) super.onBindViewHolder(holder, position, payloads)
restoreHolderState(holder) restoreHolderState(holder)
} }
@ -38,7 +38,7 @@ class CatalogueSearchAdapter(val controller: CatalogueSearchController) :
override fun onRestoreInstanceState(savedInstanceState: Bundle) { override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState) super.onRestoreInstanceState(savedInstanceState)
bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY) bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!!
} }
/** /**
@ -71,4 +71,4 @@ class CatalogueSearchAdapter(val controller: CatalogueSearchController) :
private companion object { private companion object {
const val HOLDER_BUNDLE_KEY = "holder_bundle" const val HOLDER_BUNDLE_KEY = "holder_bundle"
} }
} }

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