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
android:
components:
- build-tools-28.0.3
- android-27
- build-tools-29.0.2
- android-28
- extra-android-m2repository
- extra-google-m2repository
- extra-android-support
@ -10,7 +11,7 @@ android:
licenses:
- android-sdk-license-.+
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
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;

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
@ -11,10 +11,10 @@ Tachiyomi is a free and open source manga reader for Android.
## Features
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
* 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
* A configurable reader with multiple viewers, reading directions and other settings.
* [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
* Light and dark themes
* Schedule updating your library for new chapters
@ -23,7 +23,7 @@ Features include:
## Download
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
@ -63,7 +63,7 @@ Catalogue requests should be created at https://github.com/inorichi/tachiyomi-ex
## 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).
## License

View File

@ -29,17 +29,17 @@ ext {
}
android {
compileSdkVersion 27
buildToolsVersion '28.0.3'
compileSdkVersion 28
buildToolsVersion '29.0.2'
publishNonDefault true
defaultConfig {
applicationId "eu.kanade.tachiyomi"
minSdkVersion 16
targetSdkVersion 27
targetSdkVersion 28
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 41
versionName "0.8.4"
versionCode 42
versionName "0.8.5"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -73,7 +73,6 @@ android {
dimension "default"
}
dev {
minSdkVersion 21
resConfigs "en", "xxhdpi"
dimension "default"
}
@ -92,6 +91,14 @@ android {
checkReleaseBuilds false
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
@ -101,7 +108,7 @@ dependencies {
implementation 'com.github.inorichi:junrar-android:634c1f5'
// 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:appcompat-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: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'
@ -119,10 +126,10 @@ dependencies {
// ReactiveX
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.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
implementation "com.squareup.okhttp3:okhttp:3.10.0"
@ -146,7 +153,7 @@ dependencies {
implementation 'com.github.inorichi:unifile:e9ee588'
// HTML parser
implementation 'org.jsoup:jsoup:1.10.2'
implementation 'org.jsoup:jsoup:1.12.1'
// Job scheduling
implementation 'com.evernote:android-job:1.2.5'
@ -156,7 +163,7 @@ dependencies {
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
// 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-sqlite:8be19de@aar'
implementation 'io.requery:sqlite-android:3.25.2'
@ -179,14 +186,11 @@ dependencies {
implementation 'jp.wasabeef:glide-transformations:3.1.1'
// Logging
implementation 'com.jakewharton.timber:timber:4.6.1'
implementation 'com.jakewharton.timber:timber:4.7.1'
// Crash reports
implementation 'ch.acra:acra:4.9.2'
// Sort
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
// UI
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.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"
final coroutines_version = '0.22.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
final coroutines_version = '1.3.3'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
}
buildscript {
ext.kotlin_version = '1.2.71'
ext.kotlin_version = '1.3.61'
repositories {
mavenCentral()
}
@ -247,10 +250,9 @@ repositories {
mavenCentral()
}
kotlin {
experimental {
coroutines 'enable'
}
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers
tasks.withType(org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompile).all {
kotlinOptions.freeCompilerArgs += ["-Xuse-experimental=kotlin.Experimental"]
}
androidExtensions {

View File

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

View File

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

View File

@ -82,6 +82,11 @@ interface MangaQueries : DbProvider {
.withPutResolver(MangaViewerPutResolver())
.prepare()
fun updateMangaTitle(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaTitlePutResolver())
.prepare()
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).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()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id)
.build()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_VIEWER, manga.viewer)

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.net.Uri
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.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
@ -28,7 +29,9 @@ class DownloadProvider(private val context: Context) {
* The root directory for downloads.
*/
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 {
@ -44,9 +47,13 @@ class DownloadProvider(private val context: Context) {
* @param source the source of the manga.
*/
internal fun getMangaDir(manga: Manga, source: Source): UniFile {
return downloadsDir
.createDirectory(getSourceDirName(source))
.createDirectory(getMangaDirName(manga))
try {
return downloadsDir
.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())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ state -> onNetworkStateChanged(state)
}, { _ ->
}, {
toast(R.string.download_queue_error)
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.fetchAllImageUrlsFromPageList
import eu.kanade.tachiyomi.util.*
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.async
import okhttp3.Response
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
@ -102,7 +102,7 @@ class Downloader(
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
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 {
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.
val chaptersWithoutDir = async {
val mangaDir = provider.findMangaDir(manga, source)
@ -232,7 +232,7 @@ class Downloader(
}
// Start downloader if needed
if (autoStart) {
if (autoStart && wasEmpty) {
DownloadService.start(this@Downloader.context)
}
}
@ -407,6 +407,8 @@ class Downloader(
if (download.status == Download.DOWNLOADED) {
tmpDir.renameTo(dirname)
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.download.DownloadManager
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.notification.NotificationReceiver
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.
subscription = Observable
.defer {
val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault()
val mangaList = getMangaToUpdate(intent, target)
.sortedWith(rankingScheme[selectedScheme])
// Update either chapter list or manga details.
when (target) {
@ -246,7 +249,6 @@ class LibraryUpdateService(
else
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
}
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
}

View File

@ -29,6 +29,8 @@ object PreferenceKeys {
const val colorFilterValue = "color_filter_value"
const val colorFilterMode = "color_filter_mode"
const val defaultViewer = "pref_default_viewer_key"
const val imageScaleType = "pref_image_scale_type_key"
@ -85,6 +87,8 @@ object PreferenceKeys {
const val libraryUpdateCategories = "library_update_categories"
const val libraryUpdatePrioritization = "library_update_prioritization"
const val filterDownloaded = "pref_filter_downloaded_key"
const val filterUnread = "pref_filter_unread_key"

View File

@ -57,6 +57,8 @@ class PreferencesHelper(val context: Context) {
fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0)
fun colorFilterMode() = rxPrefs.getInteger(Keys.colorFilterMode, 0)
fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 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 libraryUpdatePrioritization() = rxPrefs.getInteger(Keys.libraryUpdatePrioritization, 0)
fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false)
fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false)

View File

@ -45,7 +45,7 @@ class SharedPreferencesDataStore(private val prefs: SharedPreferences) : Prefere
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)
}

View File

@ -4,7 +4,8 @@ import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
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) {
@ -12,7 +13,8 @@ class TrackManager(private val context: Context) {
const val MYANIMELIST = 1
const val ANILIST = 2
const val KITSU = 3
const val SHIKOMORI = 4
const val SHIKIMORI = 4
const val BANGUMI = 5
}
val myAnimeList = Myanimelist(context, MYANIMELIST)
@ -21,9 +23,11 @@ class TrackManager(private val context: Context) {
val kitsu = Kitsu(context, KITSU)
val shikomori = Shikomori(context, SHIKOMORI)
val shikimori = Shikimori(context, SHIKIMORI)
val services = listOf(myAnimeList, aniList, kitsu, shikomori)
val bangumi = Bangumi(context, BANGUMI)
val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi)
fun getService(id: Int) = services.find { it.id == id }

View File

@ -21,13 +21,15 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val jsonMime = MediaType.parse("application/json; charset=utf-8")
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> {
val query = """
mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status)
{ id status } }
"""
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
| id
| status
|}
|}
|""".trimMargin()
val variables = jsonObject(
"mangaId" to track.media_id,
"progress" to track.last_chapter_read,
@ -58,14 +60,14 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
fun updateLibManga(track: Track): Observable<Track> {
val query = """
mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
id
status
progress
}
}
"""
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|id
|status
|progress
|}
|}
|""".trimMargin()
val variables = jsonObject(
"listId" to track.library_id,
"progress" to track.last_chapter_read,
@ -90,29 +92,29 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
fun search(search: String): Observable<List<TrackSearch>> {
val query = """
query Search(${'$'}query: String) {
Page (perPage: 50) {
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
id
title {
romaji
}
coverImage {
large
}
type
status
chapters
description
startDate {
year
month
day
}
}
}
}
"""
|query Search(${'$'}query: String) {
|Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|id
|title {
|romaji
|}
|coverImage {
|large
|}
|type
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|}
|}
|}
|""".trimMargin()
val variables = jsonObject(
"query" to search
)
@ -142,37 +144,37 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
fun findLibManga(track: Track, userid: Int) : Observable<Track?> {
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
val query = """
query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
Page {
mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
id
status
scoreRaw: score(format: POINT_100)
progress
media{
id
title {
romaji
}
coverImage {
large
}
type
status
chapters
description
startDate {
year
month
day
}
}
}
}
}
"""
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|Page {
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|id
|status
|scoreRaw: score(format: POINT_100)
|progress
|media {
|id
|title {
|romaji
|}
|coverImage {
|large
|}
|type
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|}
|}
|}
|}
|""".trimMargin()
val variables = jsonObject(
"id" to userid,
"manga_id" to track.media_id
@ -214,16 +216,15 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
fun getCurrentUser(): Observable<Pair<Int, String>> {
val query = """
query User
{
Viewer {
id
mediaListOptions {
scoreFormat
}
}
}
"""
|query User {
|Viewer {
|id
|mediaListOptions {
|scoreFormat
|}
|}
|}
|""".trimMargin()
val payload = jsonObject(
"query" to query
)
@ -246,7 +247,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
}
fun jsonToALManga(struct: JsonObject): ALManga{
private fun jsonToALManga(struct: JsonObject): ALManga {
val date = try {
val date = Calendar.getInstance()
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
@ -261,11 +262,10 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
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 {
private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth"

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

@ -18,13 +18,12 @@ class KitsuSearchManga(obj: JsonObject) {
private val synopsis by obj.byString
private var startDate = obj.get("startDate").nullString?.let {
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
@CallSuper
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = this@KitsuSearchManga.id
title = canonicalTitle
total_chapters = chapterCount ?: 0
@ -55,7 +54,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
val progress by obj["attributes"].byInt
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
media_id = libraryId
title = canonicalTitle
total_chapters = chapterCount ?: 0

View File

@ -10,11 +10,11 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl
import rx.Completable
import rx.Observable
import java.lang.Exception
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
@ -29,7 +29,8 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
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"
@ -62,7 +63,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getCSRF())
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
@ -70,11 +71,11 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
track.status = COMPLETED
}
return api.updateLibManga(track, getCSRF())
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getCSRF())
return api.findLibManga(track)
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
@ -93,7 +94,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getCSRF())
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
@ -104,26 +105,48 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
override fun login(username: String, password: String): Completable {
logout()
return api.login(username, password)
return Observable.fromCallable { api.login(username, password) }
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
}
fun refreshLogin() {
val username = getUsername()
val password = getPassword()
logout()
try {
val csrf = api.login(username, password)
saveCSRF(csrf)
saveCredentials(username, password)
} catch (e: Exception) {
logout()
throw e
}
}
// Attempt to login again if cookies have been cleared but credentials are still filled
fun ensureLoggedIn() {
if (isAuthorized) return
if (!isLogged) throw Exception("MAL Login Credentials not found")
refreshLogin()
}
override fun logout() {
super.logout()
preferences.trackToken(this).delete()
networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!)
}
override val isLogged: Boolean
get() = !getUsername().isEmpty() &&
!getPassword().isEmpty() &&
checkCookies() &&
!getCSRF().isEmpty()
val isAuthorized: Boolean
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)

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
class MyanimelistApi(private val client: OkHttpClient) {
class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
fun addLibManga(track: Track, csrf: String): Observable<Track> {
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 }
}
}
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun search(query: String): Observable<List<TrackSearch>> {
return client.newCall(GET(getSearchUrl(query)))
.asObservable()
.flatMap { response ->
Observable.from(Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody")
.select("tr").drop(1))
}
.filter { row ->
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()
return if (query.startsWith(PREFIX_MY)) {
val realQuery = query.removePrefix(PREFIX_MY)
getList()
.flatMap { Observable.from(it) }
.filter { it.title.contains(realQuery, true) }
.toList()
}
else {
client.newCall(GET(searchUrl(query)))
.asObservable()
.flatMap { response ->
Observable.from(Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody")
.select("tr").drop(1))
}
}
.toList()
.filter { row ->
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>> {
return getListUrl(csrf)
fun addLibManga(track: Track): Observable<Track> {
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 ->
getListXml(url)
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map { it ->
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
@ -90,107 +151,8 @@ class MyanimelistApi(private val client: OkHttpClient) {
.toList()
}
private fun getListXml(url: String): Observable<Document> {
return client.newCall(GET(url))
.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)))
private fun getListUrl(): Observable<String> {
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
.asObservable()
.map {response ->
baseUrl + Jsoup.parse(response.consumeBody())
@ -200,17 +162,17 @@ class MyanimelistApi(private val client: OkHttpClient) {
}
}
private fun getUpdateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath("edit.json")
.toString()
private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json")
.toString()
private fun getListXml(url: String): Observable<Document> {
return authClient.newCall(GET(url))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
private fun Response.consumeBody(): String? {
use {
if (it.code() != 200) throw Exception("Login error")
if (it.code() != 200) throw Exception("HTTP error ${it.code()}")
return it.body()?.string()
}
}
@ -229,37 +191,105 @@ class MyanimelistApi(private val client: OkHttpClient) {
}
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 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")
.split("\\?")[0]
.replace("/r/50x70/", "/")
fun Element.searchMediaId() = select("div.picSurround")
private fun Element.searchMediaId() = select("div.picSurround")
.select("a").attr("id")
.replace("sarea", "")
.toInt()
fun Element.searchSummary() = select("div.pt4")
private fun Element.searchSummary() = select("div.pt4")
.first()
.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
"Completed" -> 2
"On-Hold" -> 3
@ -267,10 +297,5 @@ class MyanimelistApi(private val client: OkHttpClient) {
"Plan to Read" -> 6
else -> 1
}
const val CSRF = "csrf_token"
const val TD = "td"
private const val FINISHED = "Finished"
private const val PUBLISHING = "Publishing"
}
}

View File

@ -1,4 +1,4 @@
package eu.kanade.tachiyomi.data.track.shikomori
package eu.kanade.tachiyomi.data.track.shikimori
data class OAuth(
val access_token: String,

View File

@ -1,7 +1,8 @@
package eu.kanade.tachiyomi.data.track.shikomori
package eu.kanade.tachiyomi.data.track.shikimori
import android.content.Context
import android.graphics.Color
import android.util.Log
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
@ -11,7 +12,7 @@ import rx.Completable
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)
@ -75,15 +76,15 @@ class Shikomori(private val context: Context, id: Int) : TrackService(id) {
const val DEFAULT_SCORE = 0
}
override val name = "Shikomori"
override val name = "Shikimori"
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)

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 com.github.salomonbrys.kotson.array
@ -18,7 +18,7 @@ import okhttp3.*
import rx.Observable
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 parser = JsonParser()
@ -33,7 +33,7 @@ class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInter
"target_type" to "Manga",
"chapters" to track.last_chapter_read,
"score" to track.score.toInt(),
"status" to track.toShikomoriStatus()
"status" to track.toShikimoriStatus()
)
)
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 {
return TrackSearch.create(TrackManager.SHIKOMORI).apply {
return TrackSearch.create(TrackManager.SHIKIMORI).apply {
media_id = obj["id"].asInt
title = obj["name"].asString
total_chapters = obj["chapters"].asInt
@ -87,14 +87,15 @@ class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInter
}
}
private fun jsonToTrack(obj: JsonObject): Track {
return Track.create(TrackManager.SHIKOMORI).apply {
private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
return Track.create(TrackManager.SHIKIMORI).apply {
title = mangas["name"].asString
media_id = obj["id"].asInt
title = ""
total_chapters = mangas["chapters"].asInt
last_chapter_read = obj["chapters"].asInt
total_chapters = obj["chapters"].asInt
score = (obj["score"].asInt).toFloat()
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())
.get()
.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()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).array
if (response.size() > 1) {
throw Exception("Too much mangas in response")
}
val entry = response.map {
jsonToTrack(it.obj)
}
entry.firstOrNull()
parser.parse(responseBody).obj
}.flatMap { mangas ->
authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body()?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).array
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 clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val baseUrl = "https://shikimori.org"
private const val apiUrl = "https://shikimori.org/api"
private const val oauthUrl = "https://shikimori.org/oauth/token"
private const val loginUrl = "https://shikimori.org/oauth/authorize"
private const val baseUrl = "https://shikimori.one"
private const val apiUrl = "https://shikimori.one/api"
private const val oauthUrl = "https://shikimori.one/oauth/token"
private const val loginUrl = "https://shikimori.one/oauth/authorize"
private const val redirectUrl = "tachiyomi://shikimori-auth"
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 okhttp3.Interceptor
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.
*/
private var oauth: OAuth? = shikomori.restoreToken()
private var oauth: OAuth? = shikimori.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
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!!
// Refresh access token if expired.
if (currAuth.isExpired()) {
val response = chain.proceed(ShikomoriApi.refreshTokenRequest(refreshToken))
val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
} else {
@ -38,6 +38,6 @@ class ShikomoriInterceptor(val shikomori: Shikomori, val gson: Gson) : Intercept
fun newAuth(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.content.Intent
import android.os.Build
import android.support.v4.app.NotificationCompat
import com.evernote.android.job.Job
import com.evernote.android.job.JobManager
@ -13,10 +14,15 @@ import eu.kanade.tachiyomi.util.notificationManager
class UpdaterJob : Job() {
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()
.map { result ->
if (result is GithubUpdateResult.NewUpdate) {
if (result is UpdateResult.NewUpdate<*>) {
val url = result.release.downloadLink
val intent = Intent(context, UpdaterService::class.java).apply {
@ -33,9 +39,9 @@ class UpdaterJob : Job() {
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.
.toBlocking()
.single()

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 eu.kanade.tachiyomi.data.updater.Release
/**
* Release object.
* Contains information about the latest release from Github.
*
* @param version version of latest release.
* @param changeLog log of latest release.
* @param info log of latest release.
* @param assets assets of latest release.
*/
class GithubRelease(@SerializedName("tag_name") val version: String,
@SerializedName("body") val changeLog: String,
@SerializedName("assets") private val assets: List<Assets>) {
@SerializedName("body") override val info: String,
@SerializedName("assets") private val assets: List<Assets>): Release {
/**
* Get download link of latest release from the assets.
* @return download link of latest release.
*/
val downloadLink: String
override val downloadLink: String
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 retrofit2.Retrofit

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.data.updater.UpdateChecker
import eu.kanade.tachiyomi.data.updater.UpdateResult
import rx.Observable
class GithubUpdateChecker {
class GithubUpdateChecker : UpdateChecker() {
private val service: GithubService = GithubService.create()
/**
* Returns observable containing release information
*/
fun checkForUpdate(): Observable<GithubUpdateResult> {
override fun checkForUpdate(): Observable<UpdateResult> {
return service.getLatestVersion().map { release ->
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.source.SourceManager
import eu.kanade.tachiyomi.util.launchNow
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.async
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
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.JsonArray
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.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
@ -36,17 +37,23 @@ internal class ExtensionGithubApi {
val json = gson.fromJson<JsonArray>(text)
return json.map { element ->
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")}"
return json
.filter { element ->
val versionName = element["version"].string
val libVersion = versionName.substringBeforeLast('.').toDouble()
libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
}
.map { element ->
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 {

View File

@ -39,7 +39,7 @@ class ExtensionInstallActivity : Activity() {
}
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 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.LoadResult
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
@ -91,7 +94,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
val pkgName = getPackageNameFromIntent(intent) ?:
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.SourceFactory
import eu.kanade.tachiyomi.util.Hash
import kotlinx.coroutines.experimental.async
import kotlinx.coroutines.experimental.runBlocking
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -27,8 +27,8 @@ internal object ExtensionLoader {
private const val EXTENSION_FEATURE = "tachiyomi.extension"
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
private const val LIB_VERSION_MIN = 1
private const val LIB_VERSION_MAX = 1
const val LIB_VERSION_MIN = 1.0
const val LIB_VERSION_MAX = 1.2
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
@ -100,10 +100,16 @@ internal object ExtensionLoader {
val versionName = pkgInfo.versionName
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
val majorLibVersion = versionName.substringBefore('.').toInt()
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) {
val exception = Exception("Lib version is $majorLibVersion, while only versions " +
val libVersion = versionName.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
val exception = Exception("Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
Timber.w(exception)
return LoadResult.Error(exception)
@ -121,7 +127,7 @@ internal object ExtensionLoader {
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(";")
.map {
val sourceClass = it.trim()

View File

@ -46,12 +46,22 @@ class AndroidCookieJar(context: Context) : CookieJar {
}
}
fun remove(url: HttpUrl) {
val cookies = manager.getCookie(url.toString()) ?: return
val domain = ".${url.host()}"
fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1) {
val urlString = url.toString()
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(";")
.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) {
syncManager.sync()
@ -66,5 +76,4 @@ class AndroidCookieJar(context: Context) : CookieJar {
syncManager.sync()
}
}
}

View File

@ -5,13 +5,11 @@ import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import eu.kanade.tachiyomi.util.WebViewClientCompat
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okhttp3.*
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@ -22,6 +20,8 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
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
* blocking the main thread too much. If used too often we could consider moving it to the
@ -39,14 +39,21 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
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) {
try {
response.close()
val solutionRequest = resolveWithWebView(chain.request())
return chain.proceed(solutionRequest)
networkHelper.cookieManager.remove(originalRequest.url(), listOf("__cfduid", "cf_clearance"), 0)
val oldCookie = networkHelper.cookieManager.get(originalRequest.url())
.firstOrNull { it.name() == "cf_clearance" }
return if (resolveWithWebView(originalRequest, oldCookie)) {
chain.proceed(originalRequest)
} else {
throw IOException("Failed to bypass Cloudflare!")
}
} catch (e: Exception) {
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
// we don't crash the entire app
@ -57,19 +64,15 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
return response
}
private fun isChallengeSolutionUrl(url: String): Boolean {
return "chk_jschl" in url
}
@SuppressLint("SetJavaScriptEnabled")
private fun resolveWithWebView(request: Request): Request {
private fun resolveWithWebView(request: Request, oldCookie: Cookie?): Boolean {
// We need to lock this thread until the WebView finds the challenge solution url, because
// OkHttp doesn't support asynchronous interceptors.
val latch = CountDownLatch(1)
var webView: WebView? = null
var solutionUrl: String? = null
var challengeFound = false
var cloudflareBypassed = false
val origRequestUrl = request.url().toString()
val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
@ -81,26 +84,17 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
view.settings.userAgentString = request.header("User-Agent")
view.webViewClient = object : WebViewClientCompat() {
override fun shouldOverrideUrlCompat(view: WebView, url: String): Boolean {
if (isChallengeSolutionUrl(url)) {
solutionUrl = url
override fun onPageFinished(view: WebView, url: String) {
fun isCloudFlareBypassed(): Boolean {
return networkHelper.cookieManager.get(HttpUrl.parse(origRequestUrl)!!)
.firstOrNull { it.name() == "cf_clearance" }
.let { it != null && it != oldCookie }
}
if (isCloudFlareBypassed()) {
cloudflareBypassed = true
latch.countDown()
}
return solutionUrl != null
}
override fun shouldInterceptRequestCompat(
view: WebView,
url: String
): WebResourceResponse? {
if (solutionUrl != null) {
// Intercept any request when we have the solution.
return WebResourceResponse("text/plain", "UTF-8", null)
}
return null
}
override fun onPageFinished(view: WebView, url: String) {
// Http error codes are only received since M
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
url == origRequestUrl && !challengeFound
@ -140,15 +134,7 @@ class CloudflareInterceptor(private val context: Context) : Interceptor {
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()
return cloudflareBypassed
}
}

View File

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

View File

@ -13,7 +13,7 @@ class SourceDividerItemDecoration(context: Context) : RecyclerView.ItemDecoratio
init {
val a = context.obtainStyledAttributes(intArrayOf(android.R.attr.listDivider))
divider = a.getDrawable(0)
divider = a.getDrawable(0)!!
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.source.CatalogueSource
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.SecondaryDrawerController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.manga.info.MangaWebViewController
import eu.kanade.tachiyomi.util.*
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
import kotlinx.android.synthetic.main.catalogue_controller.*
@ -173,6 +175,7 @@ open class BrowseCatalogueController(bundle: Bundle) :
RecyclerView(view.context).apply {
id = R.id.recycler
layoutManager = LinearLayoutManager(context)
layoutParams = RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
} else {
@ -199,7 +202,7 @@ open class BrowseCatalogueController(bundle: Bundle) :
catalogue_view.addView(recycler, 1)
if (oldPosition != RecyclerView.NO_POSITION) {
recycler.layoutManager.scrollToPosition(oldPosition)
recycler.layoutManager?.scrollToPosition(oldPosition)
}
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 {
when (item.itemId) {
R.id.action_display_mode -> swapDisplayMode()
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)
}
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.
*
@ -316,19 +342,22 @@ open class BrowseCatalogueController(bundle: Bundle) :
adapter.onLoadMoreComplete(null)
hideProgressBar()
val message = if (error is NoResultsException) "No results found" else (error.message ?: "")
snack?.dismiss()
snack = catalogue_view?.snack(message, Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_retry) {
// If not the first page, show bottom progress bar.
if (adapter.mainItemCount > 0) {
val item = progressItem ?: return@setAction
adapter.addScrollableFooterWithDelay(item, 0, true)
} else {
showProgressBar()
if (catalogue_view != null) {
val message = if (error is NoResultsException) catalogue_view.context.getString(R.string.no_results_found) else (error.message ?: "")
snack = catalogue_view.snack(message, Snackbar.LENGTH_INDEFINITE) {
setAction(R.string.action_retry) {
// If not the first page, show bottom progress bar.
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)
val categories = presenter.getCategories()
val defaultCategory = categories.find { it.id == preferences.defaultCategory() }
if (defaultCategory != null) {
presenter.moveMangaToCategory(manga, defaultCategory)
} else if (categories.size <= 1) { // default or the one from the user
presenter.moveMangaToCategory(manga, categories.firstOrNull())
} else {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
when {
defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory)
defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category
presenter.moveMangaToCategory(manga, null)
else -> {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
}
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> {
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)
((view as ViewGroup).getChildAt(1) as ViewGroup).addView(recycler)
addView(view)
title.text = context?.getString(R.string.source_search_options)
title.text = context.getString(R.string.source_search_options)
search_btn.setOnClickListener { onSearchClicked() }
reset_btn.setOnClickListener { onResetClicked() }
}

View File

@ -19,7 +19,7 @@ class CatalogueSearchAdapter(val controller: CatalogueSearchController) :
*/
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)
restoreHolderState(holder)
}
@ -38,7 +38,7 @@ class CatalogueSearchAdapter(val controller: CatalogueSearchController) :
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)
bundle = savedInstanceState.getBundle(HOLDER_BUNDLE_KEY)!!
}
/**

View File

@ -31,9 +31,6 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
// Set layout horizontal.
recycler.layoutManager = LinearLayoutManager(view.context, LinearLayoutManager.HORIZONTAL, false)
recycler.adapter = mangaAdapter
nothing_found_icon.setVectorCompat(R.drawable.ic_search_black_112dp,
view.context.getResourceColor(android.R.attr.textColorHint))
}
/**
@ -54,15 +51,15 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
when {
results == null -> {
progress.visible()
nothing_found.gone()
showHolder()
}
results.isEmpty() -> {
progress.gone()
nothing_found.visible()
hideHolder()
}
else -> {
progress.gone()
nothing_found.gone()
showHolder()
}
}
if (results !== lastBoundResults) {
@ -96,4 +93,15 @@ class CatalogueSearchHolder(view: View, val adapter: CatalogueSearchAdapter) :
return null
}
private fun showHolder() {
title.visible()
source_card.visible()
}
private fun hideHolder() {
title.gone()
source_card.gone()
}
}

View File

@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.LoginSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
@ -157,9 +158,9 @@ open class CatalogueSearchPresenter(
fetchSourcesSubscription?.unsubscribe()
fetchSourcesSubscription = Observable.from(sources)
.flatMap({ source ->
source.fetchSearchManga(1, query, FilterList())
Observable.defer { source.fetchSearchManga(1, query, FilterList()) }
.subscribeOn(Schedulers.io())
.onExceptionResumeNext(Observable.empty()) // Ignore timeouts.
.onErrorReturn { MangasPage(emptyList(), false) } // Ignore timeouts or other exceptions
.map { it.mangas.take(10) } // Get at most 10 manga from search result.
.map { it.map { networkToLocalManga(it, source.id) } } // Convert to local manga.
.doOnNext { fetchImage(it, source) } // Load manga covers.
@ -239,7 +240,7 @@ open class CatalogueSearchPresenter(
* @param sManga the manga from the source.
* @return a manga from the database.
*/
private fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
protected open fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
var localManga = db.getManga(sManga.url, sourceId).executeAsBlocking()
if (localManga == null) {
val newManga = Manga.create(sManga.url, sManga.title, sourceId)

View File

@ -1,10 +1,14 @@
package eu.kanade.tachiyomi.ui.extension
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.SearchView
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.View
import android.view.ViewGroup
import com.jakewharton.rxbinding.support.v4.widget.refreshes
import com.jakewharton.rxbinding.support.v7.widget.queryTextChanges
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
@ -28,6 +32,10 @@ open class ExtensionController : NucleusController<ExtensionPresenter>(),
*/
private var adapter: FlexibleAdapter<IFlexible<*>>? = null
private var extensions: List<ExtensionItem> = emptyList()
private var query = ""
init {
setHasOptionsMenu(true)
}
@ -84,6 +92,30 @@ open class ExtensionController : NucleusController<ExtensionPresenter>(),
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.extension_main, menu)
val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView
searchView.maxWidth = Int.MAX_VALUE
if (!query.isEmpty()) {
searchItem.expandActionView()
searchView.setQuery(query, true)
searchView.clearFocus()
}
searchView.queryTextChanges()
.filter { router.backstack.lastOrNull()?.controller() == this }
.subscribeUntilDestroy {
query = it.toString()
drawExtensions()
}
// Fixes problem with the overflow icon showing up in lieu of search
searchItem.fixExpand()
}
override fun onItemClick(position: Int): Boolean {
val extension = (adapter?.getItem(position) as? ExtensionItem)?.extension ?: return false
if (extension is Extension.Installed) {
@ -114,7 +146,19 @@ open class ExtensionController : NucleusController<ExtensionPresenter>(),
fun setExtensions(extensions: List<ExtensionItem>) {
ext_swipe_refresh?.isRefreshing = false
adapter?.updateDataSet(extensions)
this.extensions = extensions
drawExtensions()
}
fun drawExtensions() {
if (!query.isBlank()) {
adapter?.updateDataSet(
extensions.filter {
it.extension.name.contains(query, ignoreCase = true)
})
} else {
adapter?.updateDataSet(extensions)
}
}
fun downloadUpdate(item: ExtensionItem) {

View File

@ -47,7 +47,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
}
override fun createPresenter(): ExtensionDetailsPresenter {
return ExtensionDetailsPresenter(args.getString(PKGNAME_KEY))
return ExtensionDetailsPresenter(args.getString(PKGNAME_KEY)!!)
}
override fun getTitle(): String? {

View File

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

View File

@ -3,18 +3,14 @@ package eu.kanade.tachiyomi.ui.extension
import android.annotation.SuppressLint
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import kotlinx.android.synthetic.main.extension_card_header.*
import kotlinx.android.synthetic.main.extension_card_header.title
class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<*>) :
BaseFlexibleViewHolder(view, adapter) {
@SuppressLint("SetTextI18n")
fun bind(item: ExtensionGroupItem) {
title.text = when {
item.installed -> itemView.context.getString(R.string.ext_installed)
else -> itemView.context.getString(R.string.ext_available)
} + " (" + item.size + ")"
title.text = item.name
}
}

View File

@ -6,11 +6,12 @@ import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.kanade.tachiyomi.R
/**
* Item that contains the language header.
* Item that contains the group header.
*
* @param code The lang code.
* @param name The header name.
* @param size The number of items in the group.
*/
data class ExtensionGroupItem(val installed: Boolean, val size: Int) : AbstractHeaderItem<ExtensionGroupHolder>() {
data class ExtensionGroupItem(val name: String, val size: Int) : AbstractHeaderItem<ExtensionGroupHolder>() {
/**
* Returns the layout resource of this item.
@ -38,13 +39,13 @@ data class ExtensionGroupItem(val installed: Boolean, val size: Int) : AbstractH
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is ExtensionGroupItem) {
return installed == other.installed
return name == other.name
}
return false
}
override fun hashCode(): Int {
return installed.hashCode()
return name.hashCode()
}
}

View File

@ -1,10 +1,13 @@
package eu.kanade.tachiyomi.ui.extension
import android.app.Application
import android.os.Bundle
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.LocaleHelper
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
@ -49,6 +52,8 @@ open class ExtensionPresenter(
@Synchronized
private fun toItems(tuple: ExtensionTuple): List<ExtensionItem> {
val context = Injekt.get<Application>()
val (installed, untrusted, available) = tuple
val items = mutableListOf<ExtensionItem>()
@ -62,7 +67,7 @@ open class ExtensionPresenter(
.sortedBy { it.pkgName }
if (installedSorted.isNotEmpty() || untrustedSorted.isNotEmpty()) {
val header = ExtensionGroupItem(true, installedSorted.size + untrustedSorted.size)
val header = ExtensionGroupItem(context.getString(R.string.ext_installed), installedSorted.size + untrustedSorted.size)
items += installedSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
}
@ -71,10 +76,17 @@ open class ExtensionPresenter(
}
}
if (availableSorted.isNotEmpty()) {
val header = ExtensionGroupItem(false, availableSorted.size)
items += availableSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
}
val availableGroupedByLang = availableSorted
.groupBy { LocaleHelper.getDisplayName(it.lang, context) }
.toSortedMap()
availableGroupedByLang
.forEach {
val header = ExtensionGroupItem(it.key, it.value.size)
items += it.value.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
}
}
}
this.extensions = items

View File

@ -24,10 +24,10 @@ class ExtensionTrustDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
.positiveText(R.string.ext_trust)
.negativeText(R.string.ext_uninstall)
.onPositive { _, _ ->
(targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY))
(targetController as? Listener)?.trustSignature(args.getString(SIGNATURE_KEY)!!)
}
.onNegative { _, _ ->
(targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY))
(targetController as? Listener)?.uninstallExtension(args.getString(PKGNAME_KEY)!!)
}
.build()
}

View File

@ -185,7 +185,7 @@ class LibraryPresenter(
val sortFn: (LibraryItem, LibraryItem) -> Int = { i1, i2 ->
when (sortingMode) {
LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title)
LibrarySort.ALPHA -> i1.manga.title.compareTo(i2.manga.title, true)
LibrarySort.LAST_READ -> {
// Get index of manga, set equal to list if size unknown.
val manga1LastRead = lastReadManga[i1.manga.id!!] ?: lastReadManga.size

View File

@ -24,6 +24,7 @@ import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController
import eu.kanade.tachiyomi.ui.setting.SettingsMainController
import eu.kanade.tachiyomi.util.openInBrowser
import kotlinx.android.synthetic.main.main_activity.*
import uy.kohesive.injekt.injectLazy
@ -91,6 +92,9 @@ class MainActivity : BaseActivity() {
R.id.nav_drawer_settings -> {
router.pushController(SettingsMainController().withFadeTransaction())
}
R.id.nav_drawer_help -> {
openInBrowser(URL_HELP)
}
}
}
drawer.closeDrawer(GravityCompat.START)
@ -271,6 +275,8 @@ class MainActivity : BaseActivity() {
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_SEARCH_QUERY = "query"
const val INTENT_SEARCH_FILTER = "filter"
private const val URL_HELP = "https://tachiyomi.org/help/"
}
}

View File

@ -179,7 +179,6 @@ class MangaController : RxController, TabbedController {
}
companion object {
const val FROM_CATALOGUE_EXTRA = "from_catalogue"
const val MANGA_EXTRA = "manga"
@ -187,9 +186,8 @@ class MangaController : RxController, TabbedController {
const val CHAPTERS_CONTROLLER = 1
const val TRACK_CONTROLLER = 2
private val tabField = TabLayout.Tab::class.java.getDeclaredField("mView")
private val tabField = TabLayout.Tab::class.java.getDeclaredField("view")
.apply { isAccessible = true }
}
}

View File

@ -404,8 +404,12 @@ class ChaptersController : NucleusController<ChaptersPresenter>(),
presenter.deleteChapters(chapters)
}
fun onChaptersDeleted() {
fun onChaptersDeleted(chapters: List<ChapterItem>) {
dismissDeletingDialog()
//this is needed so the downloaded text gets removed from the item
chapters.forEach {
adapter?.updateItem(it)
}
adapter?.notifyDataSetChanged()
}

View File

@ -278,7 +278,7 @@ class ChaptersPresenter(
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
view.onChaptersDeleted()
view.onChaptersDeleted(chapters)
}, ChaptersController::onChaptersDeletedError)
}

View File

@ -42,6 +42,7 @@ import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.getResourceColor
import eu.kanade.tachiyomi.util.openInBrowser
import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.util.truncateCenter
@ -87,6 +88,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
// Set onclickListener to toggle favorite when FAB clicked.
fab_favorite.clicks().subscribeUntilDestroy { onFabClick() }
// Set onLongClickListener to manage categories when FAB is clicked.
fab_favorite.longClicks().subscribeUntilDestroy{ onFabLongClick() }
// Set SwipeRefresh to refresh manga data.
swipe_refresh.refreshes().subscribeUntilDestroy { fetchMangaFromSource() }
@ -287,15 +291,7 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
val context = view?.context ?: return
val source = presenter.source as? HttpSource ?: return
try {
val url = Uri.parse(source.mangaDetailsRequest(presenter.manga).url().toString())
val intent = CustomTabsIntent.Builder()
.setToolbarColor(context.getResourceColor(R.attr.colorPrimary))
.build()
intent.launchUrl(activity, url)
} catch (e: Exception) {
context.toast(e.message)
}
context.openInBrowser(source.mangaDetailsRequest(presenter.manga).url().toString())
}
private fun openInWebView() {
@ -386,11 +382,12 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
toggleFavorite()
if (manga.favorite) {
val categories = presenter.getCategories()
val defaultCategory = categories.find { it.id == preferences.defaultCategory() }
val defaultCategoryId = preferences.defaultCategory()
val defaultCategory = categories.find { it.id == defaultCategoryId }
when {
defaultCategory != null -> presenter.moveMangaToCategory(manga, defaultCategory)
categories.size <= 1 -> // default or the one from the user
presenter.moveMangaToCategory(manga, categories.firstOrNull())
defaultCategoryId == 0 || categories.isEmpty() -> // 'Default' or no category
presenter.moveMangaToCategory(manga, null)
else -> {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
@ -407,6 +404,30 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
}
}
/**
* Called when the fab is long clicked.
*/
private fun onFabLongClick() {
val manga = presenter.manga
if (!manga.favorite) {
toggleFavorite()
activity?.toast(activity?.getString(R.string.manga_added_library))
}
val categories = presenter.getCategories()
if (categories.isEmpty()) {
// no categories exist, display a message about adding categories
activity?.toast(activity?.getString(R.string.action_add_category))
} else {
val ids = presenter.getMangaCategoryIds(manga)
val preselected = ids.mapNotNull { id ->
categories.indexOfFirst { it.id == id }.takeIf { it != -1 }
}.toTypedArray()
ChangeMangaCategoriesDialog(this, listOf(manga), categories, preselected)
.showDialog(router)
}
}
override fun updateCategoriesForMangas(mangas: List<Manga>, categories: List<Category>) {
val manga = mangas.firstOrNull() ?: return
presenter.moveMangaToCategories(manga, categories)

View File

@ -130,9 +130,9 @@ class MangaInfoPresenter(
}
/**
* 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> {
return db.getCategories().executeAsBlocking()

View File

@ -1,9 +1,7 @@
package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.*
import android.webkit.WebView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.SourceManager
@ -16,6 +14,10 @@ class MangaWebViewController(bundle: Bundle? = null) : BaseController(bundle) {
private val sourceManager by injectLazy<SourceManager>()
init {
setHasOptionsMenu(true)
}
constructor(sourceId: Long, url: String) : this(Bundle().apply {
putLong(SOURCE_KEY, sourceId)
putString(URL_KEY, url)
@ -43,6 +45,40 @@ class MangaWebViewController(bundle: Bundle? = null) : BaseController(bundle) {
web.loadUrl(url, headers)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.web_view, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
val web = view as WebView
menu.findItem(R.id.action_forward).isVisible = web.canGoForward()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_forward -> {
val web = view as WebView
if (web.canGoForward()) web.goForward()
}
R.id.action_refresh -> {
val web = view as WebView
web.reload()
}
R.id.action_close -> router.popController(this)
else -> return super.onOptionsItemSelected(item)
}
return true
}
override fun handleBack(): Boolean {
val web = view as WebView
if (web.canGoBack()) {
web.goBack()
return true
}
return super.handleBack()
}
override fun onDestroyView(view: View) {
val web = view as WebView
web.stopLoading()

View File

@ -52,27 +52,27 @@ class TrackSearchAdapter(context: Context)
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.centerCrop()
.into(view.track_search_cover)
}
if (track.publishing_status.isNullOrBlank()) {
view.track_search_status.gone()
view.track_search_status_result.gone()
} else {
view.track_search_status_result.text = track.publishing_status.capitalize()
}
if (track.publishing_status.isNullOrBlank()) {
view.track_search_status.gone()
view.track_search_status_result.gone()
} else {
view.track_search_status_result.text = track.publishing_status.capitalize()
}
if (track.publishing_type.isNullOrBlank()) {
view.track_search_type.gone()
view.track_search_type_result.gone()
} else {
view.track_search_type_result.text = track.publishing_type.capitalize()
}
if (track.publishing_type.isNullOrBlank()) {
view.track_search_type.gone()
view.track_search_type_result.gone()
} else {
view.track_search_type_result.text = track.publishing_type.capitalize()
}
if (track.start_date.isNullOrBlank()) {
view.track_search_start.gone()
view.track_search_start_result.gone()
} else {
view.track_search_start_result.text = track.start_date
}
if (track.start_date.isNullOrBlank()) {
view.track_search_start.gone()
view.track_search_start_result.gone()
} else {
view.track_search_start_result.text = track.start_date
}
}
}

View File

@ -146,6 +146,9 @@ class MigrationPresenter(
}
manga.favorite = true
db.updateMangaFavorite(manga).executeAsBlocking()
// SearchPresenter#networkToLocalManga may have updated the manga title, so ensure db gets updated title
db.updateMangaTitle(manga).executeAsBlocking()
}
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.migration
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchCardItem
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchItem
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchPresenter
@ -21,4 +22,11 @@ class SearchPresenter(
//Set the catalogue search item as highlighted if the source matches that of the selected manga
return CatalogueSearchItem(source, results, source.id == manga.source)
}
override fun networkToLocalManga(sManga: SManga, sourceId: Long): Manga {
val localManga = super.networkToLocalManga(sManga, sourceId)
// For migration, displayed title should always match source rather than local DB
localManga.title = sManga.title
return localManga
}
}

View File

@ -5,7 +5,7 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import kotlinx.android.synthetic.main.catalogue_main_controller_card.*
import kotlinx.android.synthetic.main.catalogue_main_controller_card.title
/**
* Item that contains the selection header.
@ -36,7 +36,7 @@ class SelectionHeader : AbstractHeaderItem<SelectionHeader.Holder>() {
class Holder(view: View, adapter: FlexibleAdapter<*>) : BaseFlexibleViewHolder(view, adapter) {
init {
title.text = "Please select a source to migrate from"
title.text = view.context.getString(R.string.migration_selection_prompt)
}
}

View File

@ -119,8 +119,8 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
setContentView(R.layout.reader_activity)
if (presenter.needsInit()) {
val manga = intent.extras.getLong("manga", -1)
val chapter = intent.extras.getLong("chapter", -1)
val manga = intent.extras!!.getLong("manga", -1)
val chapter = intent.extras!!.getLong("chapter", -1)
if (manga == -1L || chapter == -1L) {
finish()
@ -574,6 +574,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
subscriptions += preferences.colorFilter().asObservable()
.subscribe { setColorFilter(it) }
subscriptions += preferences.colorFilterMode().asObservable()
.subscribe { setColorFilter(preferences.colorFilter().getOrDefault()) }
}
/**
@ -722,7 +725,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
*/
private fun setColorFilterValue(value: Int) {
color_overlay.visibility = View.VISIBLE
color_overlay.setBackgroundColor(value)
color_overlay.setFilterColor(value, preferences.colorFilterMode().getOrDefault())
}
}

View File

@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import eu.kanade.tachiyomi.widget.SimpleSeekBarListener
import kotlinx.android.synthetic.main.reader_color_filter.*
import kotlinx.android.synthetic.main.reader_color_filter_sheet.*
@ -54,6 +55,9 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ
subscriptions += preferences.colorFilter().asObservable()
.subscribe { setColorFilter(it, view) }
subscriptions += preferences.colorFilterMode().asObservable()
.subscribe { setColorFilter(preferences.colorFilter().getOrDefault(), view) }
subscriptions += preferences.customBrightness().asObservable()
.subscribe { setCustomBrightness(it, view) }
@ -84,6 +88,11 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ
preferences.customBrightness().set(isChecked)
}
color_filter_mode.onItemSelectedListener = IgnoreFirstSpinnerListener { position ->
preferences.colorFilterMode().set(position)
}
color_filter_mode.setSelection(preferences.colorFilterMode().getOrDefault(), false)
seekbar_color_filter_alpha.setOnSeekBarChangeListener(object : SimpleSeekBarListener() {
override fun onProgressChanged(seekBar: SeekBar, value: Int, fromUser: Boolean) {
if (fromUser) {
@ -248,7 +257,7 @@ class ReaderColorFilterSheet(activity: ReaderActivity) : BottomSheetDialog(activ
*/
private fun setColorFilterValue(@ColorInt color: Int, view: View) = with(view) {
color_overlay.visibility = View.VISIBLE
color_overlay.setBackgroundColor(color)
color_overlay.setFilterColor(color, preferences.colorFilterMode().getOrDefault())
setValues(color, view)
}

View File

@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.ui.reader
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.View
class ReaderColorFilterView(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val colorFilterPaint: Paint = Paint()
fun setFilterColor(color: Int, filterMode: Int) {
colorFilterPaint.setColor(color)
colorFilterPaint.xfermode = PorterDuffXfermode(when (filterMode) {
1 -> PorterDuff.Mode.MULTIPLY
2 -> PorterDuff.Mode.SCREEN
3 -> PorterDuff.Mode.OVERLAY
4 -> PorterDuff.Mode.LIGHTEN
5 -> PorterDuff.Mode.DARKEN
else -> PorterDuff.Mode.SRC_OVER
})
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawPaint(colorFilterPaint)
}
}

View File

@ -147,10 +147,9 @@ class ReaderPresenter(
/**
* Called when the user pressed the back button and is going to leave the reader. Used to
* update tracking services and trigger deletion of the downloaded chapters.
* trigger deletion of the downloaded chapters.
*/
fun onBackPressed() {
updateTrackLastChapterRead()
deletePendingChapters()
}
@ -308,7 +307,7 @@ class ReaderPresenter(
/**
* Called every time a page changes on the reader. Used to mark the flag of chapters being
* read, enqueue downloaded chapter deletion, and updating the active chapter if this
* read, update tracking services, enqueue downloaded chapter deletion, and updating the active chapter if this
* [page]'s chapter is different from the currently active.
*/
fun onPageSelected(page: ReaderPage) {
@ -320,6 +319,7 @@ class ReaderPresenter(
selectedChapter.chapter.last_page_read = page.index
if (selectedChapter.pages?.lastIndex == page.index) {
selectedChapter.chapter.read = true
updateTrackChapterRead(selectedChapter)
enqueueDeleteReadChapters(selectedChapter)
}
@ -434,7 +434,8 @@ class ReaderPresenter(
// Build destination file.
val filename = DiskUtil.buildValidFilename(
"${manga.title} - ${chapter.name}") + " - ${page.number}.${type.extension}"
"${manga.title} - ${chapter.name}".take(225)
) + " - ${page.number}.${type.extension}"
val destFile = File(directory, filename)
stream().use { input ->
@ -497,7 +498,7 @@ class ReaderPresenter(
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onShareImageResult(file) },
{ view, error -> /* Empty */ }
{ _, _ -> /* Empty */ }
)
}
@ -553,21 +554,11 @@ class ReaderPresenter(
* Starts the service that updates the last chapter read in sync services. This operation
* will run in a background thread and errors are ignored.
*/
private fun updateTrackLastChapterRead() {
private fun updateTrackChapterRead(readerChapter: ReaderChapter) {
if (!preferences.autoUpdateTrack()) return
val viewerChapters = viewerChaptersRelay.value ?: return
val manga = manga ?: return
val currChapter = viewerChapters.currChapter.chapter
val prevChapter = viewerChapters.prevChapter?.chapter
// Get the last chapter read from the reader.
val lastChapterRead = if (currChapter.read)
currChapter.chapter_number.toInt()
else if (prevChapter != null && prevChapter.read)
prevChapter.chapter_number.toInt()
else
return
val chapterRead = readerChapter.chapter.chapter_number.toInt()
val trackManager = Injekt.get<TrackManager>()
@ -575,8 +566,8 @@ class ReaderPresenter(
.flatMapCompletable { trackList ->
Completable.concat(trackList.map { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && lastChapterRead > track.last_chapter_read) {
track.last_chapter_read = lastChapterRead
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
track.last_chapter_read = chapterRead
// We wan't these to execute even if the presenter is destroyed and leaks
// for a while. The view can still be garbage collected.

View File

@ -2,8 +2,8 @@ package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator
import eu.kanade.tachiyomi.util.ImageUtil
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.io.FileInputStream
@ -18,11 +18,9 @@ class DirectoryPageLoader(val file: File) : PageLoader() {
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return file.listFiles()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.sortedWith(Comparator<File> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) })
.mapIndexed { i, file ->
val streamFn = { FileInputStream(file) }
ReaderPage(i).apply {

View File

@ -32,9 +32,9 @@ class DownloadPageLoader(
return downloadManager.buildPageList(source, manga, chapter.chapter)
.map { pages ->
pages.map { page ->
ReaderPage(page.index, page.url, page.imageUrl, {
ReaderPage(page.index, page.url, page.imageUrl) {
context.contentResolver.openInputStream(page.uri)
}).apply {
}.apply {
status = Page.READY
}
}

View File

@ -17,6 +17,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.concurrent.PriorityBlockingQueue
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.min
/**
* Loader used to load chapters from an online source.
@ -37,18 +38,20 @@ class HttpPageLoader(
*/
private val subscriptions = CompositeSubscription()
private val preloadSize = 4
init {
subscriptions += Observable.defer { Observable.just(queue.take().page) }
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
.filter { it.status == Page.QUEUE }
.concatMap { source.fetchImageFromCacheThenNet(it) }
.repeat()
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
if (error !is InterruptedException) {
Timber.e(error)
}
})
}
/**
@ -80,13 +83,13 @@ class HttpPageLoader(
*/
override fun getPages(): Observable<List<ReaderPage>> {
return chapterCache
.getPageListFromCache(chapter.chapter)
.onErrorResumeNext { source.fetchPageList(chapter.chapter) }
.map { pages ->
pages.mapIndexed { index, page -> // Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl)
.getPageListFromCache(chapter.chapter)
.onErrorResumeNext { source.fetchPageList(chapter.chapter) }
.map { pages ->
pages.mapIndexed { index, page -> // Don't trust sources and use our own indexing
ReaderPage(index, page.url, page.imageUrl)
}
}
}
}
/**
@ -110,29 +113,41 @@ class HttpPageLoader(
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
val queuedPages = mutableListOf<PriorityPage>()
if (page.status == Page.QUEUE) {
queue.offer(PriorityPage(page, 1))
queuedPages += PriorityPage(page, 1).also { queue.offer(it) }
}
preloadNextPages(page, 4)
queuedPages += preloadNextPages(page, preloadSize)
statusSubject.startWith(page.status)
.doOnUnsubscribe {
queuedPages.forEach {
if (it.page.status == Page.QUEUE) {
queue.remove(it)
}
}
}
}
.subscribeOn(Schedulers.io())
.unsubscribeOn(Schedulers.io())
}
/**
* Preloads the given [amount] of pages after the [currentPage] with a lower priority.
* @return a list of [PriorityPage] that were added to the [queue]
*/
private fun preloadNextPages(currentPage: ReaderPage, amount: Int) {
private fun preloadNextPages(currentPage: ReaderPage, amount: Int): List<PriorityPage> {
val pageIndex = currentPage.index
val pages = currentPage.chapter.pages ?: return
if (pageIndex == pages.lastIndex) return
val nextPages = pages.subList(pageIndex + 1, Math.min(pageIndex + 1 + amount, pages.size))
for (nextPage in nextPages) {
if (nextPage.status == Page.QUEUE) {
queue.offer(PriorityPage(nextPage, 0))
}
}
val pages = currentPage.chapter.pages ?: return emptyList()
if (pageIndex == pages.lastIndex) return emptyList()
return pages
.subList(pageIndex + 1, min(pageIndex + 1 + amount, pages.size))
.mapNotNull {
if (it.status == Page.QUEUE) {
PriorityPage(it, 0).apply { queue.offer(this) }
} else null
}
}
/**
@ -148,7 +163,7 @@ class HttpPageLoader(
/**
* Data class used to keep ordering of pages in order to maintain priority.
*/
private data class PriorityPage(
private class PriorityPage(
val page: ReaderPage,
val priority: Int
): Comparable<PriorityPage> {

View File

@ -2,10 +2,10 @@ package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator
import eu.kanade.tachiyomi.util.ImageUtil
import junrar.Archive
import junrar.rarfile.FileHeader
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.io.InputStream
@ -42,11 +42,9 @@ class RarPageLoader(file: File) : PageLoader() {
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
.sortedWith(Comparator<FileHeader> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.fileNameString, f2.fileNameString) })
.mapIndexed { i, header ->
val streamFn = { getStream(header) }

View File

@ -2,8 +2,8 @@ package eu.kanade.tachiyomi.ui.reader.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator
import eu.kanade.tachiyomi.util.ImageUtil
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import rx.Observable
import java.io.File
import java.util.zip.ZipEntry
@ -32,11 +32,9 @@ class ZipPageLoader(file: File) : PageLoader() {
* comparator.
*/
override fun getPages(): Observable<List<ReaderPage>> {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
.sortedWith(Comparator<ZipEntry> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) })
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply {

View File

@ -19,6 +19,7 @@ import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.load.resource.gif.GifDrawable
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.NoTransition
@ -457,6 +458,9 @@ class PagerPageHolder(
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
if (resource is GifDrawable) {
resource.setLoopCount(GifDrawable.LOOP_INTRINSIC)
}
onImageDecoded()
return false
}

View File

@ -46,6 +46,7 @@ class PagerTransitionHolder(
* Text view used to display the text of the current and next/prev chapters.
*/
private var textView = TextView(context).apply {
textSize = 17.5F
wrapContent()
}

View File

@ -70,14 +70,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
pager.adapter = adapter
pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
val page = adapter.items.getOrNull(position)
if (page != null && currentPage != page) {
currentPage = page
when (page) {
is ReaderPage -> onPageSelected(page, position)
is ChapterTransition -> onTransitionSelected(page)
}
}
onPageChange(position)
}
override fun onPageScrollStateChanged(state: Int) {
@ -129,25 +122,38 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
}
/**
* Called from the ViewPager listener when a [page] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
* Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active
*/
private fun onPageSelected(page: ReaderPage, position: Int) {
val pages = page.chapter.pages!! // Won't be null because it's the loaded chapter
Timber.d("onPageSelected: ${page.number}/${pages.size}")
activity.onPageSelected(page)
if (page === pages.last()) {
Timber.d("Request preload next chapter because we're at the last page")
val transition = adapter.items.getOrNull(position + 1) as? ChapterTransition.Next
if (transition?.to != null) {
activity.requestPreloadChapter(transition.to)
private fun onPageChange(position: Int) {
val page = adapter.items.getOrNull(position)
if (page != null && currentPage != page) {
currentPage = page
when (page) {
is ReaderPage -> onReaderPageSelected(page)
is ChapterTransition -> onTransitionSelected(page)
}
}
}
/**
* Called from the ViewPager listener when a [transition] is marked as active. It request the
* Called when a [ReaderPage] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onReaderPageSelected(page: ReaderPage) {
val pages = page.chapter.pages!! // Won't be null because it's the loaded chapter
Timber.d("onReaderPageSelected: ${page.number}/${pages.size}")
activity.onPageSelected(page)
if (page === pages.last()) {
Timber.d("Request preload next chapter because we're at the last page")
adapter.nextTransition?.to?.let {
activity.requestPreloadChapter(it)
}
}
}
/**
* Called when a [ChapterTransition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
@ -194,10 +200,15 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
* Tells this viewer to move to the given [page].
*/
override fun moveToPage(page: ReaderPage) {
Timber.d("moveToPage")
Timber.d("moveToPage ${page.number}")
val position = adapter.items.indexOf(page)
if (position != -1) {
val currentPosition = pager.currentItem
pager.setCurrentItem(position, true)
// manually call onPageChange since ViewPager listener is not triggered in this case
if (currentPosition == position) {
onPageChange(position)
}
} else {
Timber.d("Page $page not found in adapter")
}

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