mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-28 03:57:50 +02:00
Compare commits
157 Commits
Author | SHA1 | Date | |
---|---|---|---|
d971768056 | |||
2e39be6625 | |||
f514d466a6 | |||
d10bf45283 | |||
a0064a1699 | |||
907472403d | |||
a9b6db9ee9 | |||
3e1dc9f400 | |||
d30c019b89 | |||
86b8712dd1 | |||
44241e03da | |||
12dcc2c31f | |||
bb89b72a81 | |||
ea790faeb3 | |||
4ef7b16925 | |||
93e244b4c4 | |||
87281d34c1 | |||
20041701cd | |||
f9c5379400 | |||
2a531f1a1e | |||
4d4b9c0d6d | |||
dc592e92b5 | |||
0db1a3167d | |||
830f792824 | |||
a13ebc3975 | |||
b28ef61618 | |||
6f297161de | |||
59c626b4a8 | |||
1d014a5a94 | |||
2dc8159d96 | |||
453f742732 | |||
5e6cf9fb02 | |||
83349fc72d | |||
979a5c8c16 | |||
9f625835ec | |||
5fd379e71b | |||
9c5b497751 | |||
4dc5f3e7d9 | |||
13954ffe01 | |||
36d4e1f7ef | |||
b716a2f8ac | |||
f98095e6cb | |||
d183aca810 | |||
52f4bddbce | |||
b837424f29 | |||
ba2a8c82f8 | |||
2856d9d6a3 | |||
71fac76e3d | |||
125f1ae34c | |||
b418169c20 | |||
f4d12ba622 | |||
c64d8c8b6b | |||
10a1ba95d6 | |||
27d3daf918 | |||
dcbd72e64d | |||
52e1e93f9d | |||
7d3d0999f3 | |||
93f90b5a62 | |||
c2b113ac0a | |||
8ff8ab4f27 | |||
414b8c9f21 | |||
4975787afa | |||
1210691fdd | |||
2a4527a8d6 | |||
2991906a85 | |||
5b1f4f189b | |||
d77a1e6925 | |||
19c713ebb2 | |||
90e0e0b72a | |||
22bbcaeed0 | |||
d7b8015df7 | |||
c1ac47e1ce | |||
e375101132 | |||
05b14bae7b | |||
eb15fe3898 | |||
4f5518bdd8 | |||
c9e1e6e020 | |||
ade73e6892 | |||
ee2aae7e3a | |||
b6011d4cf5 | |||
a31c6ff875 | |||
69baaac27e | |||
b16a90e9d9 | |||
f31aa622c0 | |||
4578edf157 | |||
33df35db1b | |||
093ddd776b | |||
da10b27219 | |||
5b4ed6f926 | |||
8fc467652d | |||
7971b64d57 | |||
9c1e2c3c45 | |||
909917e133 | |||
3b6c37a30b | |||
4a6e2a5d99 | |||
6cf84256fe | |||
876831480a | |||
aebc9a3b9e | |||
7b28614c37 | |||
4524c705da | |||
1f70be688a | |||
500eedaab7 | |||
2d2ff0a29d | |||
6d0689fe6c | |||
0b3dda18d3 | |||
09a8a494a0 | |||
11ac4df5d7 | |||
d352405ba6 | |||
a81609fd2c | |||
bf05952582 | |||
596a24fce8 | |||
9f20e40257 | |||
8be67a4431 | |||
58a2f7a874 | |||
cb92143613 | |||
08e26aa30d | |||
8e3ffe87b8 | |||
20e2bf9682 | |||
8512f97386 | |||
3ce880bc62 | |||
72ae243fa2 | |||
91829b0e7d | |||
7c3cd10696 | |||
24bdee626f | |||
6a30a75e3e | |||
ccdc336112 | |||
a4b71f4d11 | |||
c3f61e86b7 | |||
d8d93ee344 | |||
8ffff44454 | |||
568b90d0b4 | |||
46e09d174b | |||
1698a85e99 | |||
c9b62209c2 | |||
b280d6a76b | |||
29993e6412 | |||
2a5edf4547 | |||
d58c517a6c | |||
50136c319f | |||
2fb3b50535 | |||
4171e87b4b | |||
60b3036037 | |||
dfb2487640 | |||
97454ca162 | |||
4200409f79 | |||
b6a06189fb | |||
be521804c8 | |||
e95fcf6172 | |||
fbd2235a51 | |||
31b1b83606 | |||
a5d4f63281 | |||
328f9a70d3 | |||
df2b1dbeb1 | |||
f768393a4b | |||
c0a0d60c87 | |||
9cf5a4cac0 | |||
f21a030cf8 |
@ -5,17 +5,20 @@ android:
|
|||||||
- tools
|
- tools
|
||||||
|
|
||||||
# The BuildTools version used by your project
|
# The BuildTools version used by your project
|
||||||
- build-tools-23.0.3
|
- build-tools-25.0.1
|
||||||
- android-23
|
- android-25
|
||||||
- extra-android-m2repository
|
- extra-android-m2repository
|
||||||
- extra-google-m2repository
|
- extra-google-m2repository
|
||||||
- extra-android-support
|
- extra-android-support
|
||||||
- extra-google-google_play_services
|
- extra-google-google_play_services
|
||||||
|
|
||||||
|
jdk:
|
||||||
|
- oraclejdk8
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- chmod +x gradlew
|
- chmod +x gradlew
|
||||||
#Build, and run tests
|
#Build, and run tests
|
||||||
script: "./gradlew clean buildDebug"
|
script: "./gradlew clean buildStandardDebug"
|
||||||
sudo: false
|
sudo: false
|
||||||
|
|
||||||
before_cache:
|
before_cache:
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
| Build | Download | Auto Update |
|
| Build | Download | Auto Update |
|
||||||
|-------|----------|-------------|
|
|-------|----------|-------------|
|
||||||
| [](https://teamcity.kanade.eu/project.html?projectId=tachiyomi) [](https://travis-ci.org/inorichi/tachiyomi) | [](https://github.com/inorichi/tachiyomi/releases) [](http://tachiyomi.kanade.eu/latest/app-debug.apk) | [](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [](//github.com/inorichi/tachiyomi/wiki/FDroid-for-debug-versions) |
|
| [](https://teamcity.kanade.eu/project.html?projectId=tachiyomi) [](https://travis-ci.org/inorichi/tachiyomi) | [](https://github.com/inorichi/tachiyomi/releases) [](http://tachiyomi.kanade.eu/latest/app-debug.apk) | [](https://f-droid.org/repository/browse/?fdid=eu.kanade.tachiyomi) [](//github.com/inorichi/tachiyomi/wiki/FDroid-for-dev-versions) |
|
||||||
|
|
||||||
## [Report an issue](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md)
|
## [Report an issue](https://github.com/inorichi/tachiyomi/blob/master/.github/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
2
app/.gitignore
vendored
2
app/.gitignore
vendored
@ -1,4 +1,4 @@
|
|||||||
/build
|
/build
|
||||||
*iml
|
*iml
|
||||||
*.iml
|
*.iml
|
||||||
.idea
|
custom.gradle
|
@ -4,6 +4,10 @@ apply plugin: 'com.android.application'
|
|||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
apply plugin: 'kotlin-android-extensions'
|
apply plugin: 'kotlin-android-extensions'
|
||||||
|
|
||||||
|
if (file("custom.gradle").exists()) {
|
||||||
|
apply from: "custom.gradle"
|
||||||
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
// Git is needed in your system PATH for these commands to work.
|
// Git is needed in your system PATH for these commands to work.
|
||||||
// If it's not installed, you can return a random value as a workaround
|
// If it's not installed, you can return a random value as a workaround
|
||||||
@ -24,43 +28,54 @@ ext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def includeUpdater() {
|
|
||||||
return hasProperty("include_updater")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 23
|
compileSdkVersion 25
|
||||||
buildToolsVersion "23.0.3"
|
buildToolsVersion "25.0.1"
|
||||||
publishNonDefault true
|
publishNonDefault true
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "eu.kanade.tachiyomi"
|
applicationId "eu.kanade.tachiyomi"
|
||||||
minSdkVersion 16
|
minSdkVersion 16
|
||||||
targetSdkVersion 23
|
targetSdkVersion 25
|
||||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||||
versionCode 10
|
versionCode 16
|
||||||
versionName "0.2.3"
|
versionName "0.4.0"
|
||||||
|
|
||||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||||
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
|
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
|
||||||
buildConfigField "boolean", "INCLUDE_UPDATER", "${includeUpdater()}"
|
|
||||||
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
abiFilters "armeabi", "armeabi-v7a", "x86"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
versionNameSuffix "-${getCommitCount()}"
|
versionNameSuffix "-${getCommitCount()}"
|
||||||
applicationIdSuffix ".debug"
|
applicationIdSuffix ".debug"
|
||||||
|
multiDexEnabled true
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
shrinkResources true
|
shrinkResources true
|
||||||
|
multiDexEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
productFlavors {
|
||||||
|
standard {
|
||||||
|
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
fdroid {
|
||||||
|
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
exclude 'META-INF/DEPENDENCIES'
|
exclude 'META-INF/DEPENDENCIES'
|
||||||
exclude 'LICENSE.txt'
|
exclude 'LICENSE.txt'
|
||||||
@ -83,11 +98,10 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
// Modified dependencies
|
// Modified dependencies
|
||||||
compile 'com.github.inorichi:subsampling-scale-image-view:421fb81'
|
compile 'com.github.inorichi:subsampling-scale-image-view:f687b74'
|
||||||
compile 'com.github.inorichi:ReactiveNetwork:69092ed'
|
|
||||||
|
|
||||||
// Android support library
|
// Android support library
|
||||||
final support_library_version = '23.4.0'
|
final support_library_version = '25.0.1'
|
||||||
compile "com.android.support:support-v4:$support_library_version"
|
compile "com.android.support:support-v4:$support_library_version"
|
||||||
compile "com.android.support:appcompat-v7:$support_library_version"
|
compile "com.android.support:appcompat-v7:$support_library_version"
|
||||||
compile "com.android.support:cardview-v7:$support_library_version"
|
compile "com.android.support:cardview-v7:$support_library_version"
|
||||||
@ -96,13 +110,18 @@ dependencies {
|
|||||||
compile "com.android.support:support-annotations:$support_library_version"
|
compile "com.android.support:support-annotations:$support_library_version"
|
||||||
compile "com.android.support:customtabs:$support_library_version"
|
compile "com.android.support:customtabs:$support_library_version"
|
||||||
|
|
||||||
|
compile 'com.android.support:multidex:1.0.1'
|
||||||
|
|
||||||
// ReactiveX
|
// ReactiveX
|
||||||
compile 'io.reactivex:rxandroid:1.2.1'
|
compile 'io.reactivex:rxandroid:1.2.1'
|
||||||
compile 'io.reactivex:rxjava:1.1.6'
|
compile 'io.reactivex:rxjava:1.2.3'
|
||||||
|
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||||
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||||
|
compile 'com.github.pwittchen:reactivenetwork:0.6.0'
|
||||||
|
|
||||||
// Network client
|
// Network client
|
||||||
compile "com.squareup.okhttp3:okhttp:3.3.1"
|
compile "com.squareup.okhttp3:okhttp:3.5.0"
|
||||||
|
compile 'com.squareup.okio:okio:1.11.0'
|
||||||
|
|
||||||
// REST
|
// REST
|
||||||
final retrofit_version = '2.1.0'
|
final retrofit_version = '2.1.0'
|
||||||
@ -110,30 +129,32 @@ dependencies {
|
|||||||
compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
|
||||||
compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
|
compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
|
||||||
|
|
||||||
// IO
|
|
||||||
compile 'com.squareup.okio:okio:1.8.0'
|
|
||||||
|
|
||||||
// JSON
|
// JSON
|
||||||
compile 'com.google.code.gson:gson:2.7'
|
compile 'com.google.code.gson:gson:2.8.0'
|
||||||
compile 'com.github.salomonbrys.kotson:kotson:2.3.0'
|
compile 'com.github.salomonbrys.kotson:kotson:2.4.0'
|
||||||
|
|
||||||
// YAML
|
// YAML
|
||||||
compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
|
compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
|
||||||
|
|
||||||
// JavaScript engine
|
// JavaScript engine
|
||||||
compile 'com.squareup.duktape:duktape-android:0.9.5'
|
compile 'com.squareup.duktape:duktape-android:1.1.0'
|
||||||
|
|
||||||
// Disk cache
|
// Disk
|
||||||
compile 'com.jakewharton:disklrucache:2.0.2'
|
compile 'com.jakewharton:disklrucache:2.0.2'
|
||||||
|
compile 'com.github.seven332:unifile:1.0.0'
|
||||||
|
|
||||||
// Parse HTML
|
// HTML parser
|
||||||
compile 'org.jsoup:jsoup:1.9.2'
|
compile 'org.jsoup:jsoup:1.10.1'
|
||||||
|
|
||||||
|
// Job scheduling
|
||||||
|
compile 'com.evernote:android-job:1.1.3'
|
||||||
|
compile 'com.google.android.gms:play-services-gcm:10.0.1'
|
||||||
|
|
||||||
// Changelog
|
// Changelog
|
||||||
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
compile "com.pushtorefresh.storio:sqlite:1.9.0"
|
compile "com.pushtorefresh.storio:sqlite:1.11.0"
|
||||||
|
|
||||||
// Model View Presenter
|
// Model View Presenter
|
||||||
final nucleus_version = '3.0.0'
|
final nucleus_version = '3.0.0'
|
||||||
@ -147,35 +168,41 @@ dependencies {
|
|||||||
// Image library
|
// Image library
|
||||||
compile 'com.github.bumptech.glide:glide:3.7.0'
|
compile 'com.github.bumptech.glide:glide:3.7.0'
|
||||||
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
|
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
|
||||||
|
// Transformations
|
||||||
|
compile 'jp.wasabeef:glide-transformations:2.0.1'
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
compile 'com.jakewharton.timber:timber:4.1.2'
|
compile 'com.jakewharton.timber:timber:4.3.1'
|
||||||
|
|
||||||
// Crash reports
|
// Crash reports
|
||||||
compile 'ch.acra:acra:4.9.0'
|
compile 'ch.acra:acra:4.9.1'
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
compile 'com.dmitrymalkovich.android:material-design-dimens:1.2'
|
compile 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||||
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||||
compile 'eu.davidea:flexible-adapter:4.2.0'
|
compile 'eu.davidea:flexible-adapter:4.2.0'
|
||||||
compile 'com.nononsenseapps:filepicker:2.5.2'
|
compile 'com.nononsenseapps:filepicker:2.5.2'
|
||||||
compile 'com.github.amulyakhare:TextDrawable:558677e'
|
compile 'com.github.amulyakhare:TextDrawable:558677e'
|
||||||
compile 'com.afollestad.material-dialogs:core:0.8.6.1'
|
compile 'com.afollestad.material-dialogs:core:0.9.1.0'
|
||||||
compile 'net.xpece.android:support-preference:0.8.1'
|
compile 'net.xpece.android:support-preference:1.2.0'
|
||||||
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
||||||
compile 'org.adw.library:discrete-seekbar:1.0.1'
|
compile 'de.hdodenhof:circleimageview:2.1.0'
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
testCompile 'junit:junit:4.12'
|
testCompile 'junit:junit:4.12'
|
||||||
testCompile 'org.assertj:assertj-core:1.7.1'
|
testCompile 'org.assertj:assertj-core:1.7.1'
|
||||||
testCompile 'org.mockito:mockito-core:1.10.19'
|
testCompile 'org.mockito:mockito-core:1.10.19'
|
||||||
testCompile 'org.robolectric:robolectric:3.1'
|
|
||||||
|
final robolectric_version = '3.1.4'
|
||||||
|
testCompile "org.robolectric:robolectric:$robolectric_version"
|
||||||
|
testCompile "org.robolectric:shadows-multidex:$robolectric_version"
|
||||||
|
testCompile "org.robolectric:shadows-play-services:$robolectric_version"
|
||||||
|
|
||||||
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.0.3'
|
ext.kotlin_version = '1.0.5-2'
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
63
app/proguard-rules.pro
vendored
63
app/proguard-rules.pro
vendored
@ -1,5 +1,10 @@
|
|||||||
-dontobfuscate
|
-dontobfuscate
|
||||||
|
|
||||||
|
-keep class eu.kanade.tachiyomi.**
|
||||||
|
|
||||||
|
-keep class com.hippo.image.** { *; }
|
||||||
|
-keep interface com.hippo.image.** { *; }
|
||||||
|
|
||||||
# OkHttp
|
# OkHttp
|
||||||
-keepattributes Signature
|
-keepattributes Signature
|
||||||
-keepattributes *Annotation*
|
-keepattributes *Annotation*
|
||||||
@ -59,7 +64,10 @@
|
|||||||
public <init>(android.content.Context);
|
public <init>(android.content.Context);
|
||||||
}
|
}
|
||||||
|
|
||||||
## GSON 2.2.4 specific rules ##
|
# ReactiveNetwork
|
||||||
|
-dontwarn com.github.pwittchen.reactivenetwork.**
|
||||||
|
|
||||||
|
## GSON ##
|
||||||
|
|
||||||
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
# Gson uses generic type information stored in a class file when working with fields. Proguard
|
||||||
# removes such information by default, so configure it to keep all of it.
|
# removes such information by default, so configure it to keep all of it.
|
||||||
@ -68,55 +76,18 @@
|
|||||||
# For using GSON @Expose annotation
|
# For using GSON @Expose annotation
|
||||||
-keepattributes *Annotation*
|
-keepattributes *Annotation*
|
||||||
|
|
||||||
-keepattributes EnclosingMethod
|
|
||||||
|
|
||||||
# Gson specific classes
|
# Gson specific classes
|
||||||
-keep class sun.misc.Unsafe { *; }
|
-keep class sun.misc.Unsafe { *; }
|
||||||
-keep class com.google.gson.stream.** { *; }
|
#-keep class com.google.gson.stream.** { *; }
|
||||||
|
|
||||||
## ACRA 4.5.0 specific rules ##
|
# Application classes that will be serialized/deserialized over Gson
|
||||||
|
-keep class com.google.gson.examples.android.model.** { *; }
|
||||||
|
|
||||||
# we need line numbers in our stack traces otherwise they are pretty useless
|
# Prevent proguard from stripping interface information from TypeAdapterFactory,
|
||||||
-renamesourcefileattribute SourceFile
|
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keep class * implements com.google.gson.TypeAdapterFactory
|
||||||
|
-keep class * implements com.google.gson.JsonSerializer
|
||||||
# ACRA needs "annotations" so add this...
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
-keepattributes *Annotation*
|
|
||||||
|
|
||||||
# keep this class so that logging will show 'ACRA' and not a obfuscated name like 'a'.
|
|
||||||
# Note: if you are removing log messages elsewhere in this file then this isn't necessary
|
|
||||||
-keep class org.acra.ACRA {
|
|
||||||
*;
|
|
||||||
}
|
|
||||||
|
|
||||||
# keep this around for some enums that ACRA needs
|
|
||||||
-keep class org.acra.ReportingInteractionMode {
|
|
||||||
*;
|
|
||||||
}
|
|
||||||
|
|
||||||
-keepnames class org.acra.sender.HttpSender$** {
|
|
||||||
*;
|
|
||||||
}
|
|
||||||
|
|
||||||
-keepnames class org.acra.ReportField {
|
|
||||||
*;
|
|
||||||
}
|
|
||||||
|
|
||||||
# keep this otherwise it is removed by ProGuard
|
|
||||||
-keep public class org.acra.ErrorReporter {
|
|
||||||
public void addCustomData(java.lang.String,java.lang.String);
|
|
||||||
public void putCustomData(java.lang.String,java.lang.String);
|
|
||||||
public void removeCustomData(java.lang.String);
|
|
||||||
}
|
|
||||||
|
|
||||||
# keep this otherwise it is removed by ProGuard
|
|
||||||
-keep public class org.acra.ErrorReporter {
|
|
||||||
public void handleSilentException(java.lang.Throwable);
|
|
||||||
}
|
|
||||||
|
|
||||||
# Keep the support library
|
|
||||||
-keep class org.acra.** { *; }
|
|
||||||
-keep interface org.acra.** { *; }
|
|
||||||
|
|
||||||
# SnakeYaml
|
# SnakeYaml
|
||||||
-keep class org.yaml.snakeyaml.** { public protected private *; }
|
-keep class org.yaml.snakeyaml.** { public protected private *; }
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="eu.kanade.tachiyomi">
|
package="eu.kanade.tachiyomi">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
@ -8,6 +10,8 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" />
|
||||||
|
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
@ -18,8 +22,7 @@
|
|||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
android:theme="@style/Theme.Tachiyomi" >
|
android:theme="@style/Theme.Tachiyomi" >
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.main.MainActivity"
|
android:name=".ui.main.MainActivity">
|
||||||
android:theme="@style/Theme.BrandedLaunch">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
@ -28,7 +31,8 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.manga.MangaActivity"
|
android:name=".ui.manga.MangaActivity"
|
||||||
android:parentActivityName=".ui.main.MainActivity" >
|
android:parentActivityName=".ui.main.MainActivity"
|
||||||
|
android:exported="true">
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.reader.ReaderActivity"
|
android:name=".ui.reader.ReaderActivity"
|
||||||
@ -50,6 +54,16 @@
|
|||||||
android:theme="@style/FilePickerTheme">
|
android:theme="@style/FilePickerTheme">
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="android.support.v4.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.provider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/provider_paths"/>
|
||||||
|
</provider>
|
||||||
|
|
||||||
<service android:name=".data.library.LibraryUpdateService"
|
<service android:name=".data.library.LibraryUpdateService"
|
||||||
android:exported="false"/>
|
android:exported="false"/>
|
||||||
|
|
||||||
@ -59,46 +73,14 @@
|
|||||||
<service android:name=".data.mangasync.UpdateMangaSyncService"
|
<service android:name=".data.mangasync.UpdateMangaSyncService"
|
||||||
android:exported="false"/>
|
android:exported="false"/>
|
||||||
|
|
||||||
<receiver
|
<service android:name=".data.updater.UpdateDownloaderService"
|
||||||
android:name=".data.library.LibraryUpdateService$SyncOnConnectionAvailable"
|
android:exported="false"/>
|
||||||
android:enabled="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<receiver
|
<receiver android:name=".data.updater.UpdateNotificationReceiver"/>
|
||||||
android:name=".data.library.LibraryUpdateService$SyncOnPowerConnected"
|
|
||||||
android:enabled="false">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.ACTION_POWER_CONNECTED" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<receiver
|
<receiver android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver" />
|
||||||
android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver">
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".data.updater.UpdateDownloader$InstallOnReceived">
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".data.library.LibraryUpdateAlarm">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
|
||||||
<action android:name="eu.kanade.UPDATE_LIBRARY" />
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".data.updater.UpdateDownloaderAlarm">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
|
||||||
<action android:name="eu.kanade.CHECK_UPDATE"/>
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
|
<receiver android:name=".ui.reader.notification.ImageNotificationReceiver" />
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
package eu.kanade.tachiyomi
|
package eu.kanade.tachiyomi
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.support.multidex.MultiDex
|
||||||
|
import com.evernote.android.job.JobManager
|
||||||
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import org.acra.annotation.ReportsCrashes
|
import org.acra.annotation.ReportsCrashes
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@ -25,10 +30,28 @@ open class App : Application() {
|
|||||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||||
|
|
||||||
setupAcra()
|
setupAcra()
|
||||||
|
setupJobManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun attachBaseContext(base: Context) {
|
||||||
|
super.attachBaseContext(base)
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
MultiDex.install(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun setupAcra() {
|
protected open fun setupAcra() {
|
||||||
ACRA.init(this)
|
ACRA.init(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected open fun setupJobManager() {
|
||||||
|
JobManager.create(this).addJobCreator { tag ->
|
||||||
|
when (tag) {
|
||||||
|
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||||
|
UpdateCheckerJob.TAG -> UpdateCheckerJob()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -5,4 +5,5 @@ object Constants {
|
|||||||
const val NOTIFICATION_UPDATER_ID = 2
|
const val NOTIFICATION_UPDATER_ID = 2
|
||||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
|
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
|
||||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
|
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
|
||||||
|
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 5
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,10 @@ package eu.kanade.tachiyomi.data.backup
|
|||||||
import com.github.salomonbrys.kotson.fromJson
|
import com.github.salomonbrys.kotson.fromJson
|
||||||
import com.google.gson.*
|
import com.google.gson.*
|
||||||
import com.google.gson.stream.JsonReader
|
import com.google.gson.stream.JsonReader
|
||||||
|
import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer
|
||||||
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
|
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
|
||||||
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
|
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
|
||||||
|
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
|
||||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
import eu.kanade.tachiyomi.data.database.models.*
|
import eu.kanade.tachiyomi.data.database.models.*
|
||||||
import java.io.*
|
import java.io.*
|
||||||
@ -42,7 +44,9 @@ class BackupManager(private val db: DatabaseHelper) {
|
|||||||
|
|
||||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||||
private val gson = GsonBuilder()
|
private val gson = GsonBuilder()
|
||||||
.registerTypeAdapter(Integer::class.java, IntegerSerializer())
|
.registerTypeAdapter(java.lang.Integer::class.java, IntegerSerializer())
|
||||||
|
.registerTypeAdapter(java.lang.Boolean::class.java, BooleanSerializer())
|
||||||
|
.registerTypeAdapter(java.lang.Long::class.java, LongSerializer())
|
||||||
.setExclusionStrategies(IdExclusion())
|
.setExclusionStrategies(IdExclusion())
|
||||||
.create()
|
.create()
|
||||||
|
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.serializer
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement
|
||||||
|
import com.google.gson.JsonPrimitive
|
||||||
|
import com.google.gson.JsonSerializationContext
|
||||||
|
import com.google.gson.JsonSerializer
|
||||||
|
import java.lang.reflect.Type
|
||||||
|
|
||||||
|
class BooleanSerializer : JsonSerializer<Boolean> {
|
||||||
|
|
||||||
|
override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||||
|
if (value != null && value != false)
|
||||||
|
return JsonPrimitive(value)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
@ -2,10 +2,10 @@ package eu.kanade.tachiyomi.data.backup.serializer
|
|||||||
|
|
||||||
import com.google.gson.ExclusionStrategy
|
import com.google.gson.ExclusionStrategy
|
||||||
import com.google.gson.FieldAttributes
|
import com.google.gson.FieldAttributes
|
||||||
import eu.kanade.tachiyomi.data.database.models.Category
|
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl
|
||||||
|
|
||||||
class IdExclusion : ExclusionStrategy {
|
class IdExclusion : ExclusionStrategy {
|
||||||
|
|
||||||
@ -15,10 +15,10 @@ class IdExclusion : ExclusionStrategy {
|
|||||||
private val syncExclusions = listOf("id", "manga_id", "update")
|
private val syncExclusions = listOf("id", "manga_id", "update")
|
||||||
|
|
||||||
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
|
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
|
||||||
Manga::class.java -> mangaExclusions.contains(f.name)
|
MangaImpl::class.java -> mangaExclusions.contains(f.name)
|
||||||
Chapter::class.java -> chapterExclusions.contains(f.name)
|
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
|
||||||
MangaSync::class.java -> syncExclusions.contains(f.name)
|
MangaSyncImpl::class.java -> syncExclusions.contains(f.name)
|
||||||
Category::class.java -> categoryExclusions.contains(f.name)
|
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.backup.serializer
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement
|
||||||
|
import com.google.gson.JsonPrimitive
|
||||||
|
import com.google.gson.JsonSerializationContext
|
||||||
|
import com.google.gson.JsonSerializer
|
||||||
|
import java.lang.reflect.Type
|
||||||
|
|
||||||
|
class LongSerializer : JsonSerializer<Long> {
|
||||||
|
|
||||||
|
override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||||
|
if (value != null && value !== 0L)
|
||||||
|
return JsonPrimitive(value)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
@ -2,18 +2,18 @@ package eu.kanade.tachiyomi.data.cache
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
|
import com.github.salomonbrys.kotson.fromJson
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import com.jakewharton.disklrucache.DiskLruCache
|
import com.jakewharton.disklrucache.DiskLruCache
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.util.DiskUtils
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
import eu.kanade.tachiyomi.util.saveImageTo
|
import eu.kanade.tachiyomi.util.saveTo
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okio.Okio
|
import okio.Okio
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.lang.reflect.Type
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class used to create chapter cache
|
* Class used to create chapter cache
|
||||||
@ -26,15 +26,6 @@ import java.lang.reflect.Type
|
|||||||
*/
|
*/
|
||||||
class ChapterCache(private val context: Context) {
|
class ChapterCache(private val context: Context) {
|
||||||
|
|
||||||
/** Google Json class used for parsing JSON files. */
|
|
||||||
private val gson: Gson = Gson()
|
|
||||||
|
|
||||||
/** Cache class used for cache management. */
|
|
||||||
private val diskCache: DiskLruCache
|
|
||||||
|
|
||||||
/** Page list collection used for deserializing from JSON. */
|
|
||||||
private val pageListCollection: Type = object : TypeToken<List<Page>>() {}.type
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Name of cache directory. */
|
/** Name of cache directory. */
|
||||||
const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache"
|
const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache"
|
||||||
@ -49,38 +40,37 @@ class ChapterCache(private val context: Context) {
|
|||||||
const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024
|
const val PARAMETER_CACHE_SIZE = 75L * 1024 * 1024
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
/** Google Json class used for parsing JSON files. */
|
||||||
// Open cache in default cache directory.
|
private val gson: Gson by injectLazy()
|
||||||
diskCache = DiskLruCache.open(
|
|
||||||
File(context.cacheDir, PARAMETER_CACHE_DIRECTORY),
|
/** Cache class used for cache management. */
|
||||||
|
private val diskCache = DiskLruCache.open(
|
||||||
|
File(context.externalCacheDir, PARAMETER_CACHE_DIRECTORY),
|
||||||
PARAMETER_APP_VERSION,
|
PARAMETER_APP_VERSION,
|
||||||
PARAMETER_VALUE_COUNT,
|
PARAMETER_VALUE_COUNT,
|
||||||
PARAMETER_CACHE_SIZE)
|
PARAMETER_CACHE_SIZE)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns directory of cache.
|
* Returns directory of cache.
|
||||||
* @return directory of cache.
|
|
||||||
*/
|
*/
|
||||||
val cacheDir: File
|
val cacheDir: File
|
||||||
get() = diskCache.directory
|
get() = diskCache.directory
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns real size of directory.
|
* Returns real size of directory.
|
||||||
* @return real size of directory.
|
|
||||||
*/
|
*/
|
||||||
private val realSize: Long
|
private val realSize: Long
|
||||||
get() = DiskUtils.getDirectorySize(cacheDir)
|
get() = DiskUtil.getDirectorySize(cacheDir)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns real size of directory in human readable format.
|
* Returns real size of directory in human readable format.
|
||||||
* @return real size of directory.
|
|
||||||
*/
|
*/
|
||||||
val readableSize: String
|
val readableSize: String
|
||||||
get() = Formatter.formatFileSize(context, realSize)
|
get() = Formatter.formatFileSize(context, realSize)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove file from cache.
|
* Remove file from cache.
|
||||||
|
*
|
||||||
* @param file name of file "md5.0".
|
* @param file name of file "md5.0".
|
||||||
* @return status of deletion for the file.
|
* @return status of deletion for the file.
|
||||||
*/
|
*/
|
||||||
@ -101,23 +91,25 @@ class ChapterCache(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get page list from cache.
|
* Get page list from cache.
|
||||||
|
*
|
||||||
* @param chapterUrl the url of the chapter.
|
* @param chapterUrl the url of the chapter.
|
||||||
* @return an observable of the list of pages.
|
* @return an observable of the list of pages.
|
||||||
*/
|
*/
|
||||||
fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> {
|
fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> {
|
||||||
return Observable.fromCallable<List<Page>> {
|
return Observable.fromCallable<List<Page>> {
|
||||||
// Get the key for the chapter.
|
// Get the key for the chapter.
|
||||||
val key = DiskUtils.hashKeyForDisk(chapterUrl)
|
val key = DiskUtil.hashKeyForDisk(chapterUrl)
|
||||||
|
|
||||||
// Convert JSON string to list of objects. Throws an exception if snapshot is null
|
// Convert JSON string to list of objects. Throws an exception if snapshot is null
|
||||||
diskCache.get(key).use {
|
diskCache.get(key).use {
|
||||||
gson.fromJson(it.getString(0), pageListCollection)
|
gson.fromJson<List<Page>>(it.getString(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add page list to disk cache.
|
* Add page list to disk cache.
|
||||||
|
*
|
||||||
* @param chapterUrl the url of the chapter.
|
* @param chapterUrl the url of the chapter.
|
||||||
* @param pages list of pages.
|
* @param pages list of pages.
|
||||||
*/
|
*/
|
||||||
@ -130,7 +122,7 @@ class ChapterCache(private val context: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get editor from md5 key.
|
// Get editor from md5 key.
|
||||||
val key = DiskUtils.hashKeyForDisk(chapterUrl)
|
val key = DiskUtil.hashKeyForDisk(chapterUrl)
|
||||||
editor = diskCache.edit(key) ?: return
|
editor = diskCache.edit(key) ?: return
|
||||||
|
|
||||||
// Write chapter urls to cache.
|
// Write chapter urls to cache.
|
||||||
@ -151,51 +143,50 @@ class ChapterCache(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if image is in cache.
|
* Returns true if image is in cache.
|
||||||
|
*
|
||||||
* @param imageUrl url of image.
|
* @param imageUrl url of image.
|
||||||
* @return true if in cache otherwise false.
|
* @return true if in cache otherwise false.
|
||||||
*/
|
*/
|
||||||
fun isImageInCache(imageUrl: String): Boolean {
|
fun isImageInCache(imageUrl: String): Boolean {
|
||||||
try {
|
try {
|
||||||
return diskCache.get(DiskUtils.hashKeyForDisk(imageUrl)) != null
|
return diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image path from url.
|
* Get image file from url.
|
||||||
|
*
|
||||||
* @param imageUrl url of image.
|
* @param imageUrl url of image.
|
||||||
* @return path of image.
|
* @return path of image.
|
||||||
*/
|
*/
|
||||||
fun getImagePath(imageUrl: String): String? {
|
fun getImageFile(imageUrl: String): File {
|
||||||
try {
|
|
||||||
// Get file from md5 key.
|
// Get file from md5 key.
|
||||||
val imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0"
|
val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0"
|
||||||
return File(diskCache.directory, imageName).canonicalPath
|
return File(diskCache.directory, imageName)
|
||||||
} catch (e: IOException) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add image to cache.
|
* Add image to cache.
|
||||||
|
*
|
||||||
* @param imageUrl url of image.
|
* @param imageUrl url of image.
|
||||||
* @param response http response from page.
|
* @param response http response from page.
|
||||||
* @throws IOException image error.
|
* @throws IOException image error.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun putImageToCache(imageUrl: String, response: Response, reencode: Boolean) {
|
fun putImageToCache(imageUrl: String, response: Response) {
|
||||||
// Initialize editor (edits the values for an entry).
|
// Initialize editor (edits the values for an entry).
|
||||||
var editor: DiskLruCache.Editor? = null
|
var editor: DiskLruCache.Editor? = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get editor from md5 key.
|
// Get editor from md5 key.
|
||||||
val key = DiskUtils.hashKeyForDisk(imageUrl)
|
val key = DiskUtil.hashKeyForDisk(imageUrl)
|
||||||
editor = diskCache.edit(key) ?: throw IOException("Unable to edit key")
|
editor = diskCache.edit(key) ?: throw IOException("Unable to edit key")
|
||||||
|
|
||||||
// Get OutputStream and write image with Okio.
|
// Get OutputStream and write image with Okio.
|
||||||
response.body().source().saveImageTo(editor.newOutputStream(0), reencode)
|
response.body().source().saveTo(editor.newOutputStream(0))
|
||||||
|
|
||||||
diskCache.flush()
|
diskCache.flush()
|
||||||
editor.commit()
|
editor.commit()
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.cache
|
package eu.kanade.tachiyomi.data.cache
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.util.DiskUtils
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@ -29,7 +29,7 @@ class CoverCache(private val context: Context) {
|
|||||||
* @return cover image.
|
* @return cover image.
|
||||||
*/
|
*/
|
||||||
fun getCoverFile(thumbnailUrl: String): File {
|
fun getCoverFile(thumbnailUrl: String): File {
|
||||||
return File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl))
|
return File(cacheDir, DiskUtil.hashKeyForDisk(thumbnailUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -17,7 +17,7 @@ class DbOpenHelper(context: Context)
|
|||||||
/**
|
/**
|
||||||
* Version of the database.
|
* Version of the database.
|
||||||
*/
|
*/
|
||||||
const val DATABASE_VERSION = 3
|
const val DATABASE_VERSION = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
override fun onCreate(db: SQLiteDatabase) = with(db) {
|
||||||
@ -48,6 +48,9 @@ class DbOpenHelper(context: Context)
|
|||||||
db.execSQL(HistoryTable.createTableQuery)
|
db.execSQL(HistoryTable.createTableQuery)
|
||||||
db.execSQL(HistoryTable.createChapterIdIndexQuery)
|
db.execSQL(HistoryTable.createChapterIdIndexQuery)
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 4) {
|
||||||
|
db.execSQL(ChapterTable.bookmarkUpdateQuery)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConfigure(db: SQLiteDatabase) {
|
override fun onConfigure(db: SQLiteDatabase) {
|
||||||
|
@ -11,6 +11,7 @@ import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
|||||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_BOOKMARK
|
||||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_CHAPTER_NUMBER
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_CHAPTER_NUMBER
|
||||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_FETCH
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_FETCH
|
||||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_UPLOAD
|
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_UPLOAD
|
||||||
@ -41,12 +42,13 @@ class ChapterPutResolver : DefaultPutResolver<Chapter>() {
|
|||||||
.whereArgs(obj.id)
|
.whereArgs(obj.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
override fun mapToContentValues(obj: Chapter) = ContentValues(10).apply {
|
override fun mapToContentValues(obj: Chapter) = ContentValues(11).apply {
|
||||||
put(COL_ID, obj.id)
|
put(COL_ID, obj.id)
|
||||||
put(COL_MANGA_ID, obj.manga_id)
|
put(COL_MANGA_ID, obj.manga_id)
|
||||||
put(COL_URL, obj.url)
|
put(COL_URL, obj.url)
|
||||||
put(COL_NAME, obj.name)
|
put(COL_NAME, obj.name)
|
||||||
put(COL_READ, obj.read)
|
put(COL_READ, obj.read)
|
||||||
|
put(COL_BOOKMARK, obj.bookmark)
|
||||||
put(COL_DATE_FETCH, obj.date_fetch)
|
put(COL_DATE_FETCH, obj.date_fetch)
|
||||||
put(COL_DATE_UPLOAD, obj.date_upload)
|
put(COL_DATE_UPLOAD, obj.date_upload)
|
||||||
put(COL_LAST_PAGE_READ, obj.last_page_read)
|
put(COL_LAST_PAGE_READ, obj.last_page_read)
|
||||||
@ -63,6 +65,7 @@ class ChapterGetResolver : DefaultGetResolver<Chapter>() {
|
|||||||
url = cursor.getString(cursor.getColumnIndex(COL_URL))
|
url = cursor.getString(cursor.getColumnIndex(COL_URL))
|
||||||
name = cursor.getString(cursor.getColumnIndex(COL_NAME))
|
name = cursor.getString(cursor.getColumnIndex(COL_NAME))
|
||||||
read = cursor.getInt(cursor.getColumnIndex(COL_READ)) == 1
|
read = cursor.getInt(cursor.getColumnIndex(COL_READ)) == 1
|
||||||
|
bookmark = cursor.getInt(cursor.getColumnIndex(COL_BOOKMARK)) == 1
|
||||||
date_fetch = cursor.getLong(cursor.getColumnIndex(COL_DATE_FETCH))
|
date_fetch = cursor.getLong(cursor.getColumnIndex(COL_DATE_FETCH))
|
||||||
date_upload = cursor.getLong(cursor.getColumnIndex(COL_DATE_UPLOAD))
|
date_upload = cursor.getLong(cursor.getColumnIndex(COL_DATE_UPLOAD))
|
||||||
last_page_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_PAGE_READ))
|
last_page_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_PAGE_READ))
|
||||||
|
@ -14,6 +14,8 @@ interface Chapter : Serializable {
|
|||||||
|
|
||||||
var read: Boolean
|
var read: Boolean
|
||||||
|
|
||||||
|
var bookmark: Boolean
|
||||||
|
|
||||||
var last_page_read: Int
|
var last_page_read: Int
|
||||||
|
|
||||||
var date_fetch: Long
|
var date_fetch: Long
|
||||||
|
@ -12,6 +12,8 @@ class ChapterImpl : Chapter {
|
|||||||
|
|
||||||
override var read: Boolean = false
|
override var read: Boolean = false
|
||||||
|
|
||||||
|
override var bookmark: Boolean = false
|
||||||
|
|
||||||
override var last_page_read: Int = 0
|
override var last_page_read: Int = 0
|
||||||
|
|
||||||
override var date_fetch: Long = 0
|
override var date_fetch: Long = 0
|
||||||
|
@ -84,6 +84,10 @@ interface Manga : Serializable {
|
|||||||
get() = chapter_flags and DOWNLOADED_MASK
|
get() = chapter_flags and DOWNLOADED_MASK
|
||||||
set(filter) = setFlags(filter, DOWNLOADED_MASK)
|
set(filter) = setFlags(filter, DOWNLOADED_MASK)
|
||||||
|
|
||||||
|
var bookmarkedFilter: Int
|
||||||
|
get() = chapter_flags and BOOKMARKED_MASK
|
||||||
|
set(filter) = setFlags(filter, BOOKMARKED_MASK)
|
||||||
|
|
||||||
var sorting: Int
|
var sorting: Int
|
||||||
get() = chapter_flags and SORTING_MASK
|
get() = chapter_flags and SORTING_MASK
|
||||||
set(sort) = setFlags(sort, SORTING_MASK)
|
set(sort) = setFlags(sort, SORTING_MASK)
|
||||||
@ -110,6 +114,10 @@ interface Manga : Serializable {
|
|||||||
const val SHOW_NOT_DOWNLOADED = 0x00000010
|
const val SHOW_NOT_DOWNLOADED = 0x00000010
|
||||||
const val DOWNLOADED_MASK = 0x00000018
|
const val DOWNLOADED_MASK = 0x00000018
|
||||||
|
|
||||||
|
const val SHOW_BOOKMARKED = 0x00000020
|
||||||
|
const val SHOW_NOT_BOOKMARKED = 0x00000040
|
||||||
|
const val BOOKMARKED_MASK = 0x00000060
|
||||||
|
|
||||||
const val SORTING_SOURCE = 0x00000000
|
const val SORTING_SOURCE = 0x00000000
|
||||||
const val SORTING_NUMBER = 0x00000100
|
const val SORTING_NUMBER = 0x00000100
|
||||||
const val SORTING_MASK = 0x00000100
|
const val SORTING_MASK = 0x00000100
|
||||||
|
@ -33,6 +33,15 @@ interface ChapterQueries : DbProvider {
|
|||||||
.withGetResolver(MangaChapterGetResolver.INSTANCE)
|
.withGetResolver(MangaChapterGetResolver.INSTANCE)
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun getChapter(id: Long) = db.get()
|
||||||
|
.`object`(Chapter::class.java)
|
||||||
|
.withQuery(Query.builder()
|
||||||
|
.table(ChapterTable.TABLE)
|
||||||
|
.where("${ChapterTable.COL_ID} = ?")
|
||||||
|
.whereArgs(id)
|
||||||
|
.build())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
|
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
|
||||||
|
|
||||||
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
|
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
|
||||||
|
@ -25,8 +25,9 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() {
|
|||||||
.whereArgs(chapter.id)
|
.whereArgs(chapter.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(chapter: Chapter) = ContentValues(2).apply {
|
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
|
||||||
put(ChapterTable.COL_READ, chapter.read)
|
put(ChapterTable.COL_READ, chapter.read)
|
||||||
|
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
|
||||||
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,6 +14,8 @@ object ChapterTable {
|
|||||||
|
|
||||||
const val COL_READ = "read"
|
const val COL_READ = "read"
|
||||||
|
|
||||||
|
const val COL_BOOKMARK = "bookmark"
|
||||||
|
|
||||||
const val COL_DATE_FETCH = "date_fetch"
|
const val COL_DATE_FETCH = "date_fetch"
|
||||||
|
|
||||||
const val COL_DATE_UPLOAD = "date_upload"
|
const val COL_DATE_UPLOAD = "date_upload"
|
||||||
@ -31,6 +33,7 @@ object ChapterTable {
|
|||||||
$COL_URL TEXT NOT NULL,
|
$COL_URL TEXT NOT NULL,
|
||||||
$COL_NAME TEXT NOT NULL,
|
$COL_NAME TEXT NOT NULL,
|
||||||
$COL_READ BOOLEAN NOT NULL,
|
$COL_READ BOOLEAN NOT NULL,
|
||||||
|
$COL_BOOKMARK BOOLEAN NOT NULL,
|
||||||
$COL_LAST_PAGE_READ INT NOT NULL,
|
$COL_LAST_PAGE_READ INT NOT NULL,
|
||||||
$COL_CHAPTER_NUMBER FLOAT NOT NULL,
|
$COL_CHAPTER_NUMBER FLOAT NOT NULL,
|
||||||
$COL_SOURCE_ORDER INTEGER NOT NULL,
|
$COL_SOURCE_ORDER INTEGER NOT NULL,
|
||||||
@ -46,4 +49,7 @@ object ChapterTable {
|
|||||||
val sourceOrderUpdateQuery: String
|
val sourceOrderUpdateQuery: String
|
||||||
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
|
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0"
|
||||||
|
|
||||||
|
val bookmarkUpdateQuery: String
|
||||||
|
get() = "ALTER TABLE $TABLE ADD COLUMN $COL_BOOKMARK BOOLEAN DEFAULT FALSE"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,450 +1,154 @@
|
|||||||
package eu.kanade.tachiyomi.data.download
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import com.hippo.unifile.UniFile
|
||||||
import com.google.gson.Gson
|
import com.jakewharton.rxrelay.BehaviorRelay
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import com.google.gson.stream.JsonReader
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
|
||||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
|
||||||
import eu.kanade.tachiyomi.data.source.Source
|
import eu.kanade.tachiyomi.data.source.Source
|
||||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
|
||||||
import eu.kanade.tachiyomi.util.*
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.Subscription
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import rx.schedulers.Schedulers
|
|
||||||
import rx.subjects.BehaviorSubject
|
|
||||||
import rx.subjects.PublishSubject
|
|
||||||
import timber.log.Timber
|
|
||||||
import uy.kohesive.injekt.Injekt
|
|
||||||
import uy.kohesive.injekt.api.get
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileReader
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class DownloadManager(
|
|
||||||
private val context: Context,
|
|
||||||
private val sourceManager: SourceManager = Injekt.get(),
|
|
||||||
private val preferences: PreferencesHelper = Injekt.get()
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val gson = Gson()
|
|
||||||
|
|
||||||
private val downloadsQueueSubject = PublishSubject.create<List<Download>>()
|
|
||||||
val runningSubject = BehaviorSubject.create<Boolean>()
|
|
||||||
private var downloadsSubscription: Subscription? = null
|
|
||||||
|
|
||||||
val downloadNotifier by lazy { DownloadNotifier(context) }
|
|
||||||
|
|
||||||
private val threadsSubject = BehaviorSubject.create<Int>()
|
|
||||||
private var threadsSubscription: Subscription? = null
|
|
||||||
|
|
||||||
val queue = DownloadQueue()
|
|
||||||
|
|
||||||
val imageFilenameRegex = "[^\\sa-zA-Z0-9.-]".toRegex()
|
|
||||||
|
|
||||||
val PAGE_LIST_FILE = "index.json"
|
|
||||||
|
|
||||||
@Volatile var isRunning: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
private fun initializeSubscriptions() {
|
|
||||||
|
|
||||||
downloadsSubscription?.unsubscribe()
|
|
||||||
|
|
||||||
threadsSubscription = preferences.downloadThreads().asObservable()
|
|
||||||
.subscribe {
|
|
||||||
threadsSubject.onNext(it)
|
|
||||||
downloadNotifier.multipleDownloadThreads = it > 1
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadsSubscription = downloadsQueueSubject.flatMap { Observable.from(it) }
|
|
||||||
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
|
|
||||||
.onBackpressureBuffer()
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe({
|
|
||||||
// Delete successful downloads from queue
|
|
||||||
if (it.status == Download.DOWNLOADED) {
|
|
||||||
// remove downloaded chapter from queue
|
|
||||||
queue.del(it)
|
|
||||||
downloadNotifier.onProgressChange(queue)
|
|
||||||
}
|
|
||||||
if (areAllDownloadsFinished()) {
|
|
||||||
DownloadService.stop(context)
|
|
||||||
}
|
|
||||||
}, { e ->
|
|
||||||
DownloadService.stop(context)
|
|
||||||
Timber.e(e, e.message)
|
|
||||||
downloadNotifier.onError(e.message)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isRunning) {
|
|
||||||
isRunning = true
|
|
||||||
runningSubject.onNext(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroySubscriptions() {
|
|
||||||
if (isRunning) {
|
|
||||||
isRunning = false
|
|
||||||
runningSubject.onNext(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadsSubscription != null) {
|
|
||||||
downloadsSubscription?.unsubscribe()
|
|
||||||
downloadsSubscription = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (threadsSubscription != null) {
|
|
||||||
threadsSubscription?.unsubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a download object for every chapter and add them to the downloads queue
|
|
||||||
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
|
||||||
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
|
|
||||||
|
|
||||||
// Add chapters to queue from the start
|
|
||||||
val sortedChapters = chapters.sortedByDescending { it.source_order }
|
|
||||||
|
|
||||||
// Used to avoid downloading chapters with the same name
|
|
||||||
val addedChapters = ArrayList<String>()
|
|
||||||
val pending = ArrayList<Download>()
|
|
||||||
|
|
||||||
for (chapter in sortedChapters) {
|
|
||||||
if (addedChapters.contains(chapter.name))
|
|
||||||
continue
|
|
||||||
|
|
||||||
addedChapters.add(chapter.name)
|
|
||||||
val download = Download(source, manga, chapter)
|
|
||||||
|
|
||||||
if (!prepareDownload(download)) {
|
|
||||||
queue.add(download)
|
|
||||||
pending.add(download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize queue size
|
|
||||||
downloadNotifier.initialQueueSize = queue.size
|
|
||||||
// Show notification
|
|
||||||
downloadNotifier.onProgressChange(queue)
|
|
||||||
|
|
||||||
if (isRunning) downloadsQueueSubject.onNext(pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public method to check if a chapter is downloaded
|
|
||||||
fun isChapterDownloaded(source: Source, manga: Manga, chapter: Chapter): Boolean {
|
|
||||||
val directory = getAbsoluteChapterDirectory(source, manga, chapter)
|
|
||||||
if (!directory.exists())
|
|
||||||
return false
|
|
||||||
|
|
||||||
val pages = getSavedPageList(source, manga, chapter)
|
|
||||||
return isChapterDownloaded(directory, pages)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the download. Returns true if the chapter is already downloaded
|
|
||||||
private fun prepareDownload(download: Download): Boolean {
|
|
||||||
// If the chapter is already queued, don't add it again
|
|
||||||
for (queuedDownload in queue) {
|
|
||||||
if (download.chapter.id == queuedDownload.chapter.id)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the directory to the download object for future access
|
|
||||||
download.directory = getAbsoluteChapterDirectory(download)
|
|
||||||
|
|
||||||
// If the directory doesn't exist, the chapter isn't downloaded.
|
|
||||||
if (!download.directory.exists()) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the page list doesn't exist, the chapter isn't downloaded
|
|
||||||
val savedPages = getSavedPageList(download) ?: return false
|
|
||||||
|
|
||||||
// Add the page list to the download object for future access
|
|
||||||
download.pages = savedPages
|
|
||||||
|
|
||||||
// If the number of files matches the number of pages, the chapter is downloaded.
|
|
||||||
// We have the index file, so we check one file more
|
|
||||||
return isChapterDownloaded(download.directory, download.pages)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that all the images are downloaded
|
|
||||||
private fun isChapterDownloaded(directory: File, pages: List<Page>?): Boolean {
|
|
||||||
return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download the entire chapter
|
|
||||||
private fun downloadChapter(download: Download): Observable<Download> {
|
|
||||||
DiskUtils.createDirectory(download.directory)
|
|
||||||
|
|
||||||
val pageListObservable: Observable<List<Page>> = if (download.pages == null)
|
|
||||||
// Pull page list from network and add them to download object
|
|
||||||
download.source.fetchPageListFromNetwork(download.chapter)
|
|
||||||
.doOnNext { pages ->
|
|
||||||
download.pages = pages
|
|
||||||
savePageList(download)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
// Or if the page list already exists, start from the file
|
|
||||||
Observable.just(download.pages)
|
|
||||||
|
|
||||||
return Observable.defer {
|
|
||||||
pageListObservable
|
|
||||||
.doOnNext { pages ->
|
|
||||||
download.downloadedImages = 0
|
|
||||||
download.status = Download.DOWNLOADING
|
|
||||||
}
|
|
||||||
// Get all the URLs to the source images, fetch pages if necessary
|
|
||||||
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
|
|
||||||
// Start downloading images, consider we can have downloaded images already
|
|
||||||
.concatMap { page -> getOrDownloadImage(page, download) }
|
|
||||||
// Do when page is downloaded.
|
|
||||||
.doOnNext {
|
|
||||||
downloadNotifier.onProgressChange(download, queue)
|
|
||||||
}
|
|
||||||
// Do after download completes
|
|
||||||
.doOnCompleted { onDownloadCompleted(download) }
|
|
||||||
.toList()
|
|
||||||
.map { pages -> download }
|
|
||||||
// If the page list threw, it will resume here
|
|
||||||
.onErrorResumeNext { error ->
|
|
||||||
download.status = Download.ERROR
|
|
||||||
downloadNotifier.onError(error.message, download.chapter.name)
|
|
||||||
Observable.just(download)
|
|
||||||
}
|
|
||||||
}.subscribeOn(Schedulers.io())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the image from the filesystem if it exists or download from network
|
|
||||||
private fun getOrDownloadImage(page: Page, download: Download): Observable<Page> {
|
|
||||||
// If the image URL is empty, do nothing
|
|
||||||
if (page.imageUrl == null)
|
|
||||||
return Observable.just(page)
|
|
||||||
|
|
||||||
val filename = getImageFilename(page)
|
|
||||||
val imagePath = File(download.directory, filename)
|
|
||||||
|
|
||||||
// If the image is already downloaded, do nothing. Otherwise download from network
|
|
||||||
val pageObservable = if (isImageDownloaded(imagePath))
|
|
||||||
Observable.just(page)
|
|
||||||
else
|
|
||||||
downloadImage(page, download.source, download.directory, filename)
|
|
||||||
|
|
||||||
return pageObservable
|
|
||||||
// When the image is ready, set image path, progress (just in case) and status
|
|
||||||
.doOnNext {
|
|
||||||
page.imagePath = imagePath.absolutePath
|
|
||||||
page.progress = 100
|
|
||||||
download.downloadedImages++
|
|
||||||
page.status = Page.READY
|
|
||||||
}
|
|
||||||
// Mark this page as error and allow to download the remaining
|
|
||||||
.onErrorResumeNext {
|
|
||||||
page.progress = 0
|
|
||||||
page.status = Page.ERROR
|
|
||||||
Observable.just(page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save image on disk
|
|
||||||
private fun downloadImage(page: Page, source: OnlineSource, directory: File, filename: String): Observable<Page> {
|
|
||||||
page.status = Page.DOWNLOAD_IMAGE
|
|
||||||
return source.imageResponse(page)
|
|
||||||
.map {
|
|
||||||
val file = File(directory, filename)
|
|
||||||
try {
|
|
||||||
file.parentFile.mkdirs()
|
|
||||||
it.body().source().saveImageTo(file.outputStream(), preferences.reencodeImage())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
it.close()
|
|
||||||
file.delete()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
page
|
|
||||||
}
|
|
||||||
// Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
|
|
||||||
.retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public method to get the image from the filesystem. It does NOT provide any way to download the image
|
|
||||||
fun getDownloadedImage(page: Page, chapterDir: File): Observable<Page> {
|
|
||||||
if (page.imageUrl == null) {
|
|
||||||
page.status = Page.ERROR
|
|
||||||
return Observable.just(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
val imagePath = File(chapterDir, getImageFilename(page))
|
|
||||||
|
|
||||||
// When the image is ready, set image path, progress (just in case) and status
|
|
||||||
if (isImageDownloaded(imagePath)) {
|
|
||||||
page.imagePath = imagePath.absolutePath
|
|
||||||
page.progress = 100
|
|
||||||
page.status = Page.READY
|
|
||||||
} else {
|
|
||||||
page.status = Page.ERROR
|
|
||||||
}
|
|
||||||
return Observable.just(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the filename for an image given the page
|
|
||||||
private fun getImageFilename(page: Page): String {
|
|
||||||
val url = page.imageUrl
|
|
||||||
val number = String.format("%03d", page.pageNumber + 1)
|
|
||||||
|
|
||||||
// Try to preserve file extension
|
|
||||||
return when {
|
|
||||||
UrlUtil.isJpg(url) -> "$number.jpg"
|
|
||||||
UrlUtil.isPng(url) -> "$number.png"
|
|
||||||
UrlUtil.isGif(url) -> "$number.gif"
|
|
||||||
else -> Uri.parse(url).lastPathSegment.replace(imageFilenameRegex, "_")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isImageDownloaded(imagePath: File): Boolean {
|
|
||||||
return imagePath.exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when a download finishes. This doesn't mean the download was successful, so we check it
|
|
||||||
private fun onDownloadCompleted(download: Download) {
|
|
||||||
checkDownloadIsSuccessful(download)
|
|
||||||
savePageList(download)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkDownloadIsSuccessful(download: Download) {
|
|
||||||
var actualProgress = 0
|
|
||||||
var status = Download.DOWNLOADED
|
|
||||||
// If any page has an error, the download result will be error
|
|
||||||
for (page in download.pages!!) {
|
|
||||||
actualProgress += page.progress
|
|
||||||
if (page.status != Page.READY) {
|
|
||||||
status = Download.ERROR
|
|
||||||
downloadNotifier.onError(context.getString(R.string.download_notifier_page_ready_error), download.chapter.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ensure that the chapter folder has all the images
|
|
||||||
if (!isChapterDownloaded(download.directory, download.pages)) {
|
|
||||||
status = Download.ERROR
|
|
||||||
downloadNotifier.onError(context.getString(R.string.download_notifier_page_error), download.chapter.name)
|
|
||||||
}
|
|
||||||
download.totalProgress = actualProgress
|
|
||||||
download.status = status
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the page list from the chapter's directory if it exists, null otherwise
|
|
||||||
fun getSavedPageList(source: Source, manga: Manga, chapter: Chapter): List<Page>? {
|
|
||||||
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
|
|
||||||
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
|
|
||||||
|
|
||||||
return try {
|
|
||||||
JsonReader(FileReader(pagesFile)).use {
|
|
||||||
val collectionType = object : TypeToken<List<Page>>() {}.type
|
|
||||||
gson.fromJson(it, collectionType)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shortcut for the method above
|
|
||||||
private fun getSavedPageList(download: Download): List<Page>? {
|
|
||||||
return getSavedPageList(download.source, download.manga, download.chapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the page list to the chapter's directory
|
|
||||||
fun savePageList(source: Source, manga: Manga, chapter: Chapter, pages: List<Page>) {
|
|
||||||
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
|
|
||||||
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
|
|
||||||
|
|
||||||
pagesFile.outputStream().use {
|
|
||||||
try {
|
|
||||||
it.write(gson.toJson(pages).toByteArray())
|
|
||||||
it.flush()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shortcut for the method above
|
|
||||||
private fun savePageList(download: Download) {
|
|
||||||
savePageList(download.source, download.manga, download.chapter, download.pages!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAbsoluteMangaDirectory(source: Source, manga: Manga): File {
|
|
||||||
val mangaRelativePath = source.toString() +
|
|
||||||
File.separator +
|
|
||||||
manga.title.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
|
|
||||||
|
|
||||||
return File(preferences.downloadsDirectory().getOrDefault(), mangaRelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the absolute path to the chapter directory
|
|
||||||
fun getAbsoluteChapterDirectory(source: Source, manga: Manga, chapter: Chapter): File {
|
|
||||||
val chapterRelativePath = chapter.name.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
|
|
||||||
|
|
||||||
return File(getAbsoluteMangaDirectory(source, manga), chapterRelativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shortcut for the method above
|
|
||||||
private fun getAbsoluteChapterDirectory(download: Download): File {
|
|
||||||
return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
|
|
||||||
val path = getAbsoluteChapterDirectory(source, manga, chapter)
|
|
||||||
DiskUtils.deleteFiles(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun areAllDownloadsFinished(): Boolean {
|
|
||||||
for (download in queue) {
|
|
||||||
if (download.status <= Download.DOWNLOADING)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to manage chapter downloads in the application. It must be instantiated once
|
||||||
|
* and retrieved through dependency injection. You can use this class to queue new chapters or query
|
||||||
|
* downloaded chapters.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
class DownloadManager(context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
|
||||||
|
*/
|
||||||
|
private val provider = DownloadProvider(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloader whose only task is to download chapters.
|
||||||
|
*/
|
||||||
|
private val downloader = Downloader(context, provider)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads queue, where the pending chapters are stored.
|
||||||
|
*/
|
||||||
|
val queue: DownloadQueue
|
||||||
|
get() = downloader.queue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject for subscribing to downloader status.
|
||||||
|
*/
|
||||||
|
val runningRelay: BehaviorRelay<Boolean>
|
||||||
|
get() = downloader.runningRelay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the downloader to begin downloads.
|
||||||
|
*
|
||||||
|
* @return true if it's started, false otherwise (empty queue).
|
||||||
|
*/
|
||||||
fun startDownloads(): Boolean {
|
fun startDownloads(): Boolean {
|
||||||
if (queue.isEmpty())
|
return downloader.start()
|
||||||
return false
|
|
||||||
|
|
||||||
if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed)
|
|
||||||
initializeSubscriptions()
|
|
||||||
|
|
||||||
val pending = ArrayList<Download>()
|
|
||||||
for (download in queue) {
|
|
||||||
if (download.status != Download.DOWNLOADED) {
|
|
||||||
if (download.status != Download.QUEUE) download.status = Download.QUEUE
|
|
||||||
pending.add(download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadsQueueSubject.onNext(pending)
|
|
||||||
|
|
||||||
return !pending.isEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopDownloads(errorMessage: String? = null) {
|
/**
|
||||||
destroySubscriptions()
|
* Tells the downloader to stop downloads.
|
||||||
for (download in queue) {
|
*
|
||||||
if (download.status == Download.DOWNLOADING) {
|
* @param reason an optional reason for being stopped, used to notify the user.
|
||||||
download.status = Download.ERROR
|
*/
|
||||||
}
|
fun stopDownloads(reason: String? = null) {
|
||||||
}
|
downloader.stop(reason)
|
||||||
errorMessage?.let { downloadNotifier.onError(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empties the download queue.
|
||||||
|
*/
|
||||||
fun clearQueue() {
|
fun clearQueue() {
|
||||||
queue.clear()
|
downloader.clearQueue()
|
||||||
downloadNotifier.onClear()
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the downloader to enqueue the given list of chapters.
|
||||||
|
*
|
||||||
|
* @param manga the manga of the chapters.
|
||||||
|
* @param chapters the list of chapters to enqueue.
|
||||||
|
*/
|
||||||
|
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
|
||||||
|
downloader.queueChapters(manga, chapters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the page list of a downloaded chapter.
|
||||||
|
*
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param chapter the downloaded chapter.
|
||||||
|
* @return an observable containing the list of pages from the chapter.
|
||||||
|
*/
|
||||||
|
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): Observable<List<Page>> {
|
||||||
|
return buildPageList(provider.findChapterDir(source, manga, chapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the page list of a downloaded chapter.
|
||||||
|
*
|
||||||
|
* @param chapterDir the file where the chapter is downloaded.
|
||||||
|
* @return an observable containing the list of pages from the chapter.
|
||||||
|
*/
|
||||||
|
private fun buildPageList(chapterDir: UniFile?): Observable<List<Page>> {
|
||||||
|
return Observable.fromCallable {
|
||||||
|
val files = chapterDir?.listFiles().orEmpty()
|
||||||
|
.filter { "image" in it.type.orEmpty() }
|
||||||
|
|
||||||
|
if (files.isEmpty()) {
|
||||||
|
throw Exception("Page list is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
files.sortedBy { it.name }
|
||||||
|
.mapIndexed { i, file ->
|
||||||
|
Page(i, uri = file.uri).apply { status = Page.READY }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory name for the given chapter.
|
||||||
|
*
|
||||||
|
* @param chapter the chapter to query.
|
||||||
|
*/
|
||||||
|
fun getChapterDirName(chapter: Chapter): String {
|
||||||
|
return provider.getChapterDirName(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory for the given manga, if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source of the manga.
|
||||||
|
* @param manga the manga to query.
|
||||||
|
*/
|
||||||
|
fun findMangaDir(source: Source, manga: Manga): UniFile? {
|
||||||
|
return provider.findMangaDir(source, manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the directory for the given chapter, if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param chapter the chapter to query.
|
||||||
|
*/
|
||||||
|
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
|
||||||
|
return provider.findChapterDir(source, manga, chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the directory of a downloaded chapter.
|
||||||
|
*
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param chapter the chapter to delete.
|
||||||
|
*/
|
||||||
|
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
|
||||||
|
provider.findChapterDir(source, manga, chapter)?.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
package eu.kanade.tachiyomi.data.download
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.support.v4.app.NotificationCompat
|
import android.support.v4.app.NotificationCompat
|
||||||
import eu.kanade.tachiyomi.Constants
|
import eu.kanade.tachiyomi.Constants
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||||
|
import eu.kanade.tachiyomi.util.chop
|
||||||
import eu.kanade.tachiyomi.util.notificationManager
|
import eu.kanade.tachiyomi.util.notificationManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,17 +15,14 @@ import eu.kanade.tachiyomi.util.notificationManager
|
|||||||
*
|
*
|
||||||
* @param context context of application
|
* @param context context of application
|
||||||
*/
|
*/
|
||||||
class DownloadNotifier(private val context: Context) {
|
internal class DownloadNotifier(private val context: Context) {
|
||||||
/**
|
/**
|
||||||
* Notification builder.
|
* Notification builder.
|
||||||
*/
|
*/
|
||||||
private val notificationBuilder = NotificationCompat.Builder(context)
|
private val notification by lazy {
|
||||||
|
NotificationCompat.Builder(context)
|
||||||
/**
|
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
|
||||||
* Id of the notification.
|
}
|
||||||
*/
|
|
||||||
private val notificationId: Int
|
|
||||||
get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status of download. Used for correct notification icon.
|
* Status of download. Used for correct notification icon.
|
||||||
@ -33,12 +32,29 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* The size of queue on start download.
|
* The size of queue on start download.
|
||||||
*/
|
*/
|
||||||
internal var initialQueueSize = 0
|
var initialQueueSize = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simultaneous download setting > 1.
|
* Simultaneous download setting > 1.
|
||||||
*/
|
*/
|
||||||
internal var multipleDownloadThreads = false
|
var multipleDownloadThreads = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a notification from this builder.
|
||||||
|
*
|
||||||
|
* @param id the id of the notification.
|
||||||
|
*/
|
||||||
|
private fun NotificationCompat.Builder.show(id: Int = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID) {
|
||||||
|
context.notificationManager.notify(id, build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss the downloader's notification. Downloader error notifications use a different id, so
|
||||||
|
* those can only be dismissed by the user.
|
||||||
|
*/
|
||||||
|
fun dismiss() {
|
||||||
|
context.notificationManager.cancel(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when download progress changes.
|
* Called when download progress changes.
|
||||||
@ -46,47 +62,47 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* @param queue the queue containing downloads.
|
* @param queue the queue containing downloads.
|
||||||
*/
|
*/
|
||||||
internal fun onProgressChange(queue: DownloadQueue) {
|
fun onProgressChange(queue: DownloadQueue) {
|
||||||
if (multipleDownloadThreads) {
|
if (multipleDownloadThreads) {
|
||||||
doOnProgressChange(null, queue)
|
doOnProgressChange(null, queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when download progress changes
|
* Called when download progress changes.
|
||||||
* Note: Only accepted when single download active
|
* Note: Only accepted when single download active.
|
||||||
*
|
*
|
||||||
* @param download download object containing download information
|
* @param download download object containing download information.
|
||||||
* @param queue the queue containing downloads
|
* @param queue the queue containing downloads.
|
||||||
*/
|
*/
|
||||||
internal fun onProgressChange(download: Download, queue: DownloadQueue) {
|
fun onProgressChange(download: Download, queue: DownloadQueue) {
|
||||||
if (!multipleDownloadThreads) {
|
if (!multipleDownloadThreads) {
|
||||||
doOnProgressChange(download, queue)
|
doOnProgressChange(download, queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show notification progress of chapter
|
* Show notification progress of chapter.
|
||||||
*
|
*
|
||||||
* @param download download object containing download information
|
* @param download download object containing download information.
|
||||||
* @param queue the queue containing downloads
|
* @param queue the queue containing downloads.
|
||||||
*/
|
*/
|
||||||
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
|
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
|
||||||
// Check if download is completed
|
// Check if download is completed
|
||||||
if (multipleDownloadThreads) {
|
if (multipleDownloadThreads) {
|
||||||
if (queue.isEmpty()) {
|
if (queue.isEmpty()) {
|
||||||
onComplete(null)
|
onChapterCompleted(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (download != null && download.pages!!.size == download.downloadedImages) {
|
if (download != null && download.pages!!.size == download.downloadedImages) {
|
||||||
onComplete(download)
|
onChapterCompleted(download)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create notification
|
// Create notification
|
||||||
with (notificationBuilder) {
|
with(notification) {
|
||||||
// Check if icon needs refresh
|
// Check if icon needs refresh
|
||||||
if (!isDownloading) {
|
if (!isDownloading) {
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
@ -96,16 +112,16 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
if (multipleDownloadThreads) {
|
if (multipleDownloadThreads) {
|
||||||
setContentTitle(context.getString(R.string.app_name))
|
setContentTitle(context.getString(R.string.app_name))
|
||||||
|
|
||||||
|
// Reset the queue size if the download progress is negative
|
||||||
|
if ((initialQueueSize - queue.size) < 0)
|
||||||
|
initialQueueSize = queue.size
|
||||||
|
|
||||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||||
.format(initialQueueSize - queue.size, initialQueueSize))
|
.format(initialQueueSize - queue.size, initialQueueSize))
|
||||||
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
|
||||||
} else {
|
} else {
|
||||||
download?.let {
|
download?.let {
|
||||||
if (it.chapter.name.length >= 33)
|
setContentTitle(it.chapter.name.chop(30))
|
||||||
setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
|
|
||||||
else
|
|
||||||
setContentTitle(it.chapter.name)
|
|
||||||
|
|
||||||
setContentText(context.getString(R.string.chapter_downloading_progress)
|
setContentText(context.getString(R.string.chapter_downloading_progress)
|
||||||
.format(it.downloadedImages, it.pages!!.size))
|
.format(it.downloadedImages, it.pages!!.size))
|
||||||
setProgress(it.pages!!.size, it.downloadedImages, false)
|
setProgress(it.pages!!.size, it.downloadedImages, false)
|
||||||
@ -114,17 +130,17 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Displays the progress bar on notification
|
// Displays the progress bar on notification
|
||||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
notification.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when chapter is downloaded
|
* Called when chapter is downloaded.
|
||||||
*
|
*
|
||||||
* @param download download object containing download information
|
* @param download download object containing download information.
|
||||||
*/
|
*/
|
||||||
private fun onComplete(download: Download?) {
|
private fun onChapterCompleted(download: Download?) {
|
||||||
// Create notification.
|
// Create notification.
|
||||||
with(notificationBuilder) {
|
with(notification) {
|
||||||
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
|
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
|
||||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
@ -132,7 +148,7 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show notification.
|
// Show notification.
|
||||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
notification.show()
|
||||||
|
|
||||||
// Reset initial values
|
// Reset initial values
|
||||||
isDownloading = false
|
isDownloading = false
|
||||||
@ -140,27 +156,38 @@ class DownloadNotifier(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the notification message
|
* Called when the downloader receives a warning.
|
||||||
|
*
|
||||||
|
* @param reason the text to show.
|
||||||
*/
|
*/
|
||||||
internal fun onClear() {
|
fun onWarning(reason: String) {
|
||||||
context.notificationManager.cancel(notificationId)
|
with(notification) {
|
||||||
|
setContentTitle(context.getString(R.string.download_notifier_downloader_title))
|
||||||
|
setContentText(reason)
|
||||||
|
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
|
setProgress(0, 0, false)
|
||||||
|
}
|
||||||
|
notification.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called on error while downloading chapter
|
* Called when the downloader receives an error. It's shown as a separate notification to avoid
|
||||||
|
* being overwritten.
|
||||||
*
|
*
|
||||||
* @param error string containing error information
|
* @param error string containing error information.
|
||||||
* @param chapter string containing chapter title
|
* @param chapter string containing chapter title.
|
||||||
*/
|
*/
|
||||||
internal fun onError(error: String? = null, chapter: String? = null) {
|
fun onError(error: String? = null, chapter: String? = null) {
|
||||||
// Create notification
|
// Create notification
|
||||||
with(notificationBuilder) {
|
with(notification) {
|
||||||
setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error))
|
setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title))
|
||||||
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
|
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
|
||||||
setSmallIcon(android.R.drawable.stat_sys_warning)
|
setSmallIcon(android.R.drawable.stat_sys_warning)
|
||||||
setProgress(0, 0, false)
|
setProgress(0, 0, false)
|
||||||
}
|
}
|
||||||
context.notificationManager.notify(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID, notificationBuilder.build())
|
notification.show(Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID)
|
||||||
|
|
||||||
|
// Reset download information
|
||||||
isDownloading = false
|
isDownloading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,98 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.source.Source
|
||||||
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to provide the directories where the downloads should be saved.
|
||||||
|
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
class DownloadProvider(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences helper.
|
||||||
|
*/
|
||||||
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The root directory for downloads.
|
||||||
|
*/
|
||||||
|
private lateinit var downloadsDir: UniFile
|
||||||
|
|
||||||
|
init {
|
||||||
|
preferences.downloadsDirectory().asObservable()
|
||||||
|
.subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory for a manga. For internal use only.
|
||||||
|
*
|
||||||
|
* @param source the source of the manga.
|
||||||
|
* @param manga the manga to query.
|
||||||
|
*/
|
||||||
|
internal fun getMangaDir(source: Source, manga: Manga): UniFile {
|
||||||
|
return downloadsDir
|
||||||
|
.createDirectory(getSourceDirName(source))
|
||||||
|
.createDirectory(getMangaDirName(manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory for a manga if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source of the manga.
|
||||||
|
* @param manga the manga to query.
|
||||||
|
*/
|
||||||
|
fun findMangaDir(source: Source, manga: Manga): UniFile? {
|
||||||
|
val sourceDir = downloadsDir.findFile(getSourceDirName(source))
|
||||||
|
return sourceDir?.findFile(getMangaDirName(manga))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory for a chapter if it exists.
|
||||||
|
*
|
||||||
|
* @param source the source of the chapter.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
* @param chapter the chapter to query.
|
||||||
|
*/
|
||||||
|
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
|
||||||
|
val mangaDir = findMangaDir(source, manga)
|
||||||
|
return mangaDir?.findFile(getChapterDirName(chapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory name for a source.
|
||||||
|
*
|
||||||
|
* @param source the source to query.
|
||||||
|
*/
|
||||||
|
fun getSourceDirName(source: Source): String {
|
||||||
|
return source.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the download directory name for a manga.
|
||||||
|
*
|
||||||
|
* @param manga the manga to query.
|
||||||
|
*/
|
||||||
|
fun getMangaDirName(manga: Manga): String {
|
||||||
|
return DiskUtil.buildValidFilename(manga.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the chapter directory name for a chapter.
|
||||||
|
*
|
||||||
|
* @param chapter the chapter to query.
|
||||||
|
*/
|
||||||
|
fun getChapterDirName(chapter: Chapter): String {
|
||||||
|
return DiskUtil.buildValidFilename(chapter.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -3,130 +3,177 @@ package eu.kanade.tachiyomi.data.download
|
|||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.NetworkInfo.State.CONNECTED
|
||||||
|
import android.net.NetworkInfo.State.DISCONNECTED
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus
|
import com.github.pwittchen.reactivenetwork.library.Connectivity
|
||||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
||||||
|
import com.jakewharton.rxrelay.BehaviorRelay
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.util.connectivityManager
|
||||||
|
import eu.kanade.tachiyomi.util.plusAssign
|
||||||
|
import eu.kanade.tachiyomi.util.powerManager
|
||||||
import eu.kanade.tachiyomi.util.toast
|
import eu.kanade.tachiyomi.util.toast
|
||||||
import rx.Subscription
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
|
import rx.subscriptions.CompositeSubscription
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service is used to manage the downloader. The system can decide to stop the service, in
|
||||||
|
* which case the downloader is also stopped. It's also stopped while there's no network available.
|
||||||
|
* While the downloader is running, a wake lock will be held.
|
||||||
|
*/
|
||||||
class DownloadService : Service() {
|
class DownloadService : Service() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay used to know when the service is running.
|
||||||
|
*/
|
||||||
|
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts this service.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
context.startService(Intent(context, DownloadService::class.java))
|
context.startService(Intent(context, DownloadService::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops this service.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
context.stopService(Intent(context, DownloadService::class.java))
|
context.stopService(Intent(context, DownloadService::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val downloadManager: DownloadManager by injectLazy()
|
/**
|
||||||
val preferences: PreferencesHelper by injectLazy()
|
* Download manager.
|
||||||
|
*/
|
||||||
|
private val downloadManager: DownloadManager by injectLazy()
|
||||||
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
/**
|
||||||
private var networkChangeSubscription: Subscription? = null
|
* Preferences helper.
|
||||||
private var queueRunningSubscription: Subscription? = null
|
*/
|
||||||
private var isRunning: Boolean = false
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wake lock to prevent the device to enter sleep mode.
|
||||||
|
*/
|
||||||
|
private val wakeLock by lazy {
|
||||||
|
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscriptions to store while the service is running.
|
||||||
|
*/
|
||||||
|
private lateinit var subscriptions: CompositeSubscription
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the service is created.
|
||||||
|
*/
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
runningRelay.call(true)
|
||||||
createWakeLock()
|
subscriptions = CompositeSubscription()
|
||||||
|
listenDownloaderState()
|
||||||
listenQueueRunningChanges()
|
|
||||||
listenNetworkChanges()
|
listenNetworkChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
/**
|
||||||
return Service.START_STICKY
|
* Called when the service is destroyed.
|
||||||
}
|
*/
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
queueRunningSubscription?.unsubscribe()
|
runningRelay.call(false)
|
||||||
networkChangeSubscription?.unsubscribe()
|
subscriptions.unsubscribe()
|
||||||
downloadManager.destroySubscriptions()
|
downloadManager.stopDownloads()
|
||||||
destroyWakeLock()
|
wakeLock.releaseIfNeeded()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not used.
|
||||||
|
*/
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
return Service.START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Not used.
|
||||||
|
*/
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to network changes.
|
||||||
|
*
|
||||||
|
* @see onNetworkStateChanged
|
||||||
|
*/
|
||||||
private fun listenNetworkChanges() {
|
private fun listenNetworkChanges() {
|
||||||
networkChangeSubscription = ReactiveNetwork().enableInternetCheck()
|
subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
|
||||||
.observeConnectivity(applicationContext)
|
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe({ state ->
|
.subscribe({ state -> onNetworkStateChanged(state)
|
||||||
when (state) {
|
|
||||||
ConnectivityStatus.WIFI_CONNECTED_HAS_INTERNET -> {
|
|
||||||
// If there are no remaining downloads, destroy the service
|
|
||||||
if (!isRunning && !downloadManager.startDownloads()) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConnectivityStatus.MOBILE_CONNECTED -> {
|
|
||||||
if (!preferences.downloadOnlyOverWifi()) {
|
|
||||||
if (!isRunning && !downloadManager.startDownloads()) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
} else if (isRunning) {
|
|
||||||
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
if (isRunning) {
|
|
||||||
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { error ->
|
}, { error ->
|
||||||
toast(R.string.download_queue_error)
|
toast(R.string.download_queue_error)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun listenQueueRunningChanges() {
|
/**
|
||||||
queueRunningSubscription = downloadManager.runningSubject.subscribe { running ->
|
* Called when the network state changes.
|
||||||
isRunning = running
|
*
|
||||||
|
* @param connectivity the new network state.
|
||||||
|
*/
|
||||||
|
private fun onNetworkStateChanged(connectivity: Connectivity) {
|
||||||
|
when (connectivity.state) {
|
||||||
|
CONNECTED -> {
|
||||||
|
if (preferences.downloadOnlyOverWifi() && connectivityManager.isActiveNetworkMetered) {
|
||||||
|
downloadManager.stopDownloads(getString(R.string.download_notifier_text_only_wifi))
|
||||||
|
} else {
|
||||||
|
val started = downloadManager.startDownloads()
|
||||||
|
if (!started) stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DISCONNECTED -> {
|
||||||
|
downloadManager.stopDownloads(getString(R.string.download_notifier_no_network))
|
||||||
|
}
|
||||||
|
else -> { /* Do nothing */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to downloader status. Enables or disables the wake lock depending on the status.
|
||||||
|
*/
|
||||||
|
private fun listenDownloaderState() {
|
||||||
|
subscriptions += downloadManager.runningRelay.subscribe { running ->
|
||||||
if (running)
|
if (running)
|
||||||
acquireWakeLock()
|
wakeLock.acquireIfNeeded()
|
||||||
else
|
else
|
||||||
releaseWakeLock()
|
wakeLock.releaseIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createWakeLock() {
|
/**
|
||||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
* Releases the wake lock if it's held.
|
||||||
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
|
*/
|
||||||
|
fun PowerManager.WakeLock.releaseIfNeeded() {
|
||||||
|
if (isHeld) release()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun destroyWakeLock() {
|
/**
|
||||||
if (wakeLock != null && wakeLock!!.isHeld) {
|
* Acquires the wake lock if it's not held.
|
||||||
wakeLock!!.release()
|
*/
|
||||||
wakeLock = null
|
fun PowerManager.WakeLock.acquireIfNeeded() {
|
||||||
}
|
if (!isHeld) acquire()
|
||||||
}
|
|
||||||
|
|
||||||
fun acquireWakeLock() {
|
|
||||||
if (wakeLock != null && !wakeLock!!.isHeld) {
|
|
||||||
wakeLock!!.acquire()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun releaseWakeLock() {
|
|
||||||
if (wakeLock != null && wakeLock!!.isHeld) {
|
|
||||||
wakeLock!!.release()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,135 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
|
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to persist active downloads across application restarts.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
*/
|
||||||
|
class DownloadStore(context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preference file where active downloads are stored.
|
||||||
|
*/
|
||||||
|
private val preferences = context.getSharedPreferences("active_downloads", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gson instance to serialize/deserialize downloads.
|
||||||
|
*/
|
||||||
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source manager.
|
||||||
|
*/
|
||||||
|
private val sourceManager: SourceManager by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database helper.
|
||||||
|
*/
|
||||||
|
private val db: DatabaseHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counter used to keep the queue order.
|
||||||
|
*/
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a list of downloads to the store.
|
||||||
|
*
|
||||||
|
* @param downloads the list of downloads to add.
|
||||||
|
*/
|
||||||
|
fun addAll(downloads: List<Download>) {
|
||||||
|
val editor = preferences.edit()
|
||||||
|
downloads.forEach { editor.putString(getKey(it), serialize(it)) }
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a download from the store.
|
||||||
|
*
|
||||||
|
* @param download the download to remove.
|
||||||
|
*/
|
||||||
|
fun remove(download: Download) {
|
||||||
|
preferences.edit().remove(getKey(download)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all the downloads from the store.
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
preferences.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the preference's key for the given download.
|
||||||
|
*
|
||||||
|
* @param download the download.
|
||||||
|
*/
|
||||||
|
private fun getKey(download: Download): String {
|
||||||
|
return download.chapter.id!!.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of downloads to restore. It should be called in a background thread.
|
||||||
|
*/
|
||||||
|
fun restore(): List<Download> {
|
||||||
|
val objs = preferences.all
|
||||||
|
.mapNotNull { it.value as? String }
|
||||||
|
.map { deserialize(it) }
|
||||||
|
.sortedBy { it.order }
|
||||||
|
|
||||||
|
val downloads = mutableListOf<Download>()
|
||||||
|
if (objs.isNotEmpty()) {
|
||||||
|
val cachedManga = mutableMapOf<Long, Manga?>()
|
||||||
|
for ((mangaId, chapterId) in objs) {
|
||||||
|
val manga = cachedManga.getOrPut(mangaId) {
|
||||||
|
db.getManga(mangaId).executeAsBlocking()
|
||||||
|
} ?: continue
|
||||||
|
val source = sourceManager.get(manga.source) as? OnlineSource ?: continue
|
||||||
|
val chapter = db.getChapter(chapterId).executeAsBlocking() ?: continue
|
||||||
|
downloads.add(Download(source, manga, chapter))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the store, downloads will be added again immediately.
|
||||||
|
clear()
|
||||||
|
return downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a download to a string.
|
||||||
|
*
|
||||||
|
* @param download the download to serialize.
|
||||||
|
*/
|
||||||
|
private fun serialize(download: Download): String {
|
||||||
|
val obj = DownloadObject(download.manga.id!!, download.chapter.id!!, counter++)
|
||||||
|
return gson.toJson(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a download from a string.
|
||||||
|
*
|
||||||
|
* @param string the download as string.
|
||||||
|
*/
|
||||||
|
private fun deserialize(string: String): DownloadObject {
|
||||||
|
return gson.fromJson(string, DownloadObject::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used for download serialization
|
||||||
|
*
|
||||||
|
* @param mangaId the id of the manga.
|
||||||
|
* @param chapterId the id of the chapter.
|
||||||
|
* @param order the order of the download in the queue.
|
||||||
|
*/
|
||||||
|
data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int)
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,431 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
import com.jakewharton.rxrelay.BehaviorRelay
|
||||||
|
import com.jakewharton.rxrelay.PublishRelay
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
|
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||||
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
|
import eu.kanade.tachiyomi.util.DynamicConcurrentMergeOperator
|
||||||
|
import eu.kanade.tachiyomi.util.RetryWithDelay
|
||||||
|
import eu.kanade.tachiyomi.util.plusAssign
|
||||||
|
import eu.kanade.tachiyomi.util.saveTo
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
|
import rx.schedulers.Schedulers
|
||||||
|
import rx.subjects.BehaviorSubject
|
||||||
|
import rx.subscriptions.CompositeSubscription
|
||||||
|
import timber.log.Timber
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.net.URLConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is the one in charge of downloading chapters.
|
||||||
|
*
|
||||||
|
* Its [queue] contains the list of chapters to download. In order to download them, the downloader
|
||||||
|
* subscriptions must be running and the list of chapters must be sent to them by [downloadsRelay].
|
||||||
|
*
|
||||||
|
* The queue manipulation must be done in one thread (currently the main thread) to avoid unexpected
|
||||||
|
* behavior, but it's safe to read it from multiple threads.
|
||||||
|
*
|
||||||
|
* @param context the application context.
|
||||||
|
* @param provider the downloads directory provider.
|
||||||
|
*/
|
||||||
|
class Downloader(private val context: Context, private val provider: DownloadProvider) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store for persisting downloads across restarts.
|
||||||
|
*/
|
||||||
|
private val store = DownloadStore(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue where active downloads are kept.
|
||||||
|
*/
|
||||||
|
val queue = DownloadQueue(store)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source manager.
|
||||||
|
*/
|
||||||
|
private val sourceManager: SourceManager by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences.
|
||||||
|
*/
|
||||||
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifier for the downloader state and progress.
|
||||||
|
*/
|
||||||
|
private val notifier by lazy { DownloadNotifier(context) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloader subscriptions.
|
||||||
|
*/
|
||||||
|
private val subscriptions = CompositeSubscription()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject to do a live update of the number of simultaneous downloads.
|
||||||
|
*/
|
||||||
|
private val threadsSubject = BehaviorSubject.create<Int>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay to send a list of downloads to the downloader.
|
||||||
|
*/
|
||||||
|
private val downloadsRelay = PublishRelay.create<List<Download>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay to subscribe to the downloader status.
|
||||||
|
*/
|
||||||
|
val runningRelay: BehaviorRelay<Boolean> = BehaviorRelay.create(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the downloader is running.
|
||||||
|
*/
|
||||||
|
@Volatile private var isRunning: Boolean = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
Observable.fromCallable { store.restore() }
|
||||||
|
.map { downloads -> downloads.filter { isDownloadAllowed(it) } }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe({ downloads -> queue.addAll(downloads)
|
||||||
|
}, { error -> Timber.e(error) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the downloader. It doesn't do anything if it's already running or there isn't anything
|
||||||
|
* to download.
|
||||||
|
*
|
||||||
|
* @return true if the downloader is started, false otherwise.
|
||||||
|
*/
|
||||||
|
fun start(): Boolean {
|
||||||
|
if (isRunning || queue.isEmpty())
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (!subscriptions.hasSubscriptions())
|
||||||
|
initializeSubscriptions()
|
||||||
|
|
||||||
|
val pending = queue.filter { it.status != Download.DOWNLOADED }
|
||||||
|
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
|
||||||
|
|
||||||
|
downloadsRelay.call(pending)
|
||||||
|
return !pending.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the downloader.
|
||||||
|
*/
|
||||||
|
fun stop(reason: String? = null) {
|
||||||
|
destroySubscriptions()
|
||||||
|
queue
|
||||||
|
.filter { it.status == Download.DOWNLOADING }
|
||||||
|
.forEach { it.status = Download.ERROR }
|
||||||
|
|
||||||
|
if (reason != null) {
|
||||||
|
notifier.onWarning(reason)
|
||||||
|
} else {
|
||||||
|
notifier.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes everything from the queue.
|
||||||
|
*/
|
||||||
|
fun clearQueue() {
|
||||||
|
destroySubscriptions()
|
||||||
|
queue.clear()
|
||||||
|
notifier.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepares the subscriptions to start downloading.
|
||||||
|
*/
|
||||||
|
private fun initializeSubscriptions() {
|
||||||
|
if (isRunning) return
|
||||||
|
isRunning = true
|
||||||
|
runningRelay.call(true)
|
||||||
|
|
||||||
|
subscriptions.clear()
|
||||||
|
|
||||||
|
subscriptions += preferences.downloadThreads().asObservable()
|
||||||
|
.subscribe {
|
||||||
|
threadsSubject.onNext(it)
|
||||||
|
notifier.multipleDownloadThreads = it > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions += downloadsRelay.flatMap { Observable.from(it) }
|
||||||
|
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
|
||||||
|
.onBackpressureBuffer()
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe({ completeDownload(it)
|
||||||
|
}, { error ->
|
||||||
|
DownloadService.stop(context)
|
||||||
|
Timber.e(error)
|
||||||
|
notifier.onError(error.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the downloader subscriptions.
|
||||||
|
*/
|
||||||
|
private fun destroySubscriptions() {
|
||||||
|
if (!isRunning) return
|
||||||
|
isRunning = false
|
||||||
|
runningRelay.call(false)
|
||||||
|
|
||||||
|
subscriptions.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a download object for every chapter and adds them to the downloads queue. This method
|
||||||
|
* must be called in the main thread.
|
||||||
|
*
|
||||||
|
* @param manga the manga of the chapters to download.
|
||||||
|
* @param chapters the list of chapters to download.
|
||||||
|
*/
|
||||||
|
fun queueChapters(manga: Manga, chapters: List<Chapter>) {
|
||||||
|
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
|
||||||
|
|
||||||
|
val chaptersToQueue = chapters
|
||||||
|
// Avoid downloading chapters with the same name.
|
||||||
|
.distinctBy { it.name }
|
||||||
|
// Add chapters to queue from the start.
|
||||||
|
.sortedByDescending { it.source_order }
|
||||||
|
// Create a downloader for each one.
|
||||||
|
.map { Download(source, manga, it) }
|
||||||
|
// Filter out those already queued or downloaded.
|
||||||
|
.filter { isDownloadAllowed(it) }
|
||||||
|
|
||||||
|
// Return if there's nothing to queue.
|
||||||
|
if (chaptersToQueue.isEmpty())
|
||||||
|
return
|
||||||
|
|
||||||
|
queue.addAll(chaptersToQueue)
|
||||||
|
|
||||||
|
// Initialize queue size.
|
||||||
|
notifier.initialQueueSize = queue.size
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
// Send the list of downloads to the downloader.
|
||||||
|
downloadsRelay.call(chaptersToQueue)
|
||||||
|
} else {
|
||||||
|
// Show initial notification.
|
||||||
|
notifier.onProgressChange(queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given download can be queued and downloaded.
|
||||||
|
*
|
||||||
|
* @param download the download to be checked.
|
||||||
|
*/
|
||||||
|
private fun isDownloadAllowed(download: Download): Boolean {
|
||||||
|
// If the chapter is already queued, don't add it again
|
||||||
|
if (queue.any { it.chapter.id == download.chapter.id })
|
||||||
|
return false
|
||||||
|
|
||||||
|
val dir = provider.findChapterDir(download.source, download.manga, download.chapter)
|
||||||
|
if (dir != null && dir.exists())
|
||||||
|
return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the observable which downloads a chapter.
|
||||||
|
*
|
||||||
|
* @param download the chapter to be downloaded.
|
||||||
|
*/
|
||||||
|
private fun downloadChapter(download: Download): Observable<Download> {
|
||||||
|
val chapterDirname = provider.getChapterDirName(download.chapter)
|
||||||
|
val mangaDir = provider.getMangaDir(download.source, download.manga)
|
||||||
|
val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp")
|
||||||
|
|
||||||
|
val pageListObservable = if (download.pages == null) {
|
||||||
|
// Pull page list from network and add them to download object
|
||||||
|
download.source.fetchPageListFromNetwork(download.chapter)
|
||||||
|
.doOnNext { pages ->
|
||||||
|
download.pages = pages
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Or if the page list already exists, start from the file
|
||||||
|
Observable.just(download.pages!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageListObservable
|
||||||
|
.doOnNext { pages ->
|
||||||
|
// Delete all temporary (unfinished) files
|
||||||
|
tmpDir.listFiles()
|
||||||
|
?.filter { it.name!!.endsWith(".tmp") }
|
||||||
|
?.forEach { it.delete() }
|
||||||
|
|
||||||
|
download.downloadedImages = 0
|
||||||
|
download.status = Download.DOWNLOADING
|
||||||
|
}
|
||||||
|
// Get all the URLs to the source images, fetch pages if necessary
|
||||||
|
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
|
||||||
|
// Start downloading images, consider we can have downloaded images already
|
||||||
|
.concatMap { page -> getOrDownloadImage(page, download, tmpDir) }
|
||||||
|
// Do when page is downloaded.
|
||||||
|
.doOnNext { notifier.onProgressChange(download, queue) }
|
||||||
|
.toList()
|
||||||
|
.map { pages -> download }
|
||||||
|
// Do after download completes
|
||||||
|
.doOnNext { ensureSuccessfulDownload(download, tmpDir, chapterDirname) }
|
||||||
|
// If the page list threw, it will resume here
|
||||||
|
.onErrorReturn { error ->
|
||||||
|
download.status = Download.ERROR
|
||||||
|
notifier.onError(error.message, download.chapter.name)
|
||||||
|
download
|
||||||
|
}
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the observable which gets the image from the filesystem if it exists or downloads it
|
||||||
|
* otherwise.
|
||||||
|
*
|
||||||
|
* @param page the page to download.
|
||||||
|
* @param download the download of the page.
|
||||||
|
* @param tmpDir the temporary directory of the download.
|
||||||
|
*/
|
||||||
|
private fun getOrDownloadImage(page: Page, download: Download, tmpDir: UniFile): Observable<Page> {
|
||||||
|
// If the image URL is empty, do nothing
|
||||||
|
if (page.imageUrl == null)
|
||||||
|
return Observable.just(page)
|
||||||
|
|
||||||
|
val filename = String.format("%03d", page.number)
|
||||||
|
val tmpFile = tmpDir.findFile("$filename.tmp")
|
||||||
|
|
||||||
|
// Delete temp file if it exists.
|
||||||
|
tmpFile?.delete()
|
||||||
|
|
||||||
|
// Try to find the image file.
|
||||||
|
val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.")}
|
||||||
|
|
||||||
|
// If the image is already downloaded, do nothing. Otherwise download from network
|
||||||
|
val pageObservable = if (imageFile != null)
|
||||||
|
Observable.just(imageFile)
|
||||||
|
else
|
||||||
|
downloadImage(page, download.source, tmpDir, filename)
|
||||||
|
|
||||||
|
return pageObservable
|
||||||
|
// When the image is ready, set image path, progress (just in case) and status
|
||||||
|
.doOnNext { file ->
|
||||||
|
page.uri = file.uri
|
||||||
|
page.progress = 100
|
||||||
|
download.downloadedImages++
|
||||||
|
page.status = Page.READY
|
||||||
|
}
|
||||||
|
.map { page }
|
||||||
|
// Mark this page as error and allow to download the remaining
|
||||||
|
.onErrorReturn {
|
||||||
|
page.progress = 0
|
||||||
|
page.status = Page.ERROR
|
||||||
|
page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the observable which downloads the image from network.
|
||||||
|
*
|
||||||
|
* @param page the page to download.
|
||||||
|
* @param source the source of the page.
|
||||||
|
* @param tmpDir the temporary directory of the download.
|
||||||
|
* @param filename the filename of the image.
|
||||||
|
*/
|
||||||
|
private fun downloadImage(page: Page, source: OnlineSource, tmpDir: UniFile, filename: String): Observable<UniFile> {
|
||||||
|
page.status = Page.DOWNLOAD_IMAGE
|
||||||
|
page.progress = 0
|
||||||
|
return source.imageResponse(page)
|
||||||
|
.map { response ->
|
||||||
|
val file = tmpDir.createFile("$filename.tmp")
|
||||||
|
try {
|
||||||
|
response.body().source().saveTo(file.openOutputStream())
|
||||||
|
val extension = getImageExtension(response, file)
|
||||||
|
file.renameTo("$filename.$extension")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
response.close()
|
||||||
|
file.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
file
|
||||||
|
}
|
||||||
|
// Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
|
||||||
|
.retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the extension of the downloaded image from the network response, or if it's null,
|
||||||
|
* analyze the file. If everything fails, assume it's a jpg.
|
||||||
|
*
|
||||||
|
* @param response the network response of the image.
|
||||||
|
* @param file the file where the image is already downloaded.
|
||||||
|
*/
|
||||||
|
private fun getImageExtension(response: Response, file: UniFile): String {
|
||||||
|
// Read content type if available.
|
||||||
|
val mime = response.body().contentType()?.let { ct -> "${ct.type()}/${ct.subtype()}" }
|
||||||
|
// Else guess from the uri.
|
||||||
|
?: context.contentResolver.getType(file.uri)
|
||||||
|
// Else read magic numbers.
|
||||||
|
?: file.openInputStream().buffered().use {
|
||||||
|
URLConnection.guessContentTypeFromStream(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the download was successful.
|
||||||
|
*
|
||||||
|
* @param download the download to check.
|
||||||
|
* @param tmpDir the directory where the download is currently stored.
|
||||||
|
* @param dirname the real (non temporary) directory name of the download.
|
||||||
|
*/
|
||||||
|
private fun ensureSuccessfulDownload(download: Download, tmpDir: UniFile, dirname: String) {
|
||||||
|
// Ensure that the chapter folder has all the images.
|
||||||
|
val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") }
|
||||||
|
|
||||||
|
download.status = if (downloadedImages.size == download.pages!!.size) {
|
||||||
|
Download.DOWNLOADED
|
||||||
|
} else {
|
||||||
|
Download.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only rename the directory if it's downloaded.
|
||||||
|
if (download.status == Download.DOWNLOADED) {
|
||||||
|
tmpDir.renameTo(dirname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes a download. This method is called in the main thread.
|
||||||
|
*/
|
||||||
|
private fun completeDownload(download: Download) {
|
||||||
|
// Delete successful downloads from queue
|
||||||
|
if (download.status == Download.DOWNLOADED) {
|
||||||
|
// remove downloaded chapter from queue
|
||||||
|
queue.remove(download)
|
||||||
|
notifier.onProgressChange(queue)
|
||||||
|
}
|
||||||
|
if (areAllDownloadsFinished()) {
|
||||||
|
DownloadService.stop(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if all the queued downloads are in DOWNLOADED or ERROR state.
|
||||||
|
*/
|
||||||
|
private fun areAllDownloadsFinished(): Boolean {
|
||||||
|
return queue.none { it.status <= Download.DOWNLOADING }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -5,12 +5,9 @@ import eu.kanade.tachiyomi.data.database.models.Manga
|
|||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
import rx.subjects.PublishSubject
|
import rx.subjects.PublishSubject
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
|
class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
|
||||||
|
|
||||||
lateinit var directory: File
|
|
||||||
|
|
||||||
var pages: List<Page>? = null
|
var pages: List<Page>? = null
|
||||||
|
|
||||||
@Volatile @Transient var totalProgress: Int = 0
|
@Volatile @Transient var totalProgress: Int = 0
|
||||||
|
@ -1,38 +1,52 @@
|
|||||||
package eu.kanade.tachiyomi.data.download.model
|
package eu.kanade.tachiyomi.data.download.model
|
||||||
|
|
||||||
|
import com.jakewharton.rxrelay.PublishRelay
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
|
import eu.kanade.tachiyomi.data.download.DownloadStore
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.subjects.PublishSubject
|
import rx.subjects.PublishSubject
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
|
class DownloadQueue(
|
||||||
|
private val store: DownloadStore,
|
||||||
|
private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>())
|
||||||
: List<Download> by queue {
|
: List<Download> by queue {
|
||||||
|
|
||||||
private val statusSubject = PublishSubject.create<Download>()
|
private val statusSubject = PublishSubject.create<Download>()
|
||||||
|
|
||||||
private val removeSubject = PublishSubject.create<Download>()
|
private val updatedRelay = PublishRelay.create<Unit>()
|
||||||
|
|
||||||
fun add(download: Download): Boolean {
|
fun addAll(downloads: List<Download>) {
|
||||||
|
downloads.forEach { download ->
|
||||||
download.setStatusSubject(statusSubject)
|
download.setStatusSubject(statusSubject)
|
||||||
download.status = Download.QUEUE
|
download.status = Download.QUEUE
|
||||||
return queue.add(download)
|
}
|
||||||
|
queue.addAll(downloads)
|
||||||
|
store.addAll(downloads)
|
||||||
|
updatedRelay.call(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun del(download: Download) {
|
fun remove(download: Download) {
|
||||||
val removed = queue.remove(download)
|
val removed = queue.remove(download)
|
||||||
|
store.remove(download)
|
||||||
download.setStatusSubject(null)
|
download.setStatusSubject(null)
|
||||||
if (removed) {
|
if (removed) {
|
||||||
removeSubject.onNext(download)
|
updatedRelay.call(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun del(chapter: Chapter) {
|
fun remove(chapter: Chapter) {
|
||||||
find { it.chapter.id == chapter.id }?.let { del(it) }
|
find { it.chapter.id == chapter.id }?.let { remove(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
queue.forEach { del(it) }
|
queue.forEach { download ->
|
||||||
|
download.setStatusSubject(null)
|
||||||
|
}
|
||||||
|
queue.clear()
|
||||||
|
store.clear()
|
||||||
|
updatedRelay.call(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getActiveDownloads(): Observable<Download> =
|
fun getActiveDownloads(): Observable<Download> =
|
||||||
@ -40,7 +54,9 @@ class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayL
|
|||||||
|
|
||||||
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
|
fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer()
|
||||||
|
|
||||||
fun getRemovedObservable(): Observable<Download> = removeSubject.onBackpressureBuffer()
|
fun getUpdatedObservable(): Observable<List<Download>> = updatedRelay.onBackpressureBuffer()
|
||||||
|
.startWith(Unit)
|
||||||
|
.map { this }
|
||||||
|
|
||||||
fun getProgressObservable(): Observable<Download> {
|
fun getProgressObservable(): Observable<Download> {
|
||||||
return statusSubject.onBackpressureBuffer()
|
return statusSubject.onBackpressureBuffer()
|
||||||
|
@ -1,83 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.library
|
|
||||||
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.SystemClock
|
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
|
||||||
import eu.kanade.tachiyomi.util.alarmManager
|
|
||||||
import uy.kohesive.injekt.Injekt
|
|
||||||
import uy.kohesive.injekt.api.get
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is used to update the library by firing an alarm after a specified time.
|
|
||||||
* It has a receiver reacting to system's boot and the intent fired by this alarm.
|
|
||||||
* See [onReceive] for more information.
|
|
||||||
*/
|
|
||||||
class LibraryUpdateAlarm : BroadcastReceiver() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val LIBRARY_UPDATE_ACTION = "eu.kanade.UPDATE_LIBRARY"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the alarm to run the intent that updates the library.
|
|
||||||
* @param context the application context.
|
|
||||||
* @param intervalInHours the time in hours when it will be executed. Defaults to the
|
|
||||||
* value stored in preferences.
|
|
||||||
*/
|
|
||||||
fun startAlarm(context: Context,
|
|
||||||
intervalInHours: Int = Injekt.get<PreferencesHelper>().libraryUpdateInterval().getOrDefault()) {
|
|
||||||
// Stop previous running alarms if needed, and do not restart it if the interval is 0.
|
|
||||||
stopAlarm(context)
|
|
||||||
if (intervalInHours == 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
// Get the time the alarm should fire the event to update.
|
|
||||||
val intervalInMillis = intervalInHours * 60 * 60 * 1000
|
|
||||||
val nextRun = SystemClock.elapsedRealtime() + intervalInMillis
|
|
||||||
|
|
||||||
// Start the alarm.
|
|
||||||
val pendingIntent = getPendingIntent(context)
|
|
||||||
context.alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
|
||||||
nextRun, intervalInMillis.toLong(), pendingIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops the alarm if it's running.
|
|
||||||
* @param context the application context.
|
|
||||||
*/
|
|
||||||
fun stopAlarm(context: Context) {
|
|
||||||
val pendingIntent = getPendingIntent(context)
|
|
||||||
context.alarmManager.cancel(pendingIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the intent the alarm should run when it's fired.
|
|
||||||
* @param context the application context.
|
|
||||||
* @return the intent that will run when the alarm is fired.
|
|
||||||
*/
|
|
||||||
private fun getPendingIntent(context: Context): PendingIntent {
|
|
||||||
val intent = Intent(context, LibraryUpdateAlarm::class.java)
|
|
||||||
intent.action = LIBRARY_UPDATE_ACTION
|
|
||||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the intents received by this [BroadcastReceiver].
|
|
||||||
* @param context the application context.
|
|
||||||
* @param intent the intent to process.
|
|
||||||
*/
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
when (intent.action) {
|
|
||||||
// Start the alarm when the system is booted.
|
|
||||||
Intent.ACTION_BOOT_COMPLETED -> startAlarm(context)
|
|
||||||
// Update the library when the alarm fires an event.
|
|
||||||
LIBRARY_UPDATE_ACTION -> LibraryUpdateService.start(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,48 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.library
|
||||||
|
|
||||||
|
import com.evernote.android.job.Job
|
||||||
|
import com.evernote.android.job.JobManager
|
||||||
|
import com.evernote.android.job.JobRequest
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class LibraryUpdateJob : Job() {
|
||||||
|
|
||||||
|
override fun onRunJob(params: Params): Result {
|
||||||
|
LibraryUpdateService.start(context)
|
||||||
|
return Job.Result.SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "LibraryUpdate"
|
||||||
|
|
||||||
|
fun setupTask(prefInterval: Int? = null) {
|
||||||
|
val preferences = Injekt.get<PreferencesHelper>()
|
||||||
|
val interval = prefInterval ?: preferences.libraryUpdateInterval().getOrDefault()
|
||||||
|
if (interval > 0) {
|
||||||
|
val restrictions = preferences.libraryUpdateRestriction()
|
||||||
|
val acRestriction = "ac" in restrictions
|
||||||
|
val wifiRestriction = if ("wifi" in restrictions)
|
||||||
|
JobRequest.NetworkType.UNMETERED
|
||||||
|
else
|
||||||
|
JobRequest.NetworkType.CONNECTED
|
||||||
|
|
||||||
|
JobRequest.Builder(TAG)
|
||||||
|
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
|
||||||
|
.setRequiredNetworkType(wifiRestriction)
|
||||||
|
.setRequiresCharging(acRestriction)
|
||||||
|
.setRequirementsEnforced(true)
|
||||||
|
.setPersisted(true)
|
||||||
|
.setUpdateCurrent(true)
|
||||||
|
.build()
|
||||||
|
.schedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelTask() {
|
||||||
|
JobManager.instance().cancelAllForTag(TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -5,11 +5,10 @@ import android.app.Service
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.support.v4.app.NotificationCompat
|
import android.support.v4.app.NotificationCompat
|
||||||
import com.github.pwittchen.reactivenetwork.library.ConnectivityStatus
|
|
||||||
import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork
|
|
||||||
import eu.kanade.tachiyomi.Constants
|
import eu.kanade.tachiyomi.Constants
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
@ -17,6 +16,7 @@ import eu.kanade.tachiyomi.data.database.models.Category
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.data.source.SourceManager
|
import eu.kanade.tachiyomi.data.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||||
@ -69,18 +69,22 @@ class LibraryUpdateService : Service() {
|
|||||||
private val notificationId: Int
|
private val notificationId: Int
|
||||||
get() = Constants.NOTIFICATION_LIBRARY_ID
|
get() = Constants.NOTIFICATION_LIBRARY_ID
|
||||||
|
|
||||||
|
private val notificationBitmap by lazy {
|
||||||
|
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
|
||||||
* Key for manual library update.
|
|
||||||
*/
|
|
||||||
const val UPDATE_IS_MANUAL = "is_manual"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Key for category to update.
|
* Key for category to update.
|
||||||
*/
|
*/
|
||||||
const val UPDATE_CATEGORY = "category"
|
const val UPDATE_CATEGORY = "category"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key for updating the details instead of the chapters.
|
||||||
|
*/
|
||||||
|
const val UPDATE_DETAILS = "details"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the status of the service.
|
* Returns the status of the service.
|
||||||
*
|
*
|
||||||
@ -96,13 +100,13 @@ class LibraryUpdateService : Service() {
|
|||||||
* running.
|
* running.
|
||||||
*
|
*
|
||||||
* @param context the application context.
|
* @param context the application context.
|
||||||
* @param isManual whether the update has been manually triggered.
|
* @param category a specific category to update, or null for global update.
|
||||||
* @param category a specific category to update, or null for all in the library.
|
* @param details whether to update the details instead of the list of chapters.
|
||||||
*/
|
*/
|
||||||
fun start(context: Context, isManual: Boolean = false, category: Category? = null) {
|
fun start(context: Context, category: Category? = null, details: Boolean = false) {
|
||||||
if (!isRunning(context)) {
|
if (!isRunning(context)) {
|
||||||
val intent = Intent(context, LibraryUpdateService::class.java).apply {
|
val intent = Intent(context, LibraryUpdateService::class.java).apply {
|
||||||
putExtra(UPDATE_IS_MANUAL, isManual)
|
putExtra(UPDATE_DETAILS, details)
|
||||||
category?.let { putExtra(UPDATE_CATEGORY, it.id) }
|
category?.let { putExtra(UPDATE_CATEGORY, it.id) }
|
||||||
}
|
}
|
||||||
context.startService(intent)
|
context.startService(intent)
|
||||||
@ -135,7 +139,6 @@ class LibraryUpdateService : Service() {
|
|||||||
*/
|
*/
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
subscription?.unsubscribe()
|
subscription?.unsubscribe()
|
||||||
LibraryUpdateAlarm.startAlarm(this)
|
|
||||||
destroyWakeLock()
|
destroyWakeLock()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
@ -156,61 +159,32 @@ class LibraryUpdateService : Service() {
|
|||||||
* @return the start value of the command.
|
* @return the start value of the command.
|
||||||
*/
|
*/
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent == null) return Service.START_NOT_STICKY
|
||||||
// Get connectivity status
|
|
||||||
val connection = ReactiveNetwork().getConnectivityStatus(this, true)
|
|
||||||
|
|
||||||
// Get library update restrictions
|
|
||||||
val restrictions = preferences.libraryUpdateRestriction()
|
|
||||||
|
|
||||||
// Check if users updates library manual
|
|
||||||
val isManualUpdate = intent?.getBooleanExtra(UPDATE_IS_MANUAL, false) ?: false
|
|
||||||
|
|
||||||
// Whether to cancel the update.
|
|
||||||
var cancelUpdate = false
|
|
||||||
|
|
||||||
// Check if device has internet connection
|
|
||||||
// Check if device has wifi connection if only wifi is enabled
|
|
||||||
if (connection == ConnectivityStatus.OFFLINE || (!isManualUpdate && "wifi" in restrictions
|
|
||||||
&& connection != ConnectivityStatus.WIFI_CONNECTED_HAS_INTERNET)) {
|
|
||||||
|
|
||||||
if (isManualUpdate) {
|
|
||||||
toast(R.string.notification_no_connection_title)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable library update when connection available
|
|
||||||
AndroidComponentUtil.toggleComponent(this, SyncOnConnectionAvailable::class.java, true)
|
|
||||||
cancelUpdate = true
|
|
||||||
}
|
|
||||||
if (!isManualUpdate && "ac" in restrictions && !DeviceUtil.isPowerConnected(this)) {
|
|
||||||
AndroidComponentUtil.toggleComponent(this, SyncOnPowerConnected::class.java, true)
|
|
||||||
cancelUpdate = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelUpdate) {
|
|
||||||
stopSelf(startId)
|
|
||||||
return Service.START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop enabled components.
|
|
||||||
AndroidComponentUtil.toggleComponent(this, SyncOnConnectionAvailable::class.java, false)
|
|
||||||
AndroidComponentUtil.toggleComponent(this, SyncOnPowerConnected::class.java, false)
|
|
||||||
|
|
||||||
// Unsubscribe from any previous subscription if needed.
|
// Unsubscribe from any previous subscription if needed.
|
||||||
subscription?.unsubscribe()
|
subscription?.unsubscribe()
|
||||||
|
|
||||||
// Update favorite manga. Destroy service when completed or in case of an error.
|
// Update favorite manga. Destroy service when completed or in case of an error.
|
||||||
subscription = Observable.defer { updateMangaList(getMangaToUpdate(intent)) }
|
subscription = Observable
|
||||||
|
.defer {
|
||||||
|
val mangaList = getMangaToUpdate(intent)
|
||||||
|
|
||||||
|
// Update either chapter list or manga details.
|
||||||
|
if (!intent.getBooleanExtra(UPDATE_DETAILS, false))
|
||||||
|
updateChapterList(mangaList)
|
||||||
|
else
|
||||||
|
updateDetails(mangaList)
|
||||||
|
}
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.subscribe({},
|
.subscribe({
|
||||||
{
|
}, {
|
||||||
showNotification(getString(R.string.notification_update_error), "")
|
showNotification(getString(R.string.notification_update_error), "")
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
}, {
|
}, {
|
||||||
stopSelf(startId)
|
stopSelf(startId)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.START_STICKY
|
return Service.START_REDELIVER_INTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -219,19 +193,26 @@ class LibraryUpdateService : Service() {
|
|||||||
* @param intent the update intent.
|
* @param intent the update intent.
|
||||||
* @return a list of manga to update
|
* @return a list of manga to update
|
||||||
*/
|
*/
|
||||||
fun getMangaToUpdate(intent: Intent?): List<Manga> {
|
fun getMangaToUpdate(intent: Intent): List<Manga> {
|
||||||
val categoryId = intent?.getIntExtra(UPDATE_CATEGORY, -1) ?: -1
|
val categoryId = intent.getIntExtra(UPDATE_CATEGORY, -1)
|
||||||
|
|
||||||
var toUpdate = if (categoryId != -1)
|
var listToUpdate = if (categoryId != -1)
|
||||||
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
|
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
|
||||||
|
else {
|
||||||
|
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map { it.toInt() }
|
||||||
|
if (categoriesToUpdate.isNotEmpty())
|
||||||
|
db.getLibraryMangas().executeAsBlocking()
|
||||||
|
.filter { it.category in categoriesToUpdate }
|
||||||
|
.distinctBy { it.id }
|
||||||
else
|
else
|
||||||
db.getFavoriteMangas().executeAsBlocking()
|
db.getFavoriteMangas().executeAsBlocking().distinctBy { it.id }
|
||||||
|
|
||||||
if (preferences.updateOnlyNonCompleted()) {
|
|
||||||
toUpdate = toUpdate.filter { it.status != Manga.COMPLETED }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return toUpdate
|
if (!intent.getBooleanExtra(UPDATE_DETAILS, false) && preferences.updateOnlyNonCompleted()) {
|
||||||
|
listToUpdate = listToUpdate.filter { it.status != Manga.COMPLETED }
|
||||||
|
}
|
||||||
|
|
||||||
|
return listToUpdate
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -243,7 +224,7 @@ class LibraryUpdateService : Service() {
|
|||||||
* @param mangaToUpdate the list to update
|
* @param mangaToUpdate the list to update
|
||||||
* @return an observable delivering the progress of each update.
|
* @return an observable delivering the progress of each update.
|
||||||
*/
|
*/
|
||||||
fun updateMangaList(mangaToUpdate: List<Manga>): Observable<Manga> {
|
fun updateChapterList(mangaToUpdate: List<Manga>): Observable<Manga> {
|
||||||
// Initialize the variables holding the progress of the updates.
|
// Initialize the variables holding the progress of the updates.
|
||||||
val count = AtomicInteger(0)
|
val count = AtomicInteger(0)
|
||||||
val newUpdates = ArrayList<Manga>()
|
val newUpdates = ArrayList<Manga>()
|
||||||
@ -293,6 +274,43 @@ class LibraryUpdateService : Service() {
|
|||||||
.map { syncChaptersWithSource(db, it, manga, source) }
|
.map { syncChaptersWithSource(db, it, manga, source) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that updates the details of the given list of manga. It's called in a background
|
||||||
|
* thread, so it's safe to do heavy operations or network calls here.
|
||||||
|
* For each manga it calls [updateManga] and updates the notification showing the current
|
||||||
|
* progress.
|
||||||
|
*
|
||||||
|
* @param mangaToUpdate the list to update
|
||||||
|
* @return an observable delivering the progress of each update.
|
||||||
|
*/
|
||||||
|
fun updateDetails(mangaToUpdate: List<Manga>): Observable<Manga> {
|
||||||
|
// Initialize the variables holding the progress of the updates.
|
||||||
|
val count = AtomicInteger(0)
|
||||||
|
|
||||||
|
val cancelIntent = PendingIntent.getBroadcast(this, 0,
|
||||||
|
Intent(this, CancelUpdateReceiver::class.java), 0)
|
||||||
|
|
||||||
|
// Emit each manga and update it sequentially.
|
||||||
|
return Observable.from(mangaToUpdate)
|
||||||
|
// Notify manga that will update.
|
||||||
|
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelIntent) }
|
||||||
|
// Update the details of the manga.
|
||||||
|
.concatMap { manga ->
|
||||||
|
val source = sourceManager.get(manga.source) as? OnlineSource
|
||||||
|
?: return@concatMap Observable.empty<Manga>()
|
||||||
|
|
||||||
|
source.fetchMangaDetails(manga)
|
||||||
|
.doOnNext { networkManga ->
|
||||||
|
manga.copyFrom(networkManga)
|
||||||
|
db.insertManga(manga).executeAsBlocking()
|
||||||
|
}
|
||||||
|
.onErrorReturn { manga }
|
||||||
|
}
|
||||||
|
.doOnCompleted {
|
||||||
|
cancelNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the text that will be displayed in the notification when there are new chapters.
|
* Returns the text that will be displayed in the notification when there are new chapters.
|
||||||
*
|
*
|
||||||
@ -301,7 +319,7 @@ class LibraryUpdateService : Service() {
|
|||||||
* @return the body of the notification to display.
|
* @return the body of the notification to display.
|
||||||
*/
|
*/
|
||||||
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
|
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
|
||||||
return with(StringBuilder()) {
|
return buildString {
|
||||||
if (updates.isEmpty()) {
|
if (updates.isEmpty()) {
|
||||||
append(getString(R.string.notification_no_new_chapters))
|
append(getString(R.string.notification_no_new_chapters))
|
||||||
append("\n")
|
append("\n")
|
||||||
@ -309,7 +327,7 @@ class LibraryUpdateService : Service() {
|
|||||||
append(getString(R.string.notification_new_chapters))
|
append(getString(R.string.notification_new_chapters))
|
||||||
for (manga in updates) {
|
for (manga in updates) {
|
||||||
append("\n")
|
append("\n")
|
||||||
append(manga.title)
|
append(manga.title.chop(45))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!failedUpdates.isEmpty()) {
|
if (!failedUpdates.isEmpty()) {
|
||||||
@ -317,10 +335,9 @@ class LibraryUpdateService : Service() {
|
|||||||
append(getString(R.string.notification_manga_update_failed))
|
append(getString(R.string.notification_manga_update_failed))
|
||||||
for (manga in failedUpdates) {
|
for (manga in failedUpdates) {
|
||||||
append("\n")
|
append("\n")
|
||||||
append(manga.title)
|
append(manga.title.chop(45))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
toString()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -349,8 +366,9 @@ class LibraryUpdateService : Service() {
|
|||||||
* @param body the body of the notification.
|
* @param body the body of the notification.
|
||||||
*/
|
*/
|
||||||
private fun showNotification(title: String, body: String) {
|
private fun showNotification(title: String, body: String) {
|
||||||
notificationManager.notify(notificationId, notification() {
|
notificationManager.notify(notificationId, notification {
|
||||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||||
|
setLargeIcon(notificationBitmap)
|
||||||
setContentTitle(title)
|
setContentTitle(title)
|
||||||
setContentText(body)
|
setContentText(body)
|
||||||
})
|
})
|
||||||
@ -364,8 +382,9 @@ class LibraryUpdateService : Service() {
|
|||||||
* @param total the total progress.
|
* @param total the total progress.
|
||||||
*/
|
*/
|
||||||
private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) {
|
private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) {
|
||||||
notificationManager.notify(notificationId, notification() {
|
notificationManager.notify(notificationId, notification {
|
||||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||||
|
setLargeIcon(notificationBitmap)
|
||||||
setContentTitle(manga.title)
|
setContentTitle(manga.title)
|
||||||
setProgress(total, current, false)
|
setProgress(total, current, false)
|
||||||
setOngoing(true)
|
setOngoing(true)
|
||||||
@ -384,8 +403,9 @@ class LibraryUpdateService : Service() {
|
|||||||
val title = getString(R.string.notification_update_completed)
|
val title = getString(R.string.notification_update_completed)
|
||||||
val body = getUpdatedMangasBody(updates, failed)
|
val body = getUpdatedMangasBody(updates, failed)
|
||||||
|
|
||||||
notificationManager.notify(notificationId, notification() {
|
notificationManager.notify(notificationId, notification {
|
||||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||||
|
setLargeIcon(notificationBitmap)
|
||||||
setContentTitle(title)
|
setContentTitle(title)
|
||||||
setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||||
setContentIntent(notificationIntent)
|
setContentIntent(notificationIntent)
|
||||||
@ -410,41 +430,6 @@ class LibraryUpdateService : Service() {
|
|||||||
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Class that triggers the library to update when a connection is available. It receives
|
|
||||||
* network changes.
|
|
||||||
*/
|
|
||||||
class SyncOnConnectionAvailable : BroadcastReceiver() {
|
|
||||||
/**
|
|
||||||
* Method called when a network change occurs.
|
|
||||||
*
|
|
||||||
* @param context the application context.
|
|
||||||
* @param intent the intent received.
|
|
||||||
*/
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (DeviceUtil.isNetworkConnected(context)) {
|
|
||||||
AndroidComponentUtil.toggleComponent(context, this.javaClass, false)
|
|
||||||
start(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class that triggers the library to update when connected to power.
|
|
||||||
*/
|
|
||||||
class SyncOnPowerConnected: BroadcastReceiver() {
|
|
||||||
/**
|
|
||||||
* Method called when AC is connected.
|
|
||||||
*
|
|
||||||
* @param context the application context.
|
|
||||||
* @param intent the intent received.
|
|
||||||
*/
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
AndroidComponentUtil.toggleComponent(context, this.javaClass, false)
|
|
||||||
start(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class that stops updating the library.
|
* Class that stops updating the library.
|
||||||
*/
|
*/
|
||||||
|
@ -1,16 +1,21 @@
|
|||||||
package eu.kanade.tachiyomi.data.mangasync
|
package eu.kanade.tachiyomi.data.mangasync
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
|
||||||
import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList
|
import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList
|
||||||
|
|
||||||
class MangaSyncManager(private val context: Context) {
|
class MangaSyncManager(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MYANIMELIST = 1
|
const val MYANIMELIST = 1
|
||||||
|
const val ANILIST = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
val myAnimeList = MyAnimeList(context, MYANIMELIST)
|
val myAnimeList = MyAnimeList(context, MYANIMELIST)
|
||||||
|
|
||||||
|
val aniList = Anilist(context, ANILIST)
|
||||||
|
|
||||||
|
// TODO enable anilist
|
||||||
val services = listOf(myAnimeList)
|
val services = listOf(myAnimeList)
|
||||||
|
|
||||||
fun getService(id: Int) = services.find { it.id == id }
|
fun getService(id: Int) = services.find { it.id == id }
|
||||||
|
@ -0,0 +1,132 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||||
|
import rx.Completable
|
||||||
|
import rx.Observable
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class Anilist(private val context: Context, id: Int) : MangaSyncService(context, id) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val READING = 1
|
||||||
|
const val COMPLETED = 2
|
||||||
|
const val ON_HOLD = 3
|
||||||
|
const val DROPPED = 4
|
||||||
|
const val PLAN_TO_READ = 5
|
||||||
|
|
||||||
|
const val DEFAULT_STATUS = READING
|
||||||
|
const val DEFAULT_SCORE = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override val name = "AniList"
|
||||||
|
|
||||||
|
private val interceptor by lazy { AnilistInterceptor(getPassword()) }
|
||||||
|
|
||||||
|
private val api by lazy {
|
||||||
|
AnilistApi.createService(networkService.client.newBuilder()
|
||||||
|
.addInterceptor(interceptor)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
|
fun login(authCode: String): Completable {
|
||||||
|
// Create a new api with the default client to avoid request interceptions.
|
||||||
|
return AnilistApi.createService(client)
|
||||||
|
// Request the access token from the API with the authorization code.
|
||||||
|
.requestAccessToken(authCode)
|
||||||
|
// Save the token in the interceptor.
|
||||||
|
.doOnNext { interceptor.setAuth(it) }
|
||||||
|
// Obtain the authenticated user from the API.
|
||||||
|
.zipWith(api.getCurrentUser().map { it["id"].toString() })
|
||||||
|
{ oauth, user -> Pair(user, oauth.refresh_token!!) }
|
||||||
|
// Save service credentials (username and refresh token).
|
||||||
|
.doOnNext { saveCredentials(it.first, it.second) }
|
||||||
|
// Logout on any error.
|
||||||
|
.doOnError { logout() }
|
||||||
|
.toCompletable()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logout() {
|
||||||
|
super.logout()
|
||||||
|
interceptor.setAuth(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(query: String): Observable<List<MangaSync>> {
|
||||||
|
return api.search(query, 1)
|
||||||
|
.flatMap { Observable.from(it) }
|
||||||
|
.filter { it.type != "Novel" }
|
||||||
|
.map { it.toMangaSync() }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getList(): Observable<List<MangaSync>> {
|
||||||
|
return api.getList(getUsername())
|
||||||
|
.flatMap { Observable.from(it.flatten()) }
|
||||||
|
.map { it.toMangaSync() }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun add(manga: MangaSync): Observable<MangaSync> {
|
||||||
|
return api.addManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
|
||||||
|
manga.score.toInt())
|
||||||
|
.doOnNext { it.body().close() }
|
||||||
|
.doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
|
||||||
|
.doOnError { Timber.e(it, it.message) }
|
||||||
|
.map { manga }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(manga: MangaSync): Observable<MangaSync> {
|
||||||
|
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
|
||||||
|
manga.status = COMPLETED
|
||||||
|
}
|
||||||
|
return api.updateManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
|
||||||
|
manga.score.toInt())
|
||||||
|
.doOnNext { it.body().close() }
|
||||||
|
.doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
|
||||||
|
.doOnError { Timber.e(it, it.message) }
|
||||||
|
.map { manga }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(manga: MangaSync): Observable<MangaSync> {
|
||||||
|
return getList()
|
||||||
|
.flatMap { userlist ->
|
||||||
|
manga.sync_id = id
|
||||||
|
val mangaFromList = userlist.find { it.remote_id == manga.remote_id }
|
||||||
|
if (mangaFromList != null) {
|
||||||
|
manga.copyPersonalFrom(mangaFromList)
|
||||||
|
update(manga)
|
||||||
|
} else {
|
||||||
|
// Set default fields if it's not found in the list
|
||||||
|
manga.score = DEFAULT_SCORE.toFloat()
|
||||||
|
manga.status = DEFAULT_STATUS
|
||||||
|
add(manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getStatus(status: Int): String = with(context) {
|
||||||
|
when (status) {
|
||||||
|
READING -> getString(R.string.reading)
|
||||||
|
COMPLETED -> getString(R.string.completed)
|
||||||
|
ON_HOLD -> getString(R.string.on_hold)
|
||||||
|
DROPPED -> getString(R.string.dropped)
|
||||||
|
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MangaSync.getAnilistStatus() = when (status) {
|
||||||
|
READING -> "reading"
|
||||||
|
COMPLETED -> "completed"
|
||||||
|
ON_HOLD -> "on-hold"
|
||||||
|
DROPPED -> "dropped"
|
||||||
|
PLAN_TO_READ -> "plan to read"
|
||||||
|
else -> throw NotImplementedError("Unknown status")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALManga
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALUserLists
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
|
||||||
|
import eu.kanade.tachiyomi.data.network.POST
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import retrofit2.http.*
|
||||||
|
import rx.Observable
|
||||||
|
|
||||||
|
interface AnilistApi {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val clientId = "tachiyomi-hrtje"
|
||||||
|
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
|
||||||
|
private const val clientUrl = "tachiyomi://anilist-auth"
|
||||||
|
private const val baseUrl = "https://anilist.co/api/"
|
||||||
|
|
||||||
|
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
|
||||||
|
.appendQueryParameter("grant_type", "authorization_code")
|
||||||
|
.appendQueryParameter("client_id", clientId)
|
||||||
|
.appendQueryParameter("redirect_uri", clientUrl)
|
||||||
|
.appendQueryParameter("response_type", "code")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
|
||||||
|
body = FormBody.Builder()
|
||||||
|
.add("grant_type", "refresh_token")
|
||||||
|
.add("client_id", clientId)
|
||||||
|
.add("client_secret", clientSecret)
|
||||||
|
.add("refresh_token", token)
|
||||||
|
.build())
|
||||||
|
|
||||||
|
fun createService(client: OkHttpClient) = Retrofit.Builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||||
|
.build()
|
||||||
|
.create(AnilistApi::class.java)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@FormUrlEncoded
|
||||||
|
@POST("auth/access_token")
|
||||||
|
fun requestAccessToken(
|
||||||
|
@Field("code") code: String,
|
||||||
|
@Field("grant_type") grant_type: String = "authorization_code",
|
||||||
|
@Field("client_id") client_id: String = clientId,
|
||||||
|
@Field("client_secret") client_secret: String = clientSecret,
|
||||||
|
@Field("redirect_uri") redirect_uri: String = clientUrl)
|
||||||
|
: Observable<OAuth>
|
||||||
|
|
||||||
|
@GET("user")
|
||||||
|
fun getCurrentUser(): Observable<JsonObject>
|
||||||
|
|
||||||
|
@GET("manga/search/{query}")
|
||||||
|
fun search(@Path("query") query: String, @Query("page") page: Int): Observable<List<ALManga>>
|
||||||
|
|
||||||
|
@GET("user/{username}/mangalist")
|
||||||
|
fun getList(@Path("username") username: String): Observable<ALUserLists>
|
||||||
|
|
||||||
|
@FormUrlEncoded
|
||||||
|
@PUT("mangalist")
|
||||||
|
fun addManga(
|
||||||
|
@Field("id") id: Int,
|
||||||
|
@Field("chapters_read") chapters_read: Int,
|
||||||
|
@Field("list_status") list_status: String,
|
||||||
|
@Field("score_raw") score_raw: Int)
|
||||||
|
: Observable<Response<ResponseBody>>
|
||||||
|
|
||||||
|
@FormUrlEncoded
|
||||||
|
@PUT("mangalist")
|
||||||
|
fun updateManga(
|
||||||
|
@Field("id") id: Int,
|
||||||
|
@Field("chapters_read") chapters_read: Int,
|
||||||
|
@Field("list_status") list_status: String,
|
||||||
|
@Field("score_raw") score_raw: Int)
|
||||||
|
: Observable<Response<ResponseBody>>
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth object used for authenticated requests.
|
||||||
|
*
|
||||||
|
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
|
||||||
|
* before its original expiration date.
|
||||||
|
*/
|
||||||
|
private var oauth: OAuth? = null
|
||||||
|
set(value) {
|
||||||
|
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
|
if (refreshToken.isNullOrEmpty()) {
|
||||||
|
throw Exception("Not authenticated with Anilist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh access token if null or expired.
|
||||||
|
if (oauth == null || oauth!!.isExpired()) {
|
||||||
|
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!))
|
||||||
|
oauth = if (response.isSuccessful) {
|
||||||
|
Gson().fromJson(response.body().string(), OAuth::class.java)
|
||||||
|
} else {
|
||||||
|
response.close()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw on null auth.
|
||||||
|
if (oauth == null) {
|
||||||
|
throw Exception("Access token wasn't refreshed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the authorization header to the original request.
|
||||||
|
val authRequest = originalRequest.newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(authRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
|
||||||
|
* and the oauth object.
|
||||||
|
*/
|
||||||
|
fun setAuth(oauth: OAuth?) {
|
||||||
|
refreshToken = oauth?.refresh_token
|
||||||
|
this.oauth = oauth
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||||
|
|
||||||
|
data class ALManga(
|
||||||
|
val id: Int,
|
||||||
|
val title_romaji: String,
|
||||||
|
val type: String,
|
||||||
|
val total_chapters: Int) {
|
||||||
|
|
||||||
|
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
|
||||||
|
remote_id = this@ALManga.id
|
||||||
|
title = title_romaji
|
||||||
|
total_chapters = this@ALManga.total_chapters
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||||
|
|
||||||
|
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
|
||||||
|
|
||||||
|
fun flatten() = lists.values.flatten()
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||||
|
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
|
||||||
|
|
||||||
|
data class ALUserManga(
|
||||||
|
val id: Int,
|
||||||
|
val list_status: String,
|
||||||
|
val score_raw: Int,
|
||||||
|
val chapters_read: Int,
|
||||||
|
val manga: ALManga) {
|
||||||
|
|
||||||
|
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
|
||||||
|
remote_id = manga.id
|
||||||
|
status = getMangaSyncStatus()
|
||||||
|
score = score_raw.toFloat()
|
||||||
|
last_chapter_read = chapters_read
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMangaSyncStatus() = when (list_status) {
|
||||||
|
"reading" -> Anilist.READING
|
||||||
|
"completed" -> Anilist.COMPLETED
|
||||||
|
"on-hold" -> Anilist.ON_HOLD
|
||||||
|
"dropped" -> Anilist.DROPPED
|
||||||
|
"plan to read" -> Anilist.PLAN_TO_READ
|
||||||
|
else -> throw NotImplementedError("Unknown status")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||||
|
|
||||||
|
data class OAuth(
|
||||||
|
val access_token: String,
|
||||||
|
val token_type: String,
|
||||||
|
val expires: Long,
|
||||||
|
val expires_in: Long,
|
||||||
|
val refresh_token: String?) {
|
||||||
|
|
||||||
|
fun isExpired() = System.currentTimeMillis() > expires
|
||||||
|
}
|
@ -26,7 +26,7 @@ class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(cont
|
|||||||
private lateinit var headers: Headers
|
private lateinit var headers: Headers
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val BASE_URL = "http://myanimelist.net"
|
val BASE_URL = "https://myanimelist.net"
|
||||||
|
|
||||||
private val ENTRY_TAG = "entry"
|
private val ENTRY_TAG = "entry"
|
||||||
private val CHAPTER_TAG = "chapter"
|
private val CHAPTER_TAG = "chapter"
|
||||||
|
@ -60,8 +60,7 @@ class CloudflareInterceptor(private val cookies: PersistentCookieStore) : Interc
|
|||||||
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
||||||
.replace("\n", "")
|
.replace("\n", "")
|
||||||
|
|
||||||
// Duktape can only return strings, so the result has to be converted to string first
|
val result = (duktape.evaluate(js) as Double).toInt()
|
||||||
val result = duktape.evaluate("$js.toString()").toInt()
|
|
||||||
|
|
||||||
val answer = "${result + domain.length}"
|
val answer = "${result + domain.length}"
|
||||||
|
|
||||||
|
@ -12,12 +12,7 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||||||
fun Call.asObservable(): Observable<Response> {
|
fun Call.asObservable(): Observable<Response> {
|
||||||
return Observable.create { subscriber ->
|
return Observable.create { subscriber ->
|
||||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||||
val call = if (!isExecuted) this else {
|
val call = clone()
|
||||||
// TODO use clone method in OkHttp 3.5
|
|
||||||
val field = javaClass.getDeclaredField("client").apply { isAccessible = true }
|
|
||||||
val client = field.get(this) as OkHttpClient
|
|
||||||
client.newCall(request())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
||||||
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
||||||
|
@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.R
|
|||||||
* in the file "keys.xml". By using this class we can define preferences in one place and get them
|
* in the file "keys.xml". By using this class we can define preferences in one place and get them
|
||||||
* referenced here.
|
* referenced here.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("HasPlatformType")
|
||||||
class PreferenceKeys(context: Context) {
|
class PreferenceKeys(context: Context) {
|
||||||
|
|
||||||
val theme = context.getString(R.string.pref_theme_key)
|
val theme = context.getString(R.string.pref_theme_key)
|
||||||
@ -26,6 +27,10 @@ class PreferenceKeys(context: Context) {
|
|||||||
|
|
||||||
val customBrightnessValue = context.getString(R.string.pref_custom_brightness_value_key)
|
val customBrightnessValue = context.getString(R.string.pref_custom_brightness_value_key)
|
||||||
|
|
||||||
|
val colorFilter = context.getString(R.string.pref_color_filter_key)
|
||||||
|
|
||||||
|
val colorFilterValue = context.getString(R.string.pref_color_filter_value_key)
|
||||||
|
|
||||||
val defaultViewer = context.getString(R.string.pref_default_viewer_key)
|
val defaultViewer = context.getString(R.string.pref_default_viewer_key)
|
||||||
|
|
||||||
val imageScaleType = context.getString(R.string.pref_image_scale_type_key)
|
val imageScaleType = context.getString(R.string.pref_image_scale_type_key)
|
||||||
@ -40,8 +45,6 @@ class PreferenceKeys(context: Context) {
|
|||||||
|
|
||||||
val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key)
|
val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key)
|
||||||
|
|
||||||
val reencodeImage = context.getString(R.string.pref_reencode_key)
|
|
||||||
|
|
||||||
val portraitColumns = context.getString(R.string.pref_library_columns_portrait_key)
|
val portraitColumns = context.getString(R.string.pref_library_columns_portrait_key)
|
||||||
|
|
||||||
val landscapeColumns = context.getString(R.string.pref_library_columns_landscape_key)
|
val landscapeColumns = context.getString(R.string.pref_library_columns_landscape_key)
|
||||||
@ -66,9 +69,7 @@ class PreferenceKeys(context: Context) {
|
|||||||
|
|
||||||
val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key)
|
val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key)
|
||||||
|
|
||||||
val removeAfterRead = context.getString(R.string.pref_remove_after_read_key)
|
val removeAfterReadSlots = context.getString(R.string.pref_remove_after_read_slots_key)
|
||||||
|
|
||||||
val removeAfterReadPrevious = context.getString(R.string.pref_remove_after_read_previous_key)
|
|
||||||
|
|
||||||
val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key)
|
val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key)
|
||||||
|
|
||||||
@ -76,11 +77,13 @@ class PreferenceKeys(context: Context) {
|
|||||||
|
|
||||||
val libraryUpdateRestriction = context.getString(R.string.pref_library_update_restriction_key)
|
val libraryUpdateRestriction = context.getString(R.string.pref_library_update_restriction_key)
|
||||||
|
|
||||||
|
val libraryUpdateCategories = context.getString(R.string.pref_library_update_categories_key)
|
||||||
|
|
||||||
val filterDownloaded = context.getString(R.string.pref_filter_downloaded_key)
|
val filterDownloaded = context.getString(R.string.pref_filter_downloaded_key)
|
||||||
|
|
||||||
val filterUnread = context.getString(R.string.pref_filter_unread_key)
|
val filterUnread = context.getString(R.string.pref_filter_unread_key)
|
||||||
|
|
||||||
val automaticUpdateStatus = context.getString(R.string.pref_enable_automatic_updates_key)
|
val automaticUpdates = context.getString(R.string.pref_enable_automatic_updates_key)
|
||||||
|
|
||||||
val startScreen = context.getString(R.string.pref_start_screen_key)
|
val startScreen = context.getString(R.string.pref_start_screen_key)
|
||||||
|
|
||||||
@ -92,4 +95,6 @@ class PreferenceKeys(context: Context) {
|
|||||||
|
|
||||||
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||||
|
|
||||||
|
val libraryAsList = context.getString(R.string.pref_display_library_as_list)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.data.preference
|
package eu.kanade.tachiyomi.data.preference
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.preference.PreferenceManager
|
import android.preference.PreferenceManager
|
||||||
import com.f2prateek.rx.preferences.Preference
|
import com.f2prateek.rx.preferences.Preference
|
||||||
@ -9,7 +10,6 @@ import eu.kanade.tachiyomi.R
|
|||||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||||
import eu.kanade.tachiyomi.data.source.Source
|
import eu.kanade.tachiyomi.data.source.Source
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
|
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
|
||||||
|
|
||||||
@ -20,17 +20,9 @@ class PreferencesHelper(context: Context) {
|
|||||||
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
private val rxPrefs = RxSharedPreferences.create(prefs)
|
private val rxPrefs = RxSharedPreferences.create(prefs)
|
||||||
|
|
||||||
private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath +
|
private val defaultDownloadsDir = Uri.fromFile(
|
||||||
File.separator + context.getString(R.string.app_name), "downloads")
|
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||||
|
context.getString(R.string.app_name), "downloads"))
|
||||||
init {
|
|
||||||
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file
|
|
||||||
try {
|
|
||||||
File(downloadsDirectory().getOrDefault(), ".nomedia").createNewFile()
|
|
||||||
} catch (e: IOException) {
|
|
||||||
/* Ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startScreen() = prefs.getInt(keys.startScreen, 1)
|
fun startScreen() = prefs.getInt(keys.startScreen, 1)
|
||||||
|
|
||||||
@ -52,6 +44,10 @@ class PreferencesHelper(context: Context) {
|
|||||||
|
|
||||||
fun customBrightnessValue() = rxPrefs.getInteger(keys.customBrightnessValue, 0)
|
fun customBrightnessValue() = rxPrefs.getInteger(keys.customBrightnessValue, 0)
|
||||||
|
|
||||||
|
fun colorFilter() = rxPrefs.getBoolean(keys.colorFilter, false)
|
||||||
|
|
||||||
|
fun colorFilterValue() = rxPrefs.getInteger(keys.colorFilterValue, 0)
|
||||||
|
|
||||||
fun defaultViewer() = prefs.getInt(keys.defaultViewer, 1)
|
fun defaultViewer() = prefs.getInt(keys.defaultViewer, 1)
|
||||||
|
|
||||||
fun imageScaleType() = rxPrefs.getInteger(keys.imageScaleType, 1)
|
fun imageScaleType() = rxPrefs.getInteger(keys.imageScaleType, 1)
|
||||||
@ -66,8 +62,6 @@ class PreferencesHelper(context: Context) {
|
|||||||
|
|
||||||
fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false)
|
fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false)
|
||||||
|
|
||||||
fun reencodeImage() = prefs.getBoolean(keys.reencodeImage, false)
|
|
||||||
|
|
||||||
fun portraitColumns() = rxPrefs.getInteger(keys.portraitColumns, 0)
|
fun portraitColumns() = rxPrefs.getInteger(keys.portraitColumns, 0)
|
||||||
|
|
||||||
fun landscapeColumns() = rxPrefs.getInteger(keys.landscapeColumns, 0)
|
fun landscapeColumns() = rxPrefs.getInteger(keys.landscapeColumns, 0)
|
||||||
@ -110,15 +104,13 @@ class PreferencesHelper(context: Context) {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath)
|
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
|
||||||
|
|
||||||
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
|
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
|
||||||
|
|
||||||
fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true)
|
fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true)
|
||||||
|
|
||||||
fun removeAfterRead() = prefs.getBoolean(keys.removeAfterRead, false)
|
fun removeAfterReadSlots() = prefs.getInt(keys.removeAfterReadSlots, -1)
|
||||||
|
|
||||||
fun removeAfterReadPrevious() = prefs.getBoolean(keys.removeAfterReadPrevious, false)
|
|
||||||
|
|
||||||
fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)
|
fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)
|
||||||
|
|
||||||
@ -126,10 +118,16 @@ class PreferencesHelper(context: Context) {
|
|||||||
|
|
||||||
fun libraryUpdateRestriction() = prefs.getStringSet(keys.libraryUpdateRestriction, emptySet())
|
fun libraryUpdateRestriction() = prefs.getStringSet(keys.libraryUpdateRestriction, emptySet())
|
||||||
|
|
||||||
|
fun libraryUpdateCategories() = rxPrefs.getStringSet(keys.libraryUpdateCategories, emptySet())
|
||||||
|
|
||||||
|
fun libraryAsList() = rxPrefs.getBoolean(keys.libraryAsList, false)
|
||||||
|
|
||||||
fun filterDownloaded() = rxPrefs.getBoolean(keys.filterDownloaded, false)
|
fun filterDownloaded() = rxPrefs.getBoolean(keys.filterDownloaded, false)
|
||||||
|
|
||||||
fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false)
|
fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false)
|
||||||
|
|
||||||
fun automaticUpdateStatus() = prefs.getBoolean(keys.automaticUpdateStatus, false)
|
fun automaticUpdates() = prefs.getBoolean(keys.automaticUpdates, false)
|
||||||
|
|
||||||
|
fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet())
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -47,5 +47,4 @@ interface Source {
|
|||||||
* @param page the page.
|
* @param page the page.
|
||||||
*/
|
*/
|
||||||
fun fetchImage(page: Page): Observable<Page>
|
fun fetchImage(page: Page): Observable<Page>
|
||||||
|
|
||||||
}
|
}
|
@ -18,20 +18,7 @@ import java.io.File
|
|||||||
|
|
||||||
open class SourceManager(private val context: Context) {
|
open class SourceManager(private val context: Context) {
|
||||||
|
|
||||||
val BATOTO = 1
|
private val sourcesMap = createSources()
|
||||||
val MANGAHERE = 2
|
|
||||||
val MANGAFOX = 3
|
|
||||||
val KISSMANGA = 4
|
|
||||||
val READMANGA = 5
|
|
||||||
val MINTMANGA = 6
|
|
||||||
val MANGACHAN = 7
|
|
||||||
val READMANGATODAY = 8
|
|
||||||
val MANGASEE = 9
|
|
||||||
val WIEMANGA = 10
|
|
||||||
|
|
||||||
val LAST_SOURCE = 10
|
|
||||||
|
|
||||||
val sourcesMap = createSources()
|
|
||||||
|
|
||||||
open fun get(sourceKey: Int): Source? {
|
open fun get(sourceKey: Int): Source? {
|
||||||
return sourcesMap[sourceKey]
|
return sourcesMap[sourceKey]
|
||||||
@ -39,24 +26,21 @@ open class SourceManager(private val context: Context) {
|
|||||||
|
|
||||||
fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java)
|
fun getOnlineSources() = sourcesMap.values.filterIsInstance(OnlineSource::class.java)
|
||||||
|
|
||||||
private fun createSource(id: Int): Source? = when (id) {
|
private fun createOnlineSourceList(): List<Source> = listOf(
|
||||||
BATOTO -> Batoto(context, id)
|
Batoto(1),
|
||||||
KISSMANGA -> Kissmanga(context, id)
|
Mangahere(2),
|
||||||
MANGAHERE -> Mangahere(context, id)
|
Mangafox(3),
|
||||||
MANGAFOX -> Mangafox(context, id)
|
Kissmanga(4),
|
||||||
READMANGA -> Readmanga(context, id)
|
Readmanga(5),
|
||||||
MINTMANGA -> Mintmanga(context, id)
|
Mintmanga(6),
|
||||||
MANGACHAN -> Mangachan(context, id)
|
Mangachan(7),
|
||||||
READMANGATODAY -> Readmangatoday(context, id)
|
Readmangatoday(8),
|
||||||
MANGASEE -> Mangasee(context, id)
|
Mangasee(9),
|
||||||
WIEMANGA -> WieManga(context, id)
|
WieManga(10)
|
||||||
else -> null
|
)
|
||||||
}
|
|
||||||
|
|
||||||
private fun createSources(): Map<Int, Source> = hashMapOf<Int, Source>().apply {
|
private fun createSources(): Map<Int, Source> = hashMapOf<Int, Source>().apply {
|
||||||
for (i in 1..LAST_SOURCE) {
|
createOnlineSourceList().forEach { put(it.id, it) }
|
||||||
createSource(i)?.let { put(i, it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath +
|
val parsersDir = File(Environment.getExternalStorageDirectory().absolutePath +
|
||||||
File.separator + context.getString(R.string.app_name), "parsers")
|
File.separator + context.getString(R.string.app_name), "parsers")
|
||||||
@ -66,7 +50,7 @@ open class SourceManager(private val context: Context) {
|
|||||||
for (file in parsersDir.listFiles().filter { it.extension == "yml" }) {
|
for (file in parsersDir.listFiles().filter { it.extension == "yml" }) {
|
||||||
try {
|
try {
|
||||||
val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) }
|
val map = file.inputStream().use { yaml.loadAs(it, Map::class.java) }
|
||||||
YamlOnlineSource(context, map).let { put(it.id, it) }
|
YamlOnlineSource(map).let { put(it.id, it) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e("Error loading source from file. Bad format?")
|
Timber.e("Error loading source from file. Bad format?")
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,20 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.model
|
package eu.kanade.tachiyomi.data.source.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.data.network.ProgressListener
|
import eu.kanade.tachiyomi.data.network.ProgressListener
|
||||||
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
|
||||||
import rx.subjects.Subject
|
import rx.subjects.Subject
|
||||||
|
|
||||||
class Page(
|
class Page(
|
||||||
val pageNumber: Int,
|
val index: Int,
|
||||||
val url: String,
|
val url: String = "",
|
||||||
var imageUrl: String? = null,
|
var imageUrl: String? = null,
|
||||||
@Transient var imagePath: String? = null
|
@Transient var uri: Uri? = null
|
||||||
) : ProgressListener {
|
) : ProgressListener {
|
||||||
|
|
||||||
|
val number: Int
|
||||||
|
get() = index + 1
|
||||||
|
|
||||||
@Transient lateinit var chapter: ReaderChapter
|
@Transient lateinit var chapter: ReaderChapter
|
||||||
|
|
||||||
@Transient @Volatile var status: Int = 0
|
@Transient @Volatile var status: Int = 0
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online
|
package eu.kanade.tachiyomi.data.source.online
|
||||||
|
|
||||||
import android.content.Context
|
import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
@ -23,10 +23,8 @@ import uy.kohesive.injekt.injectLazy
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple implementation for sources from a website.
|
* A simple implementation for sources from a website.
|
||||||
*
|
|
||||||
* @param context the application context.
|
|
||||||
*/
|
*/
|
||||||
abstract class OnlineSource(context: Context) : Source {
|
abstract class OnlineSource() : Source {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Network service.
|
* Network service.
|
||||||
@ -53,11 +51,21 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
*/
|
*/
|
||||||
abstract val lang: Language
|
abstract val lang: Language
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the source has support for latest updates.
|
||||||
|
*/
|
||||||
|
abstract val supportsLatest : Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Headers used for requests.
|
* Headers used for requests.
|
||||||
*/
|
*/
|
||||||
val headers by lazy { headersBuilder().build() }
|
val headers by lazy { headersBuilder().build() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genre filters.
|
||||||
|
*/
|
||||||
|
val filters by lazy { getFilterList() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default network client for doing requests.
|
* Default network client for doing requests.
|
||||||
*/
|
*/
|
||||||
@ -126,11 +134,11 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
* the current page and the next page url.
|
* the current page and the next page url.
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
*/
|
*/
|
||||||
open fun fetchSearchManga(page: MangasPage, query: String): Observable<MangasPage> = client
|
open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client
|
||||||
.newCall(searchMangaRequest(page, query))
|
.newCall(searchMangaRequest(page, query, filters))
|
||||||
.asObservable()
|
.asObservable()
|
||||||
.map { response ->
|
.map { response ->
|
||||||
searchMangaParse(response, page, query)
|
searchMangaParse(response, page, query, filters)
|
||||||
page
|
page
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,9 +149,9 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
* @param page the page object.
|
* @param page the page object.
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
*/
|
*/
|
||||||
open protected fun searchMangaRequest(page: MangasPage, query: String): Request {
|
open protected fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||||
if (page.page == 1) {
|
if (page.page == 1) {
|
||||||
page.url = searchMangaInitialUrl(query)
|
page.url = searchMangaInitialUrl(query, filters)
|
||||||
}
|
}
|
||||||
return GET(page.url, headers)
|
return GET(page.url, headers)
|
||||||
}
|
}
|
||||||
@ -153,7 +161,7 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
*
|
*
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
*/
|
*/
|
||||||
abstract protected fun searchMangaInitialUrl(query: String): String
|
abstract protected fun searchMangaInitialUrl(query: String, filters: List<Filter>): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the response from the site. It should add a list of manga and the absolute url to the
|
* Parse the response from the site. It should add a list of manga and the absolute url to the
|
||||||
@ -163,7 +171,38 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
* @param page the page object to be filled.
|
* @param page the page object to be filled.
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
*/
|
*/
|
||||||
abstract protected fun searchMangaParse(response: Response, page: MangasPage, query: String)
|
abstract protected fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an observable containing a page with a list of latest manga.
|
||||||
|
*/
|
||||||
|
open fun fetchLatestUpdates(page: MangasPage): Observable<MangasPage> = client
|
||||||
|
.newCall(latestUpdatesRequest(page))
|
||||||
|
.asObservable()
|
||||||
|
.map { response ->
|
||||||
|
latestUpdatesParse(response, page)
|
||||||
|
page
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the request for latest manga given the page.
|
||||||
|
*/
|
||||||
|
open protected fun latestUpdatesRequest(page: MangasPage): Request {
|
||||||
|
if (page.page == 1) {
|
||||||
|
page.url = latestUpdatesInitialUrl()
|
||||||
|
}
|
||||||
|
return GET(page.url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the absolute url of the first page to latest manga.
|
||||||
|
*/
|
||||||
|
abstract protected fun latestUpdatesInitialUrl(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as [popularMangaParse], but for latest manga.
|
||||||
|
*/
|
||||||
|
abstract protected fun latestUpdatesParse(response: Response, page: MangasPage)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the updated details for a manga. Normally it's not needed to
|
* Returns an observable with the updated details for a manga. Normally it's not needed to
|
||||||
@ -187,7 +226,7 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
*
|
*
|
||||||
* @param manga the manga to be updated.
|
* @param manga the manga to be updated.
|
||||||
*/
|
*/
|
||||||
open protected fun mangaDetailsRequest(manga: Manga): Request {
|
open fun mangaDetailsRequest(manga: Manga): Request {
|
||||||
return GET(baseUrl + manga.url, headers)
|
return GET(baseUrl + manga.url, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -378,7 +417,7 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.doOnNext {
|
.doOnNext {
|
||||||
page.imagePath = chapterCache.getImagePath(imageUrl)
|
page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
|
||||||
page.status = Page.READY
|
page.status = Page.READY
|
||||||
}
|
}
|
||||||
.doOnError { page.status = Page.ERROR }
|
.doOnError { page.status = Page.ERROR }
|
||||||
@ -393,7 +432,7 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
private fun cacheImage(page: Page): Observable<Page> {
|
private fun cacheImage(page: Page): Observable<Page> {
|
||||||
page.status = Page.DOWNLOAD_IMAGE
|
page.status = Page.DOWNLOAD_IMAGE
|
||||||
return imageResponse(page)
|
return imageResponse(page)
|
||||||
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it, preferences.reencodeImage()) }
|
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
|
||||||
.map { page }
|
.map { page }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,9 +462,18 @@ abstract class OnlineSource(context: Context) : Source {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Overridable method to allow custom parsing.
|
/**
|
||||||
open fun parseChapterNumber(chapter: Chapter) {
|
* Called before inserting a new chapter into database. Use it if you need to override chapter
|
||||||
|
* fields, like the title or the chapter number. Do not change anything to [manga].
|
||||||
|
*
|
||||||
|
* @param chapter the chapter to be added.
|
||||||
|
* @param manga the manga of the chapter.
|
||||||
|
*/
|
||||||
|
open fun prepareNewChapter(chapter: Chapter, manga: Manga) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class Filter(val id: String, val name: String)
|
||||||
|
|
||||||
|
open fun getFilterList(): List<Filter> = emptyList()
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online
|
package eu.kanade.tachiyomi.data.source.online
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
@ -12,10 +11,8 @@ import org.jsoup.nodes.Element
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
||||||
*
|
|
||||||
* @param context the application context.
|
|
||||||
*/
|
*/
|
||||||
abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
abstract class ParsedOnlineSource() : OnlineSource() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the response from the site and fills [page].
|
* Parse the response from the site and fills [page].
|
||||||
@ -64,7 +61,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
|||||||
* @param page the page object to be filled.
|
* @param page the page object to be filled.
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
*/
|
*/
|
||||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
for (element in document.select(searchMangaSelector())) {
|
for (element in document.select(searchMangaSelector())) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
@ -98,6 +95,38 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
|||||||
*/
|
*/
|
||||||
abstract protected fun searchMangaNextPageSelector(): String?
|
abstract protected fun searchMangaNextPageSelector(): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the response from the site for latest updates and fills [page].
|
||||||
|
*/
|
||||||
|
override fun latestUpdatesParse(response: Response, page: MangasPage) {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
for (element in document.select(latestUpdatesSelector())) {
|
||||||
|
Manga.create(id).apply {
|
||||||
|
latestUpdatesFromElement(element, this)
|
||||||
|
page.mangas.add(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latestUpdatesNextPageSelector()?.let { selector ->
|
||||||
|
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Jsoup selector similar to [popularMangaSelector], but for latest updates.
|
||||||
|
*/
|
||||||
|
abstract protected fun latestUpdatesSelector(): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills [manga] with the given [element]. For latest updates.
|
||||||
|
*/
|
||||||
|
abstract protected fun latestUpdatesFromElement(element: Element, manga: Manga)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Jsoup selector that returns the <a> tag, like [popularMangaNextPageSelector].
|
||||||
|
*/
|
||||||
|
abstract protected fun latestUpdatesNextPageSelector(): String?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the response from the site and fills the details of [manga].
|
* Parse the response from the site and fills the details of [manga].
|
||||||
*
|
*
|
||||||
@ -179,5 +208,4 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
|||||||
* @param document the parsed document.
|
* @param document the parsed document.
|
||||||
*/
|
*/
|
||||||
abstract protected fun imageUrlParse(document: Document): String
|
abstract protected fun imageUrlParse(document: Document): String
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online
|
package eu.kanade.tachiyomi.data.source.online
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.network.GET
|
import eu.kanade.tachiyomi.data.network.GET
|
||||||
@ -17,7 +16,7 @@ import org.jsoup.nodes.Element
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(context) {
|
class YamlOnlineSource(mappings: Map<*, *>) : OnlineSource() {
|
||||||
|
|
||||||
val map = YamlSourceNode(mappings)
|
val map = YamlSourceNode(mappings)
|
||||||
|
|
||||||
@ -32,6 +31,8 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
|||||||
getLanguages().find { code == it.code }!!
|
getLanguages().find { code == it.code }!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val supportsLatest = map.latestupdates != null
|
||||||
|
|
||||||
override val client = when(map.client) {
|
override val client = when(map.client) {
|
||||||
"cloudflare" -> network.cloudflareClient
|
"cloudflare" -> network.cloudflareClient
|
||||||
else -> network.client
|
else -> network.client
|
||||||
@ -68,9 +69,9 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaRequest(page: MangasPage, query: String): Request {
|
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||||
if (page.page == 1) {
|
if (page.page == 1) {
|
||||||
page.url = searchMangaInitialUrl(query)
|
page.url = searchMangaInitialUrl(query, filters)
|
||||||
}
|
}
|
||||||
return when (map.search.method?.toLowerCase()) {
|
return when (map.search.method?.toLowerCase()) {
|
||||||
"post" -> POST(page.url, headers, map.search.createForm())
|
"post" -> POST(page.url, headers, map.search.createForm())
|
||||||
@ -78,9 +79,9 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) = map.search.url.replace("\$query", query)
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = map.search.url.replace("\$query", query)
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
for (element in document.select(map.search.manga_css)) {
|
for (element in document.select(map.search.manga_css)) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
@ -95,6 +96,33 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: MangasPage): Request {
|
||||||
|
if (page.page == 1) {
|
||||||
|
page.url = latestUpdatesInitialUrl()
|
||||||
|
}
|
||||||
|
return when (map.latestupdates!!.method?.toLowerCase()) {
|
||||||
|
"post" -> POST(page.url, headers, map.latestupdates.createForm())
|
||||||
|
else -> GET(page.url, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesInitialUrl() = map.latestupdates!!.url
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response, page: MangasPage) {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
for (element in document.select(map.latestupdates!!.manga_css)) {
|
||||||
|
Manga.create(id).apply {
|
||||||
|
title = element.text()
|
||||||
|
setUrlWithoutDomain(element.attr("href"))
|
||||||
|
page.mangas.add(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.latestupdates.next_url_css?.let { selector ->
|
||||||
|
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun mangaDetailsParse(response: Response, manga: Manga) {
|
override fun mangaDetailsParse(response: Response, manga: Manga) {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
with(map.manga) {
|
with(map.manga) {
|
||||||
@ -184,5 +212,4 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
|||||||
throw Exception("image_regex and image_css are null")
|
throw Exception("image_regex and image_css are null")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -30,6 +30,8 @@ class YamlSourceNode(uncheckedMap: Map<*, *>) {
|
|||||||
|
|
||||||
val popular = PopularNode(toMap(map["popular"])!!)
|
val popular = PopularNode(toMap(map["popular"])!!)
|
||||||
|
|
||||||
|
val latestupdates = toMap(map["latest_updates"])?.let { LatestUpdatesNode(it) }
|
||||||
|
|
||||||
val search = SearchNode(toMap(map["search"])!!)
|
val search = SearchNode(toMap(map["search"])!!)
|
||||||
|
|
||||||
val manga = MangaNode(toMap(map["manga"])!!)
|
val manga = MangaNode(toMap(map["manga"])!!)
|
||||||
@ -73,6 +75,17 @@ class PopularNode(override val map: Map<String, Any?>): RequestableNode {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LatestUpdatesNode(override val map: Map<String, Any?>): RequestableNode {
|
||||||
|
|
||||||
|
val manga_css: String by map
|
||||||
|
|
||||||
|
val next_url_css: String?
|
||||||
|
get() = map["next_url_css"] as? String
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class SearchNode(override val map: Map<String, Any?>): RequestableNode {
|
class SearchNode(override val map: Map<String, Any?>): RequestableNode {
|
||||||
|
|
||||||
val manga_css: String by map
|
val manga_css: String by map
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.english
|
package eu.kanade.tachiyomi.data.source.online.english
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.text.Html
|
import android.text.Html
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
@ -28,7 +27,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(context), LoginSource {
|
class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource {
|
||||||
|
|
||||||
override val name = "Batoto"
|
override val name = "Batoto"
|
||||||
|
|
||||||
@ -36,6 +35,8 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
|||||||
|
|
||||||
override val lang: Language get() = EN
|
override val lang: Language get() = EN
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
private val datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*")
|
private val datePattern = Pattern.compile("(\\d+|A|An)\\s+(.*?)s? ago.*")
|
||||||
|
|
||||||
private val dateFields = HashMap<String, Int>().apply {
|
private val dateFields = HashMap<String, Int>().apply {
|
||||||
@ -59,6 +60,8 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
|||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/search_ajax?order_cond=views&order=desc&p=1"
|
override fun popularMangaInitialUrl() = "$baseUrl/search_ajax?order_cond=views&order=desc&p=1"
|
||||||
|
|
||||||
|
override fun latestUpdatesInitialUrl() = "$baseUrl/search_ajax?order_cond=update&order=desc&p=1"
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response, page: MangasPage) {
|
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
for (element in document.select(popularMangaSelector())) {
|
for (element in document.select(popularMangaSelector())) {
|
||||||
@ -73,8 +76,24 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response, page: MangasPage) {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
for (element in document.select(latestUpdatesSelector())) {
|
||||||
|
Manga.create(id).apply {
|
||||||
|
latestUpdatesFromElement(element, this)
|
||||||
|
page.mangas.add(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page.nextPageUrl = document.select(latestUpdatesNextPageSelector()).first()?.let {
|
||||||
|
"$baseUrl/search_ajax?order_cond=update&order=desc&p=${page.page + 1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaSelector() = "tr:has(a)"
|
override fun popularMangaSelector() = "tr:has(a)"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = "tr:has(a)"
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("a[href^=http://bato.to]").first().let {
|
element.select("a[href^=http://bato.to]").first().let {
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
@ -82,11 +101,29 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
popularMangaFromElement(element, manga)
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "#show_more_row"
|
override fun popularMangaNextPageSelector() = "#show_more_row"
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&p=1"
|
override fun latestUpdatesNextPageSelector() = "#show_more_row"
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=1&genre_cond=and&genres=${getFilterParams(filters)}"
|
||||||
|
|
||||||
|
private fun getFilterParams(filters: List<Filter>): String = filters
|
||||||
|
.map {
|
||||||
|
";i" + it.id
|
||||||
|
}.joinToString()
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||||
|
if (page.page == 1) {
|
||||||
|
page.url = searchMangaInitialUrl(query, filters)
|
||||||
|
}
|
||||||
|
return GET(page.url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
for (element in document.select(searchMangaSelector())) {
|
for (element in document.select(searchMangaSelector())) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
@ -96,7 +133,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let {
|
page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let {
|
||||||
"$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}"
|
"$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}&order_cond=views&order=desc&genre_cond=and&genres=" + getFilterParams(filters)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,7 +201,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
|||||||
private fun parseDateFromElement(dateElement: Element): Long {
|
private fun parseDateFromElement(dateElement: Element): Long {
|
||||||
val dateAsString = dateElement.text()
|
val dateAsString = dateElement.text()
|
||||||
|
|
||||||
val date: Date
|
var date: Date
|
||||||
try {
|
try {
|
||||||
date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString)
|
date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString)
|
||||||
} catch (e: ParseException) {
|
} catch (e: ParseException) {
|
||||||
@ -211,7 +248,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
|||||||
val start = pageUrl.indexOf("#") + 1
|
val start = pageUrl.indexOf("#") + 1
|
||||||
val end = pageUrl.indexOf("_", start)
|
val end = pageUrl.indexOf("_", start)
|
||||||
val id = pageUrl.substring(start, end)
|
val id = pageUrl.substring(start, end)
|
||||||
return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end+1)}", pageHeaders)
|
return GET("$baseUrl/areader?id=$id&p=${pageUrl.substring(end + 1)}", pageHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document): String {
|
override fun imageUrlParse(document: Document): String {
|
||||||
@ -264,4 +301,49 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("#advanced_options div.genre_buttons")].map((el,i) => {
|
||||||
|
// const onClick=el.getAttribute('onclick');const id=onClick.substr(14,onClick.length-16);return `Filter("${id}", "${el.textContent.trim()}")`
|
||||||
|
// }).join(',\n')
|
||||||
|
// on https://bato.to/search
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("40", "4-Koma"),
|
||||||
|
Filter("1", "Action"),
|
||||||
|
Filter("2", "Adventure"),
|
||||||
|
Filter("39", "Award Winning"),
|
||||||
|
Filter("3", "Comedy"),
|
||||||
|
Filter("41", "Cooking"),
|
||||||
|
Filter("9", "Doujinshi"),
|
||||||
|
Filter("10", "Drama"),
|
||||||
|
Filter("12", "Ecchi"),
|
||||||
|
Filter("13", "Fantasy"),
|
||||||
|
Filter("15", "Gender Bender"),
|
||||||
|
Filter("17", "Harem"),
|
||||||
|
Filter("20", "Historical"),
|
||||||
|
Filter("22", "Horror"),
|
||||||
|
Filter("34", "Josei"),
|
||||||
|
Filter("27", "Martial Arts"),
|
||||||
|
Filter("30", "Mecha"),
|
||||||
|
Filter("42", "Medical"),
|
||||||
|
Filter("37", "Music"),
|
||||||
|
Filter("4", "Mystery"),
|
||||||
|
Filter("38", "Oneshot"),
|
||||||
|
Filter("5", "Psychological"),
|
||||||
|
Filter("6", "Romance"),
|
||||||
|
Filter("7", "School Life"),
|
||||||
|
Filter("8", "Sci-fi"),
|
||||||
|
Filter("32", "Seinen"),
|
||||||
|
Filter("35", "Shoujo"),
|
||||||
|
Filter("16", "Shoujo Ai"),
|
||||||
|
Filter("33", "Shounen"),
|
||||||
|
Filter("19", "Shounen Ai"),
|
||||||
|
Filter("21", "Slice of Life"),
|
||||||
|
Filter("23", "Smut"),
|
||||||
|
Filter("25", "Sports"),
|
||||||
|
Filter("26", "Supernatural"),
|
||||||
|
Filter("28", "Tragedy"),
|
||||||
|
Filter("36", "Webtoon"),
|
||||||
|
Filter("29", "Yaoi"),
|
||||||
|
Filter("31", "Yuri")
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
@ -19,7 +19,7 @@ import org.jsoup.nodes.Element
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class Kissmanga(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "Kissmanga"
|
override val name = "Kissmanga"
|
||||||
|
|
||||||
@ -27,12 +27,18 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
|
|
||||||
override val lang: Language get() = EN
|
override val lang: Language get() = EN
|
||||||
|
|
||||||
override val client: OkHttpClient get() = network.cloudflareClient
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val client: OkHttpClient = network.cloudflareClient
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/MangaList/MostPopular"
|
override fun popularMangaInitialUrl() = "$baseUrl/MangaList/MostPopular"
|
||||||
|
|
||||||
|
override fun latestUpdatesInitialUrl() = "http://kissmanga.com/MangaList/LatestUpdate"
|
||||||
|
|
||||||
override fun popularMangaSelector() = "table.listing tr:gt(1)"
|
override fun popularMangaSelector() = "table.listing tr:gt(1)"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = "table.listing tr:gt(1)"
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("td a:eq(0)").first().let {
|
element.select("td a:eq(0)").first().let {
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
@ -40,24 +46,33 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
popularMangaFromElement(element, manga)
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "li > a:contains(› Next)"
|
override fun popularMangaNextPageSelector() = "li > a:contains(› Next)"
|
||||||
|
|
||||||
override fun searchMangaRequest(page: MangasPage, query: String): Request {
|
override fun latestUpdatesNextPageSelector(): String = "ul.pager > li > a:contains(Next)"
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||||
if (page.page == 1) {
|
if (page.page == 1) {
|
||||||
page.url = searchMangaInitialUrl(query)
|
page.url = searchMangaInitialUrl(query, filters)
|
||||||
}
|
}
|
||||||
|
|
||||||
val form = FormBody.Builder().apply {
|
val form = FormBody.Builder().apply {
|
||||||
add("authorArtist", "")
|
add("authorArtist", "")
|
||||||
add("mangaName", query)
|
add("mangaName", query)
|
||||||
add("status", "")
|
add("status", "")
|
||||||
add("genres", "")
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
return POST(page.url, headers, form)
|
this@Kissmanga.filters.forEach { filter ->
|
||||||
|
add("genres", if (filter in filters) "1" else "0")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) = "$baseUrl/AdvanceSearch"
|
return POST(page.url, headers, form.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/AdvanceSearch"
|
||||||
|
|
||||||
override fun searchMangaSelector() = popularMangaSelector()
|
override fun searchMangaSelector() = popularMangaSelector()
|
||||||
|
|
||||||
@ -73,7 +88,7 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text()
|
manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text()
|
||||||
manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
|
manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
|
||||||
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text()
|
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text()
|
||||||
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it)}
|
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) }
|
||||||
manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src")
|
manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,10 +124,59 @@ class Kissmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not used
|
// Not used
|
||||||
override fun pageListParse(document: Document, pages: MutableList<Page>) {}
|
override fun pageListParse(document: Document, pages: MutableList<Page>) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun imageUrlRequest(page: Page) = GET(page.url)
|
override fun imageUrlRequest(page: Page) = GET(page.url)
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = ""
|
override fun imageUrlParse(document: Document) = ""
|
||||||
|
|
||||||
|
// $("select[name=\"genres\"]").map((i,el) => `Filter("${i}", "${$(el).next().text().trim()}")`).get().join(',\n')
|
||||||
|
// on http://kissmanga.com/AdvanceSearch
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("0", "Action"),
|
||||||
|
Filter("1", "Adult"),
|
||||||
|
Filter("2", "Adventure"),
|
||||||
|
Filter("3", "Comedy"),
|
||||||
|
Filter("4", "Comic"),
|
||||||
|
Filter("5", "Cooking"),
|
||||||
|
Filter("6", "Doujinshi"),
|
||||||
|
Filter("7", "Drama"),
|
||||||
|
Filter("8", "Ecchi"),
|
||||||
|
Filter("9", "Fantasy"),
|
||||||
|
Filter("10", "Gender Bender"),
|
||||||
|
Filter("11", "Harem"),
|
||||||
|
Filter("12", "Historical"),
|
||||||
|
Filter("13", "Horror"),
|
||||||
|
Filter("14", "Josei"),
|
||||||
|
Filter("15", "Lolicon"),
|
||||||
|
Filter("16", "Manga"),
|
||||||
|
Filter("17", "Manhua"),
|
||||||
|
Filter("18", "Manhwa"),
|
||||||
|
Filter("19", "Martial Arts"),
|
||||||
|
Filter("20", "Mature"),
|
||||||
|
Filter("21", "Mecha"),
|
||||||
|
Filter("22", "Medical"),
|
||||||
|
Filter("23", "Music"),
|
||||||
|
Filter("24", "Mystery"),
|
||||||
|
Filter("25", "One shot"),
|
||||||
|
Filter("26", "Psychological"),
|
||||||
|
Filter("27", "Romance"),
|
||||||
|
Filter("28", "School Life"),
|
||||||
|
Filter("29", "Sci-fi"),
|
||||||
|
Filter("30", "Seinen"),
|
||||||
|
Filter("31", "Shotacon"),
|
||||||
|
Filter("32", "Shoujo"),
|
||||||
|
Filter("33", "Shoujo Ai"),
|
||||||
|
Filter("34", "Shounen"),
|
||||||
|
Filter("35", "Shounen Ai"),
|
||||||
|
Filter("36", "Slice of Life"),
|
||||||
|
Filter("37", "Smut"),
|
||||||
|
Filter("38", "Sports"),
|
||||||
|
Filter("39", "Supernatural"),
|
||||||
|
Filter("40", "Tragedy"),
|
||||||
|
Filter("41", "Webtoon"),
|
||||||
|
Filter("42", "Yaoi"),
|
||||||
|
Filter("43", "Yuri")
|
||||||
|
)
|
||||||
}
|
}
|
@ -1,6 +1,5 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.english
|
package eu.kanade.tachiyomi.data.source.online.english
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.source.EN
|
import eu.kanade.tachiyomi.data.source.EN
|
||||||
@ -15,7 +14,7 @@ import java.text.ParseException
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class Mangafox(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "Mangafox"
|
override val name = "Mangafox"
|
||||||
|
|
||||||
@ -23,10 +22,16 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
|
|||||||
|
|
||||||
override val lang: Language get() = EN
|
override val lang: Language get() = EN
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/directory/"
|
override fun popularMangaInitialUrl() = "$baseUrl/directory/"
|
||||||
|
|
||||||
|
override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?latest"
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div#mangalist > ul.list > li"
|
override fun popularMangaSelector() = "div#mangalist > ul.list > li"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = "div#mangalist > ul.list > li"
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("a.title").first().let {
|
element.select("a.title").first().let {
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
@ -34,10 +39,16 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
popularMangaFromElement(element, manga)
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "a:has(span.next)"
|
override fun popularMangaNextPageSelector() = "a:has(span.next)"
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) =
|
override fun latestUpdatesNextPageSelector() = "a:has(span.next)"
|
||||||
"$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1"
|
|
||||||
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||||
|
"$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1&${filters.map { it.id + "=1" }.joinToString("&")}"
|
||||||
|
|
||||||
override fun searchMangaSelector() = "table#listing > tbody > tr:gt(0)"
|
override fun searchMangaSelector() = "table#listing > tbody > tr:gt(0)"
|
||||||
|
|
||||||
@ -108,7 +119,7 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
|
|||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
|
|
||||||
val url = response.request().url().toString().substringBeforeLast('/')
|
val url = response.request().url().toString().substringBeforeLast('/')
|
||||||
document.select("select.m").first().select("option:not([value=0])").forEach {
|
document.select("select.m").first()?.select("option:not([value=0])")?.forEach {
|
||||||
pages.add(Page(pages.size, "$url/${it.attr("value")}.html"))
|
pages.add(Page(pages.size, "$url/${it.attr("value")}.html"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -118,4 +129,44 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
|
|||||||
|
|
||||||
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
|
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
|
||||||
|
|
||||||
|
// $('select.genres').map((i,el)=>`Filter("${$(el).attr('name')}", "${$(el).next().text().trim()}")`).get().join(',\n')
|
||||||
|
// on http://kissmanga.com/AdvanceSearch
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("genres[Action]", "Action"),
|
||||||
|
Filter("genres[Adult]", "Adult"),
|
||||||
|
Filter("genres[Adventure]", "Adventure"),
|
||||||
|
Filter("genres[Comedy]", "Comedy"),
|
||||||
|
Filter("genres[Doujinshi]", "Doujinshi"),
|
||||||
|
Filter("genres[Drama]", "Drama"),
|
||||||
|
Filter("genres[Ecchi]", "Ecchi"),
|
||||||
|
Filter("genres[Fantasy]", "Fantasy"),
|
||||||
|
Filter("genres[Gender Bender]", "Gender Bender"),
|
||||||
|
Filter("genres[Harem]", "Harem"),
|
||||||
|
Filter("genres[Historical]", "Historical"),
|
||||||
|
Filter("genres[Horror]", "Horror"),
|
||||||
|
Filter("genres[Josei]", "Josei"),
|
||||||
|
Filter("genres[Martial Arts]", "Martial Arts"),
|
||||||
|
Filter("genres[Mature]", "Mature"),
|
||||||
|
Filter("genres[Mecha]", "Mecha"),
|
||||||
|
Filter("genres[Mystery]", "Mystery"),
|
||||||
|
Filter("genres[One Shot]", "One Shot"),
|
||||||
|
Filter("genres[Psychological]", "Psychological"),
|
||||||
|
Filter("genres[Romance]", "Romance"),
|
||||||
|
Filter("genres[School Life]", "School Life"),
|
||||||
|
Filter("genres[Sci-fi]", "Sci-fi"),
|
||||||
|
Filter("genres[Seinen]", "Seinen"),
|
||||||
|
Filter("genres[Shoujo]", "Shoujo"),
|
||||||
|
Filter("genres[Shoujo Ai]", "Shoujo Ai"),
|
||||||
|
Filter("genres[Shounen]", "Shounen"),
|
||||||
|
Filter("genres[Shounen Ai]", "Shounen Ai"),
|
||||||
|
Filter("genres[Slice of Life]", "Slice of Life"),
|
||||||
|
Filter("genres[Smut]", "Smut"),
|
||||||
|
Filter("genres[Sports]", "Sports"),
|
||||||
|
Filter("genres[Supernatural]", "Supernatural"),
|
||||||
|
Filter("genres[Tragedy]", "Tragedy"),
|
||||||
|
Filter("genres[Webtoons]", "Webtoons"),
|
||||||
|
Filter("genres[Yaoi]", "Yaoi"),
|
||||||
|
Filter("genres[Yuri]", "Yuri")
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
@ -13,7 +13,7 @@ import java.text.ParseException
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class Mangahere(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "Mangahere"
|
override val name = "Mangahere"
|
||||||
|
|
||||||
@ -21,10 +21,16 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
|
|
||||||
override val lang: Language get() = EN
|
override val lang: Language get() = EN
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/directory/"
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override fun popularMangaInitialUrl() = "$baseUrl/directory/?views.za"
|
||||||
|
|
||||||
|
override fun latestUpdatesInitialUrl() = "$baseUrl/directory/?last_chapter_time.za"
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.directory_list > ul > li"
|
override fun popularMangaSelector() = "div.directory_list > ul > li"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = "div.directory_list > ul > li"
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("div.title > a").first().let {
|
element.select("div.title > a").first().let {
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
@ -32,10 +38,15 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
popularMangaFromElement(element, manga)
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "div.next-page > a.next"
|
override fun popularMangaNextPageSelector() = "div.next-page > a.next"
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) =
|
override fun latestUpdatesNextPageSelector() = "div.next-page > a.next"
|
||||||
"$baseUrl/search.php?name=$query&page=1&sort=views&order=za"
|
|
||||||
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search.php?name=$query&page=1&sort=views&order=za&${filters.map { it.id + "=1" }.joinToString("&")}&advopts=1"
|
||||||
|
|
||||||
override fun searchMangaSelector() = "div.result_search > dl:has(dt)"
|
override fun searchMangaSelector() = "div.result_search > dl:has(dt)"
|
||||||
|
|
||||||
@ -69,10 +80,22 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
override fun chapterListSelector() = ".detail_list > ul:not([class]) > li"
|
override fun chapterListSelector() = ".detail_list > ul:not([class]) > li"
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||||
val urlElement = element.select("a").first()
|
val parentEl = element.select("span.left").first()
|
||||||
|
|
||||||
|
val urlElement = parentEl.select("a").first()
|
||||||
|
|
||||||
|
var volume = parentEl.select("span.mr6")?.first()?.text()?.trim()?:""
|
||||||
|
if (volume.length > 0) {
|
||||||
|
volume = " - " + volume
|
||||||
|
}
|
||||||
|
|
||||||
|
var title = parentEl?.textNodes()?.last()?.text()?.trim()?:""
|
||||||
|
if (title.length > 0) {
|
||||||
|
title = " - " + title
|
||||||
|
}
|
||||||
|
|
||||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||||
chapter.name = urlElement.text()
|
chapter.name = urlElement.text() + volume + title
|
||||||
chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,4 +133,41 @@ class Mangahere(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
|
|
||||||
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
|
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("select[id^='genres'")].map((el,i) => `Filter("${el.getAttribute('name')}", "${el.nextSibling.nextSibling.textContent.trim()}")`).join(',\n')
|
||||||
|
// http://www.mangahere.co/advsearch.htm
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("genres[Action]", "Action"),
|
||||||
|
Filter("genres[Adventure]", "Adventure"),
|
||||||
|
Filter("genres[Comedy]", "Comedy"),
|
||||||
|
Filter("genres[Doujinshi]", "Doujinshi"),
|
||||||
|
Filter("genres[Drama]", "Drama"),
|
||||||
|
Filter("genres[Ecchi]", "Ecchi"),
|
||||||
|
Filter("genres[Fantasy]", "Fantasy"),
|
||||||
|
Filter("genres[Gender Bender]", "Gender Bender"),
|
||||||
|
Filter("genres[Harem]", "Harem"),
|
||||||
|
Filter("genres[Historical]", "Historical"),
|
||||||
|
Filter("genres[Horror]", "Horror"),
|
||||||
|
Filter("genres[Josei]", "Josei"),
|
||||||
|
Filter("genres[Martial Arts]", "Martial Arts"),
|
||||||
|
Filter("genres[Mature]", "Mature"),
|
||||||
|
Filter("genres[Mecha]", "Mecha"),
|
||||||
|
Filter("genres[Mystery]", "Mystery"),
|
||||||
|
Filter("genres[One Shot]", "One Shot"),
|
||||||
|
Filter("genres[Psychological]", "Psychological"),
|
||||||
|
Filter("genres[Romance]", "Romance"),
|
||||||
|
Filter("genres[School Life]", "School Life"),
|
||||||
|
Filter("genres[Sci-fi]", "Sci-fi"),
|
||||||
|
Filter("genres[Seinen]", "Seinen"),
|
||||||
|
Filter("genres[Shoujo]", "Shoujo"),
|
||||||
|
Filter("genres[Shoujo Ai]", "Shoujo Ai"),
|
||||||
|
Filter("genres[Shounen]", "Shounen"),
|
||||||
|
Filter("genres[Shounen Ai]", "Shounen Ai"),
|
||||||
|
Filter("genres[Slice of Life]", "Slice of Life"),
|
||||||
|
Filter("genres[Sports]", "Sports"),
|
||||||
|
Filter("genres[Supernatural]", "Supernatural"),
|
||||||
|
Filter("genres[Tragedy]", "Tragedy"),
|
||||||
|
Filter("genres[Yaoi]", "Yaoi"),
|
||||||
|
Filter("genres[Yuri]", "Yuri")
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
@ -1,117 +1,163 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.english
|
package eu.kanade.tachiyomi.data.source.online.english
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.network.POST
|
||||||
import eu.kanade.tachiyomi.data.source.EN
|
import eu.kanade.tachiyomi.data.source.EN
|
||||||
import eu.kanade.tachiyomi.data.source.Language
|
import eu.kanade.tachiyomi.data.source.Language
|
||||||
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.util.*
|
import java.text.SimpleDateFormat
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
class Mangasee(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class Mangasee(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "Mangasee"
|
override val name = "Mangasee"
|
||||||
|
|
||||||
override val baseUrl = "http://www.mangasee.co"
|
override val baseUrl = "http://mangaseeonline.net"
|
||||||
|
|
||||||
override val lang: Language get() = EN
|
override val lang: Language get() = EN
|
||||||
|
|
||||||
private val datePattern = Pattern.compile("(\\d+)\\s+(.*?)s? ago.*")
|
override val supportsLatest = true
|
||||||
|
|
||||||
private val dateFields = HashMap<String, Int>().apply {
|
private val recentUpdatesPattern = Pattern.compile("(.*?)\\s(\\d+\\.?\\d*)\\s?(Completed)?")
|
||||||
put("second", Calendar.SECOND)
|
|
||||||
put("minute", Calendar.MINUTE)
|
private val indexPattern = Pattern.compile("-index-(.*?)-")
|
||||||
put("hour", Calendar.HOUR)
|
|
||||||
put("day", Calendar.DATE)
|
override fun popularMangaInitialUrl() = "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending"
|
||||||
put("week", Calendar.WEEK_OF_YEAR)
|
|
||||||
put("month", Calendar.MONTH)
|
override fun popularMangaSelector() = "div.requested > div.row"
|
||||||
put("year", Calendar.YEAR)
|
|
||||||
|
override fun popularMangaRequest(page: MangasPage): Request {
|
||||||
|
if (page.page == 1) {
|
||||||
|
page.url = popularMangaInitialUrl()
|
||||||
|
}
|
||||||
|
val (body, requestUrl) = convertQueryToPost(page)
|
||||||
|
return POST(requestUrl, headers, body.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/search_result.php?Action=Yes&order=popularity&numResultPerPage=20&sort=desc"
|
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
for (element in document.select(popularMangaSelector())) {
|
||||||
|
Manga.create(id).apply {
|
||||||
|
popularMangaFromElement(element, this)
|
||||||
|
page.mangas.add(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.well > table > tbody > tr"
|
page.nextPageUrl = page.url
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("td > h2 > a").first().let {
|
element.select("a.resultLink").first().let {
|
||||||
manga.setUrlWithoutDomain("/${it.attr("href")}")
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
manga.title = it.text()
|
manga.title = it.text()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "ul.pagination > li > a:contains(Next)"
|
// Not used, overrides parent.
|
||||||
|
override fun popularMangaNextPageSelector() = ""
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) =
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||||
"$baseUrl/advanced-search/result.php?sortBy=alphabet&direction=ASC&textOnly=no&resPerPage=20&page=1&seriesName=$query"
|
"$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending&keyword=$query&genre=${filters.map { it.id }.joinToString(",")}"
|
||||||
|
|
||||||
override fun searchMangaSelector() = "div.row > div > div > div > h1"
|
override fun searchMangaSelector() = "div.searchResults > div.requested > div.row"
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
|
||||||
|
if (page.page == 1) {
|
||||||
|
page.url = searchMangaInitialUrl(query, filters)
|
||||||
|
}
|
||||||
|
val (body, requestUrl) = convertQueryToPost(page)
|
||||||
|
return POST(requestUrl, headers, body.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun convertQueryToPost(page: MangasPage): Pair<FormBody.Builder, String> {
|
||||||
|
val url = HttpUrl.parse(page.url)
|
||||||
|
val body = FormBody.Builder().add("page", page.page.toString())
|
||||||
|
for (i in 0..url.querySize() - 1) {
|
||||||
|
body.add(url.queryParameterName(i), url.queryParameterValue(i))
|
||||||
|
}
|
||||||
|
val requestUrl = url.scheme() + "://" + url.host() + url.encodedPath()
|
||||||
|
return Pair(body, requestUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
for (element in document.select(popularMangaSelector())) {
|
||||||
|
Manga.create(id).apply {
|
||||||
|
popularMangaFromElement(element, this)
|
||||||
|
page.mangas.add(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page.nextPageUrl = page.url
|
||||||
|
}
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("a").first().let {
|
element.select("a.resultLink").first().let {
|
||||||
manga.setUrlWithoutDomain("/${it.attr("href")}")
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
manga.title = it.text()
|
manga.title = it.text()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = "ul.pagination > li > a:contains(Next)"
|
// Not used, overrides parent.
|
||||||
|
override fun searchMangaNextPageSelector() = ""
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||||
val detailElement = document.select("div.well > div.row").first()
|
val detailElement = document.select("div.well > div.row").first()
|
||||||
|
|
||||||
manga.author = detailElement.select("a[href^=../search_result.php?author_name=]").first()?.text()
|
manga.author = detailElement.select("a[href^=/search/?author=]").first()?.text()
|
||||||
manga.genre = detailElement.select("div > div.row > div:has(b:contains(Genre:)) > a").map { it.text() }.joinToString()
|
manga.genre = detailElement.select("span.details > div.row > div:has(b:contains(Genre(s))) > a").map { it.text() }.joinToString()
|
||||||
manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text()
|
manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text()
|
||||||
manga.status = detailElement.select("div > div.row > div:has(b:contains(Scanlation Status:))").first()?.text().orEmpty().let { parseStatus(it) }
|
manga.status = detailElement.select("a[href^=/search/?status=]").first()?.text().orEmpty().let { parseStatus(it) }
|
||||||
manga.thumbnail_url = detailElement.select("div > img").first()?.absUrl("src")
|
manga.thumbnail_url = detailElement.select("div > img").first()?.absUrl("src")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseStatus(status: String) = when {
|
private fun parseStatus(status: String) = when {
|
||||||
status.contains("Ongoing") -> Manga.ONGOING
|
status.contains("Ongoing (Scan)") -> Manga.ONGOING
|
||||||
status.contains("Completed") -> Manga.COMPLETED
|
status.contains("Complete (Scan)") -> Manga.COMPLETED
|
||||||
else -> Manga.UNKNOWN
|
else -> Manga.UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun chapterListSelector() = "div.row > div > div.row:has(a.chapter_link[alt])"
|
override fun chapterListSelector() = "div.chapter-list > a"
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
override fun chapterFromElement(element: Element, chapter: Chapter) {
|
||||||
val urlElement = element.select("a").first()
|
val urlElement = element.select("a").first()
|
||||||
|
|
||||||
chapter.setUrlWithoutDomain("/${urlElement.attr("href")}")
|
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
||||||
chapter.name = urlElement.text()
|
chapter.name = element.select("span.chapterLabel").first().text()?.let { it } ?: ""
|
||||||
chapter.date_upload = element.select("span").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
chapter.date_upload = element.select("time").first()?.attr("datetime")?.let { parseChapterDate(it) } ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseChapterDate(dateAsString: String): Long {
|
private fun parseChapterDate(dateAsString: String): Long {
|
||||||
val m = datePattern.matcher(dateAsString)
|
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(dateAsString).time
|
||||||
|
|
||||||
if (m.matches()) {
|
|
||||||
val amount = Integer.parseInt(m.group(1))
|
|
||||||
val unit = m.group(2)
|
|
||||||
|
|
||||||
return Calendar.getInstance().apply {
|
|
||||||
add(dateFields[unit]!!, -amount)
|
|
||||||
}.time.time
|
|
||||||
} else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
val url = response.request().url().toString().substringBeforeLast('/')
|
val fullUrl = response.request().url().toString()
|
||||||
|
val url = fullUrl.substringBeforeLast('/')
|
||||||
|
|
||||||
val series = document.select("input[name=series]").first().attr("value")
|
val series = document.select("input.IndexName").first().attr("value")
|
||||||
val chapter = document.select("input[name=chapter]").first().attr("value")
|
val chapter = document.select("span.CurChapter").first().text()
|
||||||
val index = document.select("input[name=index]").first().attr("value")
|
var index = ""
|
||||||
|
|
||||||
document.select("select[name=page] > option").forEach {
|
val m = indexPattern.matcher(fullUrl)
|
||||||
pages.add(Page(pages.size, "$url/?series=$series&chapter=$chapter&index=$index&page=${pages.size + 1}"))
|
if (m.find()) {
|
||||||
|
val indexNumber = m.group(1)
|
||||||
|
index = "-index-$indexNumber"
|
||||||
|
}
|
||||||
|
|
||||||
|
document.select("div.ContainerNav").first().select("select.PageSelect > option").forEach {
|
||||||
|
pages.add(Page(pages.size, "$url/$series-chapter-$chapter$index-page-${pages.size + 1}.html"))
|
||||||
}
|
}
|
||||||
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
||||||
}
|
}
|
||||||
@ -120,6 +166,88 @@ class Mangasee(context: Context, override val id: Int) : ParsedOnlineSource(cont
|
|||||||
override fun pageListParse(document: Document, pages: MutableList<Page>) {
|
override fun pageListParse(document: Document, pages: MutableList<Page>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = document.select("div > a > img").attr("src")
|
override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src")
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
|
||||||
|
// http://mangasee.co/advanced-search/
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("Action", "Action"),
|
||||||
|
Filter("Adult", "Adult"),
|
||||||
|
Filter("Adventure", "Adventure"),
|
||||||
|
Filter("Comedy", "Comedy"),
|
||||||
|
Filter("Doujinshi", "Doujinshi"),
|
||||||
|
Filter("Drama", "Drama"),
|
||||||
|
Filter("Ecchi", "Ecchi"),
|
||||||
|
Filter("Fantasy", "Fantasy"),
|
||||||
|
Filter("Gender_Bender", "Gender Bender"),
|
||||||
|
Filter("Harem", "Harem"),
|
||||||
|
Filter("Hentai", "Hentai"),
|
||||||
|
Filter("Historical", "Historical"),
|
||||||
|
Filter("Horror", "Horror"),
|
||||||
|
Filter("Josei", "Josei"),
|
||||||
|
Filter("Lolicon", "Lolicon"),
|
||||||
|
Filter("Martial_Arts", "Martial Arts"),
|
||||||
|
Filter("Mature", "Mature"),
|
||||||
|
Filter("Mecha", "Mecha"),
|
||||||
|
Filter("Mystery", "Mystery"),
|
||||||
|
Filter("Psychological", "Psychological"),
|
||||||
|
Filter("Romance", "Romance"),
|
||||||
|
Filter("School_Life", "School Life"),
|
||||||
|
Filter("Sci-fi", "Sci-fi"),
|
||||||
|
Filter("Seinen", "Seinen"),
|
||||||
|
Filter("Shotacon", "Shotacon"),
|
||||||
|
Filter("Shoujo", "Shoujo"),
|
||||||
|
Filter("Shoujo_Ai", "Shoujo Ai"),
|
||||||
|
Filter("Shounen", "Shounen"),
|
||||||
|
Filter("Shounen_Ai", "Shounen Ai"),
|
||||||
|
Filter("Slice_of_Life", "Slice of Life"),
|
||||||
|
Filter("Smut", "Smut"),
|
||||||
|
Filter("Sports", "Sports"),
|
||||||
|
Filter("Supernatural", "Supernatural"),
|
||||||
|
Filter("Tragedy", "Tragedy"),
|
||||||
|
Filter("Yaoi", "Yaoi"),
|
||||||
|
Filter("Yuri", "Yuri")
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun latestUpdatesInitialUrl(): String = "http://mangaseeonline.net/home/latest.request.php"
|
||||||
|
|
||||||
|
// Not used, overrides parent.
|
||||||
|
override fun latestUpdatesNextPageSelector(): String = ""
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector(): String = "a.latestSeries"
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: MangasPage): Request {
|
||||||
|
if (page.page == 1) {
|
||||||
|
page.url = latestUpdatesInitialUrl()
|
||||||
|
}
|
||||||
|
val (body, requestUrl) = convertQueryToPost(page)
|
||||||
|
return POST(requestUrl, headers, body.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response, page: MangasPage) {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
for (element in document.select(latestUpdatesSelector())) {
|
||||||
|
Manga.create(id).apply {
|
||||||
|
latestUpdatesFromElement(element, this)
|
||||||
|
page.mangas.add(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page.nextPageUrl = page.url
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
element.select("a.latestSeries").first().let {
|
||||||
|
val chapterUrl = it.attr("href")
|
||||||
|
val indexOfMangaUrl = chapterUrl.indexOf("-chapter-")
|
||||||
|
val indexOfLastPath = chapterUrl.lastIndexOf("/")
|
||||||
|
val mangaUrl = chapterUrl.substring(indexOfLastPath, indexOfMangaUrl)
|
||||||
|
val defaultText = it.select("p.clamp2").text()
|
||||||
|
val m = recentUpdatesPattern.matcher(defaultText)
|
||||||
|
val title = if (m.matches()) m.group(1) else defaultText
|
||||||
|
manga.setUrlWithoutDomain("/manga" + mangaUrl)
|
||||||
|
manga.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -8,14 +8,16 @@ import eu.kanade.tachiyomi.data.source.EN
|
|||||||
import eu.kanade.tachiyomi.data.source.Language
|
import eu.kanade.tachiyomi.data.source.Language
|
||||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||||
|
import okhttp3.Headers
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class Readmangatoday(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "ReadMangaToday"
|
override val name = "ReadMangaToday"
|
||||||
|
|
||||||
@ -23,12 +25,26 @@ class Readmangatoday(context: Context, override val id: Int) : ParsedOnlineSourc
|
|||||||
|
|
||||||
override val lang: Language get() = EN
|
override val lang: Language get() = EN
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
override val client: OkHttpClient get() = network.cloudflareClient
|
override val client: OkHttpClient get() = network.cloudflareClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search only returns data with this set
|
||||||
|
*/
|
||||||
|
override fun headersBuilder() = Headers.Builder().apply {
|
||||||
|
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
|
||||||
|
add("X-Requested-With", "XMLHttpRequest")
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/hot-manga/"
|
override fun popularMangaInitialUrl() = "$baseUrl/hot-manga/"
|
||||||
|
|
||||||
|
override fun latestUpdatesInitialUrl() = "$baseUrl/latest-releases/"
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.hot-manga > div.style-list > div.box"
|
override fun popularMangaSelector() = "div.hot-manga > div.style-list > div.box"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = "div.hot-manga > div.style-grid > div.box"
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("div.title > h2 > a").first().let {
|
element.select("div.title > h2 > a").first().let {
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
@ -36,24 +52,35 @@ class Readmangatoday(context: Context, override val id: Int) : ParsedOnlineSourc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
popularMangaFromElement(element, manga)
|
||||||
override fun searchMangaInitialUrl(query: String) =
|
|
||||||
"$baseUrl/search"
|
|
||||||
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: MangasPage, query: String): Request {
|
|
||||||
if (page.page == 1) {
|
|
||||||
page.url = searchMangaInitialUrl(query)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var builder = okhttp3.FormBody.Builder()
|
override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
|
||||||
builder.add("query", query)
|
|
||||||
|
override fun latestUpdatesNextPageSelector(): String = "div.hot-manga > ul.pagination > li > a:contains(»)"
|
||||||
|
|
||||||
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||||
|
"$baseUrl/service/advanced_search"
|
||||||
|
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<OnlineSource.Filter>): Request {
|
||||||
|
if (page.page == 1) {
|
||||||
|
page.url = searchMangaInitialUrl(query, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = okhttp3.FormBody.Builder()
|
||||||
|
builder.add("manga-name", query)
|
||||||
|
builder.add("type", "all")
|
||||||
|
builder.add("status", "both")
|
||||||
|
for (filter in filters) {
|
||||||
|
builder.add("include[]", filter.id)
|
||||||
|
}
|
||||||
|
|
||||||
return POST(page.url, headers, builder.build())
|
return POST(page.url, headers, builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaSelector() = "div.content-list > div.style-list > div.box"
|
override fun searchMangaSelector() = "div.style-list > div.box"
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("div.title > h2 > a").first().let {
|
element.select("div.title > h2 > a").first().let {
|
||||||
@ -127,4 +154,43 @@ class Readmangatoday(context: Context, override val id: Int) : ParsedOnlineSourc
|
|||||||
|
|
||||||
override fun imageUrlParse(document: Document) = document.select("img.img-responsive-2").first().attr("src")
|
override fun imageUrlParse(document: Document) = document.select("img.img-responsive-2").first().attr("src")
|
||||||
|
|
||||||
|
// [...document.querySelectorAll("ul.manga-cat span")].map(el => `Filter("${el.getAttribute('data-id')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
|
||||||
|
// http://www.readmanga.today/advanced-search
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("2", "Action"),
|
||||||
|
Filter("4", "Adventure"),
|
||||||
|
Filter("5", "Comedy"),
|
||||||
|
Filter("6", "Doujinshi"),
|
||||||
|
Filter("7", "Drama"),
|
||||||
|
Filter("8", "Ecchi"),
|
||||||
|
Filter("9", "Fantasy"),
|
||||||
|
Filter("10", "Gender Bender"),
|
||||||
|
Filter("11", "Harem"),
|
||||||
|
Filter("12", "Historical"),
|
||||||
|
Filter("13", "Horror"),
|
||||||
|
Filter("14", "Josei"),
|
||||||
|
Filter("15", "Lolicon"),
|
||||||
|
Filter("16", "Martial Arts"),
|
||||||
|
Filter("17", "Mature"),
|
||||||
|
Filter("18", "Mecha"),
|
||||||
|
Filter("19", "Mystery"),
|
||||||
|
Filter("20", "One shot"),
|
||||||
|
Filter("21", "Psychological"),
|
||||||
|
Filter("22", "Romance"),
|
||||||
|
Filter("23", "School Life"),
|
||||||
|
Filter("24", "Sci-fi"),
|
||||||
|
Filter("25", "Seinen"),
|
||||||
|
Filter("26", "Shotacon"),
|
||||||
|
Filter("27", "Shoujo"),
|
||||||
|
Filter("28", "Shoujo Ai"),
|
||||||
|
Filter("29", "Shounen"),
|
||||||
|
Filter("30", "Shounen Ai"),
|
||||||
|
Filter("31", "Slice of Life"),
|
||||||
|
Filter("32", "Smut"),
|
||||||
|
Filter("33", "Sports"),
|
||||||
|
Filter("34", "Supernatural"),
|
||||||
|
Filter("35", "Tragedy"),
|
||||||
|
Filter("36", "Yaoi"),
|
||||||
|
Filter("37", "Yuri")
|
||||||
|
)
|
||||||
}
|
}
|
@ -13,7 +13,7 @@ import org.jsoup.nodes.Document
|
|||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
|
||||||
class WieManga(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class WieManga(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "Wie Manga!"
|
override val name = "Wie Manga!"
|
||||||
|
|
||||||
@ -21,10 +21,16 @@ class WieManga(context: Context, override val id: Int) : ParsedOnlineSource(cont
|
|||||||
|
|
||||||
override val lang: Language get() = DE
|
override val lang: Language get() = DE
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/list/Hot-Book/"
|
override fun popularMangaInitialUrl() = "$baseUrl/list/Hot-Book/"
|
||||||
|
|
||||||
|
override fun latestUpdatesInitialUrl() = "$baseUrl/list/New-Update/"
|
||||||
|
|
||||||
override fun popularMangaSelector() = ".booklist td > div"
|
override fun popularMangaSelector() = ".booklist td > div"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = ".booklist td > div"
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
val image = element.select("dt img")
|
val image = element.select("dt img")
|
||||||
val title = element.select("dd a:first-child")
|
val title = element.select("dd a:first-child")
|
||||||
@ -34,9 +40,15 @@ class WieManga(context: Context, override val id: Int) : ParsedOnlineSource(cont
|
|||||||
manga.thumbnail_url = image.attr("src")
|
manga.thumbnail_url = image.attr("src")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
popularMangaFromElement(element, manga)
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = null
|
override fun popularMangaNextPageSelector() = null
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search/?wd=$query"
|
override fun latestUpdatesNextPageSelector() = null
|
||||||
|
|
||||||
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search/?wd=$query"
|
||||||
|
|
||||||
override fun searchMangaSelector() = ".searchresult td > div"
|
override fun searchMangaSelector() = ".searchresult td > div"
|
||||||
|
|
||||||
|
@ -1,19 +1,20 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.russian
|
package eu.kanade.tachiyomi.data.source.online.russian
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.source.Language
|
import eu.kanade.tachiyomi.data.source.Language
|
||||||
import eu.kanade.tachiyomi.data.source.RU
|
import eu.kanade.tachiyomi.data.source.RU
|
||||||
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class Mangachan(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class Mangachan(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "Mangachan"
|
override val name = "Mangachan"
|
||||||
|
|
||||||
@ -21,12 +22,30 @@ class Mangachan(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
|
|
||||||
override val lang: Language get() = RU
|
override val lang: Language get() = RU
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites"
|
override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites"
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) = "$baseUrl/?do=search&subaction=search&story=$query"
|
override fun latestUpdatesInitialUrl() = "$baseUrl/newestch"
|
||||||
|
|
||||||
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>): String {
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
return "$baseUrl/?do=search&subaction=search&story=$query"
|
||||||
|
} else if (filters.isNotEmpty()) {
|
||||||
|
var genres = ""
|
||||||
|
filters.forEach { genres = genres + it.name + '+' }
|
||||||
|
return "$baseUrl/tags/${genres.dropLast(1)}"
|
||||||
|
} else {
|
||||||
|
return "$baseUrl/?do=search&subaction=search&story=$query"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.content_row"
|
override fun popularMangaSelector() = "div.content_row"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = "ul.area_rightNews li"
|
||||||
|
|
||||||
|
override fun searchMangaSelector() = popularMangaSelector()
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("h2 > a").first().let {
|
element.select("h2 > a").first().let {
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
@ -34,15 +53,49 @@ class Mangachan(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "a:contains(Вперед)"
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
element.select("a:nth-child(1)").first().let {
|
||||||
override fun searchMangaSelector() = popularMangaSelector()
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
|
manga.title = it.text()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||||
popularMangaFromElement(element, manga)
|
popularMangaFromElement(element, manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
override fun popularMangaNextPageSelector() = "a:contains(Вперед)"
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector()
|
||||||
|
|
||||||
|
override fun searchMangaNextPageSelector() = "a:contains(Далее)"
|
||||||
|
|
||||||
|
private fun searchGenresNextPageSelector() = popularMangaNextPageSelector()
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response, page: MangasPage, query: String, filters: List<Filter>) {
|
||||||
|
val document = response.asJsoup()
|
||||||
|
for (element in document.select(searchMangaSelector())) {
|
||||||
|
Manga.create(id).apply {
|
||||||
|
searchMangaFromElement(element, this)
|
||||||
|
page.mangas.add(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchMangaNextPageSelector().let { selector ->
|
||||||
|
if (page.nextPageUrl.isNullOrEmpty() && filters.isEmpty()) {
|
||||||
|
val onClick = document.select(selector).first()?.attr("onclick")
|
||||||
|
val pageNum = onClick?.substring(23, onClick.indexOf("); return(false)"))
|
||||||
|
page.nextPageUrl = searchMangaInitialUrl(query, emptyList()) + "&search_start=" + pageNum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchGenresNextPageSelector().let { selector ->
|
||||||
|
if (page.nextPageUrl.isNullOrEmpty() && filters.isNotEmpty()) {
|
||||||
|
val url = document.select(selector).first()?.attr("href")
|
||||||
|
page.nextPageUrl = searchMangaInitialUrl(query, filters) + url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||||
val infoElement = document.select("table.mangatitle").first()
|
val infoElement = document.select("table.mangatitle").first()
|
||||||
@ -83,12 +136,74 @@ class Mangachan(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
val trimmedHtml = html.substring(beginIndex, endIndex).replace("\"", "")
|
val trimmedHtml = html.substring(beginIndex, endIndex).replace("\"", "")
|
||||||
val pageUrls = trimmedHtml.split(',')
|
val pageUrls = trimmedHtml.split(',')
|
||||||
|
|
||||||
for ((i, url) in pageUrls.withIndex()) {
|
pageUrls.mapIndexedTo(pages) { i, url -> Page(i, "", url) }
|
||||||
pages.add(Page(i, "", url))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = ""
|
override fun imageUrlParse(document: Document) = ""
|
||||||
|
|
||||||
|
/* [...document.querySelectorAll("li.sidetag > a:nth-child(1)")].map((el,i) =>
|
||||||
|
* { const link=el.getAttribute('href');const id=link.substr(6,link.length);
|
||||||
|
* return `Filter("${id}", "${id}")` }).join(',\n')
|
||||||
|
* on http://mangachan.me/
|
||||||
|
*/
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("18_плюс", "18_плюс"),
|
||||||
|
Filter("bdsm", "bdsm"),
|
||||||
|
Filter("арт", "арт"),
|
||||||
|
Filter("биография", "биография"),
|
||||||
|
Filter("боевик", "боевик"),
|
||||||
|
Filter("боевые_искусства", "боевые_искусства"),
|
||||||
|
Filter("вампиры", "вампиры"),
|
||||||
|
Filter("веб", "веб"),
|
||||||
|
Filter("гарем", "гарем"),
|
||||||
|
Filter("гендерная_интрига", "гендерная_интрига"),
|
||||||
|
Filter("героическое_фэнтези", "героическое_фэнтези"),
|
||||||
|
Filter("детектив", "детектив"),
|
||||||
|
Filter("дзёсэй", "дзёсэй"),
|
||||||
|
Filter("додзинси", "додзинси"),
|
||||||
|
Filter("драма", "драма"),
|
||||||
|
Filter("игра", "игра"),
|
||||||
|
Filter("инцест", "инцест"),
|
||||||
|
Filter("искусство", "искусство"),
|
||||||
|
Filter("история", "история"),
|
||||||
|
Filter("киберпанк", "киберпанк"),
|
||||||
|
Filter("кодомо", "кодомо"),
|
||||||
|
Filter("комедия", "комедия"),
|
||||||
|
Filter("литРПГ", "литРПГ"),
|
||||||
|
Filter("махо-сёдзё", "махо-сёдзё"),
|
||||||
|
Filter("меха", "меха"),
|
||||||
|
Filter("мистика", "мистика"),
|
||||||
|
Filter("музыка", "музыка"),
|
||||||
|
Filter("научная_фантастика", "научная_фантастика"),
|
||||||
|
Filter("повседневность", "повседневность"),
|
||||||
|
Filter("постапокалиптика", "постапокалиптика"),
|
||||||
|
Filter("приключения", "приключения"),
|
||||||
|
Filter("психология", "психология"),
|
||||||
|
Filter("романтика", "романтика"),
|
||||||
|
Filter("самурайский_боевик", "самурайский_боевик"),
|
||||||
|
Filter("сборник", "сборник"),
|
||||||
|
Filter("сверхъестественное", "сверхъестественное"),
|
||||||
|
Filter("сказка", "сказка"),
|
||||||
|
Filter("спорт", "спорт"),
|
||||||
|
Filter("супергерои", "супергерои"),
|
||||||
|
Filter("сэйнэн", "сэйнэн"),
|
||||||
|
Filter("сёдзё", "сёдзё"),
|
||||||
|
Filter("сёдзё-ай", "сёдзё-ай"),
|
||||||
|
Filter("сёнэн", "сёнэн"),
|
||||||
|
Filter("сёнэн-ай", "сёнэн-ай"),
|
||||||
|
Filter("тентакли", "тентакли"),
|
||||||
|
Filter("трагедия", "трагедия"),
|
||||||
|
Filter("триллер", "триллер"),
|
||||||
|
Filter("ужасы", "ужасы"),
|
||||||
|
Filter("фантастика", "фантастика"),
|
||||||
|
Filter("фурри", "фурри"),
|
||||||
|
Filter("фэнтези", "фэнтези"),
|
||||||
|
Filter("школа", "школа"),
|
||||||
|
Filter("эротика", "эротика"),
|
||||||
|
Filter("юри", "юри"),
|
||||||
|
Filter("яой", "яой"),
|
||||||
|
Filter("ёнкома", "ёнкома")
|
||||||
|
)
|
||||||
}
|
}
|
@ -1,6 +1,5 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.russian
|
package eu.kanade.tachiyomi.data.source.online.russian
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.source.Language
|
import eu.kanade.tachiyomi.data.source.Language
|
||||||
@ -14,7 +13,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
class Mintmanga(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class Mintmanga(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "Mintmanga"
|
override val name = "Mintmanga"
|
||||||
|
|
||||||
@ -22,12 +21,19 @@ class Mintmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
|
|
||||||
override val lang: Language get() = RU
|
override val lang: Language get() = RU
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
|
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search?q=$query"
|
override fun latestUpdatesInitialUrl() = "$baseUrl/list?sortType=updated"
|
||||||
|
|
||||||
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||||
|
"$baseUrl/search?q=$query&${filters.map { it.id + "=in" }.joinToString("&")}"
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.desc"
|
override fun popularMangaSelector() = "div.desc"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = "div.desc"
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("h3 > a").first().let {
|
element.select("h3 > a").first().let {
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
@ -35,15 +41,22 @@ class Mintmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
popularMangaFromElement(element, manga)
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "a.nextLink"
|
override fun popularMangaNextPageSelector() = "a.nextLink"
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = "a.nextLink"
|
||||||
|
|
||||||
override fun searchMangaSelector() = popularMangaSelector()
|
override fun searchMangaSelector() = popularMangaSelector()
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||||
popularMangaFromElement(element, manga)
|
popularMangaFromElement(element, manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
// max 200 results
|
||||||
|
override fun searchMangaNextPageSelector() = null
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||||
val infoElement = document.select("div.leftContent").first()
|
val infoElement = document.select("div.leftContent").first()
|
||||||
@ -76,7 +89,7 @@ class Mintmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
} ?: 0
|
} ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseChapterNumber(chapter: Chapter) {
|
override fun prepareNewChapter(chapter: Chapter, manga: Manga) {
|
||||||
chapter.chapter_number = -2f
|
chapter.chapter_number = -2f
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,4 +112,54 @@ class Mintmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = ""
|
override fun imageUrlParse(document: Document) = ""
|
||||||
|
|
||||||
|
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => {
|
||||||
|
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
|
||||||
|
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
|
||||||
|
* on http://mintmanga.com/search
|
||||||
|
*/
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("el_2220", "арт"),
|
||||||
|
Filter("el_1353", "бара"),
|
||||||
|
Filter("el_1346", "боевик"),
|
||||||
|
Filter("el_1334", "боевые искусства"),
|
||||||
|
Filter("el_1339", "вампиры"),
|
||||||
|
Filter("el_1333", "гарем"),
|
||||||
|
Filter("el_1347", "гендерная интрига"),
|
||||||
|
Filter("el_1337", "героическое фэнтези"),
|
||||||
|
Filter("el_1343", "детектив"),
|
||||||
|
Filter("el_1349", "дзёсэй"),
|
||||||
|
Filter("el_1332", "додзинси"),
|
||||||
|
Filter("el_1310", "драма"),
|
||||||
|
Filter("el_5229", "игра"),
|
||||||
|
Filter("el_1311", "история"),
|
||||||
|
Filter("el_1351", "киберпанк"),
|
||||||
|
Filter("el_1328", "комедия"),
|
||||||
|
Filter("el_1318", "меха"),
|
||||||
|
Filter("el_1324", "мистика"),
|
||||||
|
Filter("el_1325", "научная фантастика"),
|
||||||
|
Filter("el_1327", "повседневность"),
|
||||||
|
Filter("el_1342", "постапокалиптика"),
|
||||||
|
Filter("el_1322", "приключения"),
|
||||||
|
Filter("el_1335", "психология"),
|
||||||
|
Filter("el_1313", "романтика"),
|
||||||
|
Filter("el_1316", "самурайский боевик"),
|
||||||
|
Filter("el_1350", "сверхъестественное"),
|
||||||
|
Filter("el_1314", "сёдзё"),
|
||||||
|
Filter("el_1320", "сёдзё-ай"),
|
||||||
|
Filter("el_1326", "сёнэн"),
|
||||||
|
Filter("el_1330", "сёнэн-ай"),
|
||||||
|
Filter("el_1321", "спорт"),
|
||||||
|
Filter("el_1329", "сэйнэн"),
|
||||||
|
Filter("el_1344", "трагедия"),
|
||||||
|
Filter("el_1341", "триллер"),
|
||||||
|
Filter("el_1317", "ужасы"),
|
||||||
|
Filter("el_1331", "фантастика"),
|
||||||
|
Filter("el_1323", "фэнтези"),
|
||||||
|
Filter("el_1319", "школа"),
|
||||||
|
Filter("el_1340", "эротика"),
|
||||||
|
Filter("el_1354", "этти"),
|
||||||
|
Filter("el_1315", "юри"),
|
||||||
|
Filter("el_1336", "яой")
|
||||||
|
)
|
||||||
}
|
}
|
@ -1,6 +1,5 @@
|
|||||||
package eu.kanade.tachiyomi.data.source.online.russian
|
package eu.kanade.tachiyomi.data.source.online.russian
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.source.Language
|
import eu.kanade.tachiyomi.data.source.Language
|
||||||
@ -14,7 +13,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
class Readmanga(context: Context, override val id: Int) : ParsedOnlineSource(context) {
|
class Readmanga(override val id: Int) : ParsedOnlineSource() {
|
||||||
|
|
||||||
override val name = "Readmanga"
|
override val name = "Readmanga"
|
||||||
|
|
||||||
@ -22,12 +21,19 @@ class Readmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
|
|
||||||
override val lang: Language get() = RU
|
override val lang: Language get() = RU
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
|
override fun popularMangaInitialUrl() = "$baseUrl/list?sortType=rate"
|
||||||
|
|
||||||
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search?q=$query"
|
override fun latestUpdatesInitialUrl() = "$baseUrl/list?sortType=updated"
|
||||||
|
|
||||||
|
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
|
||||||
|
"$baseUrl/search?q=$query&${filters.map { it.id + "=in" }.joinToString("&")}"
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.desc"
|
override fun popularMangaSelector() = "div.desc"
|
||||||
|
|
||||||
|
override fun latestUpdatesSelector() = "div.desc"
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
override fun popularMangaFromElement(element: Element, manga: Manga) {
|
||||||
element.select("h3 > a").first().let {
|
element.select("h3 > a").first().let {
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
manga.setUrlWithoutDomain(it.attr("href"))
|
||||||
@ -35,15 +41,22 @@ class Readmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
|
||||||
|
popularMangaFromElement(element, manga)
|
||||||
|
}
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "a.nextLink"
|
override fun popularMangaNextPageSelector() = "a.nextLink"
|
||||||
|
|
||||||
|
override fun latestUpdatesNextPageSelector() = "a.nextLink"
|
||||||
|
|
||||||
override fun searchMangaSelector() = popularMangaSelector()
|
override fun searchMangaSelector() = popularMangaSelector()
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
override fun searchMangaFromElement(element: Element, manga: Manga) {
|
||||||
popularMangaFromElement(element, manga)
|
popularMangaFromElement(element, manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = popularMangaNextPageSelector()
|
// max 200 results
|
||||||
|
override fun searchMangaNextPageSelector() = null
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
override fun mangaDetailsParse(document: Document, manga: Manga) {
|
||||||
val infoElement = document.select("div.leftContent").first()
|
val infoElement = document.select("div.leftContent").first()
|
||||||
@ -76,7 +89,7 @@ class Readmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
} ?: 0
|
} ?: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun parseChapterNumber(chapter: Chapter) {
|
override fun prepareNewChapter(chapter: Chapter, manga: Manga) {
|
||||||
chapter.chapter_number = -2f
|
chapter.chapter_number = -2f
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,4 +112,53 @@ class Readmanga(context: Context, override val id: Int) : ParsedOnlineSource(con
|
|||||||
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = ""
|
override fun imageUrlParse(document: Document) = ""
|
||||||
|
|
||||||
|
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => {
|
||||||
|
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
|
||||||
|
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
|
||||||
|
* on http://readmanga.me/search
|
||||||
|
*/
|
||||||
|
override fun getFilterList(): List<Filter> = listOf(
|
||||||
|
Filter("el_5685", "арт"),
|
||||||
|
Filter("el_2155", "боевик"),
|
||||||
|
Filter("el_2143", "боевые искусства"),
|
||||||
|
Filter("el_2148", "вампиры"),
|
||||||
|
Filter("el_2142", "гарем"),
|
||||||
|
Filter("el_2156", "гендерная интрига"),
|
||||||
|
Filter("el_2146", "героическое фэнтези"),
|
||||||
|
Filter("el_2152", "детектив"),
|
||||||
|
Filter("el_2158", "дзёсэй"),
|
||||||
|
Filter("el_2141", "додзинси"),
|
||||||
|
Filter("el_2118", "драма"),
|
||||||
|
Filter("el_2154", "игра"),
|
||||||
|
Filter("el_2119", "история"),
|
||||||
|
Filter("el_8032", "киберпанк"),
|
||||||
|
Filter("el_2137", "кодомо"),
|
||||||
|
Filter("el_2136", "комедия"),
|
||||||
|
Filter("el_2147", "махо-сёдзё"),
|
||||||
|
Filter("el_2126", "меха"),
|
||||||
|
Filter("el_2132", "мистика"),
|
||||||
|
Filter("el_2133", "научная фантастика"),
|
||||||
|
Filter("el_2135", "повседневность"),
|
||||||
|
Filter("el_2151", "постапокалиптика"),
|
||||||
|
Filter("el_2130", "приключения"),
|
||||||
|
Filter("el_2144", "психология"),
|
||||||
|
Filter("el_2121", "романтика"),
|
||||||
|
Filter("el_2124", "самурайский боевик"),
|
||||||
|
Filter("el_2159", "сверхъестественное"),
|
||||||
|
Filter("el_2122", "сёдзё"),
|
||||||
|
Filter("el_2128", "сёдзё-ай"),
|
||||||
|
Filter("el_2134", "сёнэн"),
|
||||||
|
Filter("el_2139", "сёнэн-ай"),
|
||||||
|
Filter("el_2129", "спорт"),
|
||||||
|
Filter("el_2138", "сэйнэн"),
|
||||||
|
Filter("el_2153", "трагедия"),
|
||||||
|
Filter("el_2150", "триллер"),
|
||||||
|
Filter("el_2125", "ужасы"),
|
||||||
|
Filter("el_2140", "фантастика"),
|
||||||
|
Filter("el_2131", "фэнтези"),
|
||||||
|
Filter("el_2127", "школа"),
|
||||||
|
Filter("el_2149", "этти"),
|
||||||
|
Filter("el_2123", "юри")
|
||||||
|
)
|
||||||
}
|
}
|
@ -6,7 +6,6 @@ import retrofit2.converter.gson.GsonConverterFactory
|
|||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to connect with the Github API.
|
* Used to connect with the Github API.
|
||||||
*/
|
*/
|
||||||
|
@ -1,20 +1,25 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
import android.content.Context
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.util.toast
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
|
class GithubUpdateChecker() {
|
||||||
|
|
||||||
class GithubUpdateChecker(private val context: Context) {
|
private val service: GithubService = GithubService.create()
|
||||||
|
|
||||||
val service: GithubService = GithubService.create()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns observable containing release information
|
* Returns observable containing release information
|
||||||
*/
|
*/
|
||||||
fun checkForApplicationUpdate(): Observable<GithubRelease> {
|
fun checkForUpdate(): Observable<GithubUpdateResult> {
|
||||||
context.toast(R.string.update_check_look_for_updates)
|
return service.getLatestVersion().map { release ->
|
||||||
return service.getLatestVersion()
|
val newVersion = release.version.replace("[^\\d.]".toRegex(), "")
|
||||||
|
|
||||||
|
// Check if latest version is different from current version
|
||||||
|
if (newVersion != BuildConfig.VERSION_NAME) {
|
||||||
|
GithubUpdateResult.NewUpdate(release)
|
||||||
|
} else {
|
||||||
|
GithubUpdateResult.NoNewUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
sealed class GithubUpdateResult {
|
||||||
|
|
||||||
|
class NewUpdate(val release: GithubRelease): GithubUpdateResult()
|
||||||
|
class NoNewUpdate(): GithubUpdateResult()
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
import android.support.v4.app.NotificationCompat
|
||||||
|
import com.evernote.android.job.Job
|
||||||
|
import com.evernote.android.job.JobManager
|
||||||
|
import com.evernote.android.job.JobRequest
|
||||||
|
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.util.notificationManager
|
||||||
|
|
||||||
|
class UpdateCheckerJob : Job() {
|
||||||
|
|
||||||
|
override fun onRunJob(params: Params): Result {
|
||||||
|
return GithubUpdateChecker()
|
||||||
|
.checkForUpdate()
|
||||||
|
.map { result ->
|
||||||
|
if (result is GithubUpdateResult.NewUpdate) {
|
||||||
|
val url = result.release.downloadLink
|
||||||
|
|
||||||
|
NotificationCompat.Builder(context).update {
|
||||||
|
setContentTitle(context.getString(R.string.app_name))
|
||||||
|
setContentText(context.getString(R.string.update_check_notification_update_available))
|
||||||
|
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
// Download action
|
||||||
|
addAction(android.R.drawable.stat_sys_download_done,
|
||||||
|
context.getString(R.string.action_download),
|
||||||
|
UpdateNotificationReceiver.downloadApkIntent(context, url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Job.Result.SUCCESS
|
||||||
|
}
|
||||||
|
.onErrorReturn { Job.Result.FAILURE }
|
||||||
|
// Sadly, the task needs to be synchronous.
|
||||||
|
.toBlocking()
|
||||||
|
.single()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
|
||||||
|
block()
|
||||||
|
context.notificationManager.notify(NOTIFICATION_UPDATER_ID, build())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "UpdateChecker"
|
||||||
|
|
||||||
|
fun setupTask() {
|
||||||
|
JobRequest.Builder(TAG)
|
||||||
|
.setPeriodic(24 * 60 * 60 * 1000, 60 * 60 * 1000)
|
||||||
|
.setRequiredNetworkType(JobRequest.NetworkType.CONNECTED)
|
||||||
|
.setRequirementsEnforced(true)
|
||||||
|
.setPersisted(true)
|
||||||
|
.setUpdateCurrent(true)
|
||||||
|
.build()
|
||||||
|
.schedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelTask() {
|
||||||
|
JobManager.instance().cancelAllForTag(TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,202 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.AsyncTask
|
|
||||||
import android.support.v4.app.NotificationCompat
|
|
||||||
import eu.kanade.tachiyomi.Constants
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.network.GET
|
|
||||||
import eu.kanade.tachiyomi.data.network.NetworkHelper
|
|
||||||
import eu.kanade.tachiyomi.data.network.ProgressListener
|
|
||||||
import eu.kanade.tachiyomi.data.network.newCallWithProgress
|
|
||||||
import eu.kanade.tachiyomi.util.notificationManager
|
|
||||||
import eu.kanade.tachiyomi.util.saveTo
|
|
||||||
import timber.log.Timber
|
|
||||||
import uy.kohesive.injekt.injectLazy
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class UpdateDownloader(private val context: Context) :
|
|
||||||
AsyncTask<String, Int, UpdateDownloader.DownloadResult>() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Prompt user with apk install intent
|
|
||||||
* @param context context
|
|
||||||
* @param file file of apk that is installed
|
|
||||||
*/
|
|
||||||
fun installAPK(context: Context, file: File) {
|
|
||||||
// Prompt install interface
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
|
||||||
intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
|
|
||||||
// Without this flag android returned a intent error!
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val network: NetworkHelper by injectLazy()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default download dir
|
|
||||||
*/
|
|
||||||
private val apkFile = File(context.externalCacheDir, "update.apk")
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notification builder
|
|
||||||
*/
|
|
||||||
private val notificationBuilder = NotificationCompat.Builder(context)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Id of the notification
|
|
||||||
*/
|
|
||||||
private val notificationId: Int
|
|
||||||
get() = Constants.NOTIFICATION_UPDATER_ID
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class containing download result
|
|
||||||
* @param url url of file
|
|
||||||
* @param successful status of download
|
|
||||||
*/
|
|
||||||
class DownloadResult(var url: String, var successful: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called before downloading
|
|
||||||
*/
|
|
||||||
override fun onPreExecute() {
|
|
||||||
// Create download notification
|
|
||||||
with(notificationBuilder) {
|
|
||||||
setContentTitle(context.getString(R.string.update_check_notification_file_download))
|
|
||||||
setContentText(context.getString(R.string.update_check_notification_download_in_progress))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun doInBackground(vararg params: String?): DownloadResult {
|
|
||||||
// Initialize information array containing path and url to file.
|
|
||||||
val result = DownloadResult(params[0]!!, false)
|
|
||||||
|
|
||||||
// Progress of the download
|
|
||||||
var savedProgress = 0
|
|
||||||
|
|
||||||
val progressListener = object : ProgressListener {
|
|
||||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
|
||||||
val progress = (100 * bytesRead / contentLength).toInt()
|
|
||||||
if (progress > savedProgress) {
|
|
||||||
savedProgress = progress
|
|
||||||
publishProgress(progress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Make the request and download the file
|
|
||||||
val response = network.client.newCallWithProgress(GET(result.url), progressListener).execute()
|
|
||||||
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
response.body().source().saveTo(apkFile)
|
|
||||||
// Set download successful
|
|
||||||
result.successful = true
|
|
||||||
} else {
|
|
||||||
response.close()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, e.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when progress is updated
|
|
||||||
* @param values values containing progress
|
|
||||||
*/
|
|
||||||
override fun onProgressUpdate(vararg values: Int?) {
|
|
||||||
// Notify notification manager to update notification
|
|
||||||
values.getOrNull(0)?.let {
|
|
||||||
notificationBuilder.setProgress(100, it, false)
|
|
||||||
// Displays the progress bar on notification
|
|
||||||
context.notificationManager.notify(notificationId, notificationBuilder.build())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when download done
|
|
||||||
* @param result string containing download information
|
|
||||||
*/
|
|
||||||
override fun onPostExecute(result: DownloadResult) {
|
|
||||||
with(notificationBuilder) {
|
|
||||||
if (result.successful) {
|
|
||||||
setContentTitle(context.getString(R.string.app_name))
|
|
||||||
setContentText(context.getString(R.string.update_check_notification_download_complete))
|
|
||||||
addAction(R.drawable.ic_system_update_grey_24dp_img, context.getString(R.string.action_install),
|
|
||||||
getInstallOnReceivedIntent(InstallOnReceived.INSTALL_APK, apkFile.absolutePath))
|
|
||||||
addAction(R.drawable.ic_clear_grey_24dp_img, context.getString(R.string.action_cancel),
|
|
||||||
getInstallOnReceivedIntent(InstallOnReceived.CANCEL_NOTIFICATION))
|
|
||||||
} else {
|
|
||||||
setContentText(context.getString(R.string.update_check_notification_download_error))
|
|
||||||
addAction(R.drawable.ic_refresh_grey_24dp_img, context.getString(R.string.action_retry),
|
|
||||||
getInstallOnReceivedIntent(InstallOnReceived.RETRY_DOWNLOAD, result.url))
|
|
||||||
addAction(R.drawable.ic_clear_grey_24dp_img, context.getString(R.string.action_cancel),
|
|
||||||
getInstallOnReceivedIntent(InstallOnReceived.CANCEL_NOTIFICATION))
|
|
||||||
}
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
setProgress(0, 0, false)
|
|
||||||
}
|
|
||||||
val notification = notificationBuilder.build()
|
|
||||||
notification.flags = Notification.FLAG_NO_CLEAR
|
|
||||||
context.notificationManager.notify(notificationId, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns broadcast intent
|
|
||||||
* @param action action name of broadcast intent
|
|
||||||
* @param path path of file | url of file
|
|
||||||
* @return broadcast intent
|
|
||||||
*/
|
|
||||||
fun getInstallOnReceivedIntent(action: String, path: String = ""): PendingIntent {
|
|
||||||
val intent = Intent(context, InstallOnReceived::class.java).apply {
|
|
||||||
this.action = action
|
|
||||||
putExtra(InstallOnReceived.FILE_LOCATION, path)
|
|
||||||
}
|
|
||||||
return PendingIntent.getBroadcast(context, 0, intent, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BroadcastEvent used to install apk or retry download
|
|
||||||
*/
|
|
||||||
class InstallOnReceived : BroadcastReceiver() {
|
|
||||||
companion object {
|
|
||||||
// Install apk action
|
|
||||||
const val INSTALL_APK = "eu.kanade.INSTALL_APK"
|
|
||||||
|
|
||||||
// Retry download action
|
|
||||||
const val RETRY_DOWNLOAD = "eu.kanade.RETRY_DOWNLOAD"
|
|
||||||
|
|
||||||
// Retry download action
|
|
||||||
const val CANCEL_NOTIFICATION = "eu.kanade.CANCEL_NOTIFICATION"
|
|
||||||
|
|
||||||
// Absolute path of file || URL of file
|
|
||||||
const val FILE_LOCATION = "file_location"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
when (intent.action) {
|
|
||||||
// Install apk.
|
|
||||||
INSTALL_APK -> UpdateDownloader.installAPK(context, File(intent.getStringExtra(FILE_LOCATION)))
|
|
||||||
// Retry download.
|
|
||||||
RETRY_DOWNLOAD -> UpdateDownloader(context).execute(intent.getStringExtra(FILE_LOCATION))
|
|
||||||
|
|
||||||
CANCEL_NOTIFICATION -> context.notificationManager.cancel(Constants.NOTIFICATION_UPDATER_ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,110 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
|
||||||
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.SystemClock
|
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
|
||||||
import eu.kanade.tachiyomi.util.DeviceUtil
|
|
||||||
import eu.kanade.tachiyomi.util.alarmManager
|
|
||||||
import eu.kanade.tachiyomi.util.notification
|
|
||||||
import eu.kanade.tachiyomi.util.notificationManager
|
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
|
||||||
import rx.schedulers.Schedulers
|
|
||||||
import uy.kohesive.injekt.Injekt
|
|
||||||
import uy.kohesive.injekt.api.get
|
|
||||||
|
|
||||||
class UpdateDownloaderAlarm : BroadcastReceiver() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val CHECK_UPDATE_ACTION = "eu.kanade.CHECK_UPDATE"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the alarm to run the intent that checks for update
|
|
||||||
* @param context the application context.
|
|
||||||
* @param intervalInHours the time in hours when it will be executed.
|
|
||||||
*/
|
|
||||||
fun startAlarm(context: Context, intervalInHours: Int = 12,
|
|
||||||
isEnabled: Boolean = Injekt.get<PreferencesHelper>().automaticUpdateStatus()) {
|
|
||||||
// Stop previous running alarms if needed, and do not restart it if the interval is 0.
|
|
||||||
UpdateDownloaderAlarm.stopAlarm(context)
|
|
||||||
if (intervalInHours == 0 || !isEnabled)
|
|
||||||
return
|
|
||||||
|
|
||||||
// Get the time the alarm should fire the event to update.
|
|
||||||
val intervalInMillis = intervalInHours * 60 * 60 * 1000
|
|
||||||
val nextRun = SystemClock.elapsedRealtime() + intervalInMillis
|
|
||||||
|
|
||||||
// Start the alarm.
|
|
||||||
val pendingIntent = getPendingIntent(context)
|
|
||||||
context.alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
|
|
||||||
nextRun, intervalInMillis.toLong(), pendingIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops the alarm if it's running.
|
|
||||||
* @param context the application context.
|
|
||||||
*/
|
|
||||||
fun stopAlarm(context: Context) {
|
|
||||||
val pendingIntent = getPendingIntent(context)
|
|
||||||
context.alarmManager.cancel(pendingIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns broadcast intent
|
|
||||||
* @param context the application context.
|
|
||||||
* @return broadcast intent
|
|
||||||
*/
|
|
||||||
fun getPendingIntent(context: Context): PendingIntent {
|
|
||||||
return PendingIntent.getBroadcast(context, 0,
|
|
||||||
Intent(context, UpdateDownloaderAlarm::class.java).apply {
|
|
||||||
this.action = CHECK_UPDATE_ACTION
|
|
||||||
}, PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
when (intent.action) {
|
|
||||||
// Start the alarm when the system is booted.
|
|
||||||
Intent.ACTION_BOOT_COMPLETED -> startAlarm(context)
|
|
||||||
// Update the library when the alarm fires an event.
|
|
||||||
CHECK_UPDATE_ACTION -> checkVersion(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkVersion(context: Context) {
|
|
||||||
if (DeviceUtil.isNetworkConnected(context)) {
|
|
||||||
val updateChecker = GithubUpdateChecker(context)
|
|
||||||
updateChecker.checkForApplicationUpdate()
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe({ release ->
|
|
||||||
//Get version of latest release
|
|
||||||
var newVersion = release.version
|
|
||||||
newVersion = newVersion.replace("[^\\d.]".toRegex(), "")
|
|
||||||
|
|
||||||
//Check if latest version is different from current version
|
|
||||||
if (newVersion != BuildConfig.VERSION_NAME) {
|
|
||||||
val downloadLink = release.downloadLink
|
|
||||||
|
|
||||||
val n = context.notification() {
|
|
||||||
setContentTitle(context.getString(R.string.update_check_notification_update_available))
|
|
||||||
addAction(android.R.drawable.stat_sys_download_done, context.getString(eu.kanade.tachiyomi.R.string.action_download),
|
|
||||||
UpdateDownloader(context).getInstallOnReceivedIntent(UpdateDownloader.InstallOnReceived.RETRY_DOWNLOAD, downloadLink))
|
|
||||||
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
|
||||||
}
|
|
||||||
// Displays the progress bar on notification
|
|
||||||
context.notificationManager.notify(0, n);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
it.printStackTrace()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,152 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
import android.app.IntentService
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.support.v4.app.NotificationCompat
|
||||||
|
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.network.GET
|
||||||
|
import eu.kanade.tachiyomi.data.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.data.network.ProgressListener
|
||||||
|
import eu.kanade.tachiyomi.data.network.newCallWithProgress
|
||||||
|
import eu.kanade.tachiyomi.util.notificationManager
|
||||||
|
import eu.kanade.tachiyomi.util.saveTo
|
||||||
|
import timber.log.Timber
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.java.name) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Download url.
|
||||||
|
*/
|
||||||
|
const val EXTRA_DOWNLOAD_URL = "eu.kanade.APP_DOWNLOAD_URL"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a new update and let the user install the new version from a notification.
|
||||||
|
* @param context the application context.
|
||||||
|
* @param url the url to the new update.
|
||||||
|
*/
|
||||||
|
fun downloadUpdate(context: Context, url: String) {
|
||||||
|
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
|
||||||
|
putExtra(EXTRA_DOWNLOAD_URL, url)
|
||||||
|
}
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt user with apk install intent
|
||||||
|
* @param context context
|
||||||
|
* @param file file of apk that is installed
|
||||||
|
*/
|
||||||
|
fun installAPK(context: Context, file: File) {
|
||||||
|
// Prompt install interface
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
|
||||||
|
// Without this flag android returned a intent error!
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network helper
|
||||||
|
*/
|
||||||
|
private val network: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
|
override fun onHandleIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
|
||||||
|
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return
|
||||||
|
downloadApk(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadApk(url: String) {
|
||||||
|
val progressNotification = NotificationCompat.Builder(this)
|
||||||
|
|
||||||
|
progressNotification.update {
|
||||||
|
setContentTitle(getString(R.string.app_name))
|
||||||
|
setContentText(getString(R.string.update_check_notification_download_in_progress))
|
||||||
|
setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
|
setOngoing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress of the download
|
||||||
|
var savedProgress = 0
|
||||||
|
|
||||||
|
val progressListener = object : ProgressListener {
|
||||||
|
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||||
|
val progress = (100 * bytesRead / contentLength).toInt()
|
||||||
|
if (progress > savedProgress) {
|
||||||
|
savedProgress = progress
|
||||||
|
|
||||||
|
progressNotification.update { setProgress(100, progress, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference the context for later usage inside apply blocks.
|
||||||
|
val ctx = this
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Download the new update.
|
||||||
|
val response = network.client.newCallWithProgress(GET(url), progressListener).execute()
|
||||||
|
|
||||||
|
// File where the apk will be saved
|
||||||
|
val apkFile = File(externalCacheDir, "update.apk")
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body().source().saveTo(apkFile)
|
||||||
|
} else {
|
||||||
|
response.close()
|
||||||
|
throw Exception("Unsuccessful response")
|
||||||
|
}
|
||||||
|
|
||||||
|
val installIntent = UpdateNotificationReceiver.installApkIntent(ctx, apkFile.absolutePath)
|
||||||
|
|
||||||
|
// Prompt the user to install the new update.
|
||||||
|
NotificationCompat.Builder(this).update {
|
||||||
|
setContentTitle(getString(R.string.app_name))
|
||||||
|
setContentText(getString(R.string.update_check_notification_download_complete))
|
||||||
|
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
// Install action
|
||||||
|
setContentIntent(installIntent)
|
||||||
|
addAction(R.drawable.ic_system_update_grey_24dp_img,
|
||||||
|
getString(R.string.action_install),
|
||||||
|
installIntent)
|
||||||
|
// Cancel action
|
||||||
|
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||||
|
getString(R.string.action_cancel),
|
||||||
|
UpdateNotificationReceiver.cancelNotificationIntent(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Timber.e(error)
|
||||||
|
|
||||||
|
// Prompt the user to retry the download.
|
||||||
|
NotificationCompat.Builder(this).update {
|
||||||
|
setContentTitle(getString(R.string.app_name))
|
||||||
|
setContentText(getString(R.string.update_check_notification_download_error))
|
||||||
|
setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
// Retry action
|
||||||
|
addAction(R.drawable.ic_refresh_grey_24dp_img,
|
||||||
|
getString(R.string.action_retry),
|
||||||
|
UpdateNotificationReceiver.downloadApkIntent(ctx, url))
|
||||||
|
// Cancel action
|
||||||
|
addAction(R.drawable.ic_clear_grey_24dp_img,
|
||||||
|
getString(R.string.action_cancel),
|
||||||
|
UpdateNotificationReceiver.cancelNotificationIntent(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) {
|
||||||
|
block()
|
||||||
|
notificationManager.notify(NOTIFICATION_UPDATER_ID, build())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
|
||||||
|
import eu.kanade.tachiyomi.util.notificationManager
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UpdateNotificationReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_INSTALL_APK -> {
|
||||||
|
UpdateDownloaderService.installAPK(context,
|
||||||
|
File(intent.getStringExtra(EXTRA_FILE_LOCATION)))
|
||||||
|
cancelNotification(context)
|
||||||
|
}
|
||||||
|
ACTION_DOWNLOAD_UPDATE -> UpdateDownloaderService.downloadUpdate(context,
|
||||||
|
intent.getStringExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL))
|
||||||
|
ACTION_CANCEL_NOTIFICATION -> cancelNotification(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelNotification(context: Context) {
|
||||||
|
context.notificationManager.cancel(NOTIFICATION_UPDATER_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Install apk action
|
||||||
|
const val ACTION_INSTALL_APK = "eu.kanade.INSTALL_APK"
|
||||||
|
|
||||||
|
// Download apk action
|
||||||
|
const val ACTION_DOWNLOAD_UPDATE = "eu.kanade.RETRY_DOWNLOAD"
|
||||||
|
|
||||||
|
// Cancel notification action
|
||||||
|
const val ACTION_CANCEL_NOTIFICATION = "eu.kanade.CANCEL_NOTIFICATION"
|
||||||
|
|
||||||
|
// Absolute path of apk file
|
||||||
|
const val EXTRA_FILE_LOCATION = "file_location"
|
||||||
|
|
||||||
|
fun cancelNotificationIntent(context: Context): PendingIntent {
|
||||||
|
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_CANCEL_NOTIFICATION
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(context, 0, intent, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installApkIntent(context: Context, path: String): PendingIntent {
|
||||||
|
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_INSTALL_APK
|
||||||
|
putExtra(EXTRA_FILE_LOCATION, path)
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(context, 0, intent, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadApkIntent(context: Context, url: String): PendingIntent {
|
||||||
|
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
|
||||||
|
action = ACTION_DOWNLOAD_UPDATE
|
||||||
|
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url)
|
||||||
|
}
|
||||||
|
return PendingIntent.getBroadcast(context, 0, intent, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -41,6 +41,8 @@ class BackupFragment : BaseRxFragment<BackupPresenter>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||||
|
setToolbarTitle(getString(R.string.label_backup))
|
||||||
|
|
||||||
(activity as ActivityMixin).requestPermissionsOnMarshmallow()
|
(activity as ActivityMixin).requestPermissionsOnMarshmallow()
|
||||||
subscriptions = SubscriptionList()
|
subscriptions = SubscriptionList()
|
||||||
|
|
||||||
@ -121,9 +123,9 @@ class BackupFragment : BaseRxFragment<BackupPresenter>() {
|
|||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
presenter.restoreBackup(it)
|
presenter.restoreBackup(it)
|
||||||
}, {
|
}, { error ->
|
||||||
context.toast(it.message)
|
context.toast(error.message)
|
||||||
Timber.e(it, it.message)
|
Timber.e(error)
|
||||||
})
|
})
|
||||||
.apply { subscriptions.add(this) }
|
.apply { subscriptions.add(this) }
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ abstract class FlexibleViewHolder(view: View,
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun toggleActivation() {
|
fun toggleActivation() {
|
||||||
itemView.isActivated = adapter.isSelected(adapterPosition)
|
itemView.isActivated = adapter.isSelected(adapterPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,13 +2,10 @@ package eu.kanade.tachiyomi.ui.catalogue
|
|||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.v7.widget.GridLayoutManager
|
import android.support.design.widget.Snackbar
|
||||||
import android.support.v7.widget.LinearLayoutManager
|
import android.support.v7.widget.*
|
||||||
import android.support.v7.widget.SearchView
|
|
||||||
import android.support.v7.widget.Toolbar
|
|
||||||
import android.view.*
|
import android.view.*
|
||||||
import android.view.animation.AnimationUtils
|
import android.view.animation.AnimationUtils
|
||||||
import android.widget.AdapterView
|
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
import android.widget.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.Spinner
|
import android.widget.Spinner
|
||||||
@ -16,15 +13,15 @@ import com.afollestad.materialdialogs.MaterialDialog
|
|||||||
import com.f2prateek.rx.preferences.Preference
|
import com.f2prateek.rx.preferences.Preference
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.LoginSource
|
||||||
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||||
import eu.kanade.tachiyomi.ui.manga.MangaActivity
|
import eu.kanade.tachiyomi.ui.manga.MangaActivity
|
||||||
import eu.kanade.tachiyomi.util.getResourceDrawable
|
|
||||||
import eu.kanade.tachiyomi.util.snack
|
import eu.kanade.tachiyomi.util.snack
|
||||||
import eu.kanade.tachiyomi.util.toast
|
import eu.kanade.tachiyomi.util.toast
|
||||||
import eu.kanade.tachiyomi.widget.DividerItemDecoration
|
|
||||||
import eu.kanade.tachiyomi.widget.EndlessScrollListener
|
import eu.kanade.tachiyomi.widget.EndlessScrollListener
|
||||||
|
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
|
||||||
import kotlinx.android.synthetic.main.fragment_catalogue.*
|
import kotlinx.android.synthetic.main.fragment_catalogue.*
|
||||||
import kotlinx.android.synthetic.main.toolbar.*
|
import kotlinx.android.synthetic.main.toolbar.*
|
||||||
import nucleus.factory.RequiresPresenter
|
import nucleus.factory.RequiresPresenter
|
||||||
@ -39,12 +36,12 @@ import java.util.concurrent.TimeUnit.MILLISECONDS
|
|||||||
* Uses R.layout.fragment_catalogue.
|
* Uses R.layout.fragment_catalogue.
|
||||||
*/
|
*/
|
||||||
@RequiresPresenter(CataloguePresenter::class)
|
@RequiresPresenter(CataloguePresenter::class)
|
||||||
class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHolder.OnListItemClickListener {
|
open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHolder.OnListItemClickListener {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spinner shown in the toolbar to change the selected source.
|
* Spinner shown in the toolbar to change the selected source.
|
||||||
*/
|
*/
|
||||||
private lateinit var spinner: Spinner
|
private var spinner: Spinner? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter containing the list of manga from the catalogue.
|
* Adapter containing the list of manga from the catalogue.
|
||||||
@ -64,7 +61,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
/**
|
/**
|
||||||
* Query of the search box.
|
* Query of the search box.
|
||||||
*/
|
*/
|
||||||
private val query: String?
|
private val query: String
|
||||||
get() = presenter.query
|
get() = presenter.query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,11 +89,6 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
*/
|
*/
|
||||||
private var numColumnsSubscription: Subscription? = null
|
private var numColumnsSubscription: Subscription? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Display mode of the catalogue (list or grid mode).
|
|
||||||
*/
|
|
||||||
private var displayMode: MenuItem? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search item.
|
* Search item.
|
||||||
*/
|
*/
|
||||||
@ -129,6 +121,14 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||||
|
// If the source list is empty or it only has unlogged sources, return to main screen.
|
||||||
|
val sources = presenter.sources
|
||||||
|
if (sources.isEmpty() || sources.all { it is LoginSource && !it.isLogged() }) {
|
||||||
|
context.toast(R.string.no_valid_sources)
|
||||||
|
activity.onBackPressed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize adapter, scroll listener and recycler views
|
// Initialize adapter, scroll listener and recycler views
|
||||||
adapter = CatalogueAdapter(this)
|
adapter = CatalogueAdapter(this)
|
||||||
|
|
||||||
@ -144,8 +144,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
catalogue_list.adapter = adapter
|
catalogue_list.adapter = adapter
|
||||||
catalogue_list.layoutManager = llm
|
catalogue_list.layoutManager = llm
|
||||||
catalogue_list.addOnScrollListener(listScrollListener)
|
catalogue_list.addOnScrollListener(listScrollListener)
|
||||||
catalogue_list.addItemDecoration(DividerItemDecoration(context.theme.getResourceDrawable(R.attr.divider_drawable)))
|
catalogue_list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||||
|
|
||||||
if (presenter.isListMode) {
|
if (presenter.isListMode) {
|
||||||
switcher.showNext()
|
switcher.showNext()
|
||||||
}
|
}
|
||||||
@ -166,11 +165,10 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
android.R.layout.simple_spinner_item, presenter.sources)
|
android.R.layout.simple_spinner_item, presenter.sources)
|
||||||
spinnerAdapter.setDropDownViewResource(R.layout.spinner_item)
|
spinnerAdapter.setDropDownViewResource(R.layout.spinner_item)
|
||||||
|
|
||||||
val onItemSelected = object : AdapterView.OnItemSelectedListener {
|
val onItemSelected = IgnoreFirstSpinnerListener { position ->
|
||||||
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
|
||||||
val source = spinnerAdapter.getItem(position)
|
val source = spinnerAdapter.getItem(position)
|
||||||
if (!presenter.isValidSource(source)) {
|
if (!presenter.isValidSource(source)) {
|
||||||
spinner.setSelection(selectedIndex)
|
spinner?.setSelection(selectedIndex)
|
||||||
context.toast(R.string.source_requires_login)
|
context.toast(R.string.source_requires_login)
|
||||||
} else if (source != presenter.source) {
|
} else if (source != presenter.source) {
|
||||||
selectedIndex = position
|
selectedIndex = position
|
||||||
@ -178,16 +176,14 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
glm.scrollToPositionWithOffset(0, 0)
|
glm.scrollToPositionWithOffset(0, 0)
|
||||||
llm.scrollToPositionWithOffset(0, 0)
|
llm.scrollToPositionWithOffset(0, 0)
|
||||||
presenter.setActiveSource(source)
|
presenter.setActiveSource(source)
|
||||||
|
activity.invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNothingSelected(parent: AdapterView<*>) {
|
selectedIndex = presenter.sources.indexOf(presenter.source)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
spinner = Spinner(themedContext).apply {
|
spinner = Spinner(themedContext).apply {
|
||||||
adapter = spinnerAdapter
|
adapter = spinnerAdapter
|
||||||
selectedIndex = presenter.sources.indexOf(presenter.source)
|
|
||||||
setSelection(selectedIndex)
|
setSelection(selectedIndex)
|
||||||
onItemSelectedListener = onItemSelected
|
onItemSelectedListener = onItemSelected
|
||||||
}
|
}
|
||||||
@ -205,7 +201,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
searchItem = menu.findItem(R.id.action_search).apply {
|
searchItem = menu.findItem(R.id.action_search).apply {
|
||||||
val searchView = actionView as SearchView
|
val searchView = actionView as SearchView
|
||||||
|
|
||||||
if (!query.isNullOrEmpty()) {
|
if (!query.isBlank()) {
|
||||||
expandActionView()
|
expandActionView()
|
||||||
searchView.setQuery(query, true)
|
searchView.setQuery(query, true)
|
||||||
searchView.clearFocus()
|
searchView.clearFocus()
|
||||||
@ -223,20 +219,31 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup filters button
|
||||||
|
menu.findItem(R.id.action_set_filter).apply {
|
||||||
|
if (presenter.source.filters.isEmpty()) {
|
||||||
|
isEnabled = false
|
||||||
|
icon.alpha = 128
|
||||||
|
} else {
|
||||||
|
isEnabled = true
|
||||||
|
icon.alpha = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show next display mode
|
// Show next display mode
|
||||||
displayMode = menu.findItem(R.id.action_display_mode).apply {
|
menu.findItem(R.id.action_display_mode).apply {
|
||||||
val icon = if (presenter.isListMode)
|
val icon = if (presenter.isListMode)
|
||||||
R.drawable.ic_view_module_white_24dp
|
R.drawable.ic_view_module_white_24dp
|
||||||
else
|
else
|
||||||
R.drawable.ic_view_list_white_24dp
|
R.drawable.ic_view_list_white_24dp
|
||||||
setIcon(icon)
|
setIcon(icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
when (item.itemId) {
|
when (item.itemId) {
|
||||||
R.id.action_display_mode -> swapDisplayMode()
|
R.id.action_display_mode -> swapDisplayMode()
|
||||||
|
R.id.action_set_filter -> showFiltersDialog()
|
||||||
else -> return super.onOptionsItemSelected(item)
|
else -> return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@ -259,7 +266,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
searchItem?.let {
|
searchItem?.let {
|
||||||
if (it.isActionViewExpanded) it.collapseActionView()
|
if (it.isActionViewExpanded) it.collapseActionView()
|
||||||
}
|
}
|
||||||
toolbar.removeView(spinner)
|
spinner?.let { toolbar.removeView(it) }
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,7 +319,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
*/
|
*/
|
||||||
fun onAddPage(page: Int, mangas: List<Manga>) {
|
fun onAddPage(page: Int, mangas: List<Manga>) {
|
||||||
hideProgressBar()
|
hideProgressBar()
|
||||||
if (page == 0) {
|
if (page == 1) {
|
||||||
adapter.clear()
|
adapter.clear()
|
||||||
gridScrollListener.resetScroll()
|
gridScrollListener.resetScroll()
|
||||||
listScrollListener.resetScroll()
|
listScrollListener.resetScroll()
|
||||||
@ -327,12 +334,12 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
*/
|
*/
|
||||||
fun onAddPageError(error: Throwable) {
|
fun onAddPageError(error: Throwable) {
|
||||||
hideProgressBar()
|
hideProgressBar()
|
||||||
Timber.e(error, error.message)
|
Timber.e(error)
|
||||||
|
|
||||||
catalogue_view.snack(error.message ?: "") {
|
catalogue_view.snack(error.message ?: "", Snackbar.LENGTH_INDEFINITE) {
|
||||||
setAction(R.string.action_retry) {
|
setAction(R.string.action_retry) {
|
||||||
showProgressBar()
|
showProgressBar()
|
||||||
presenter.retryPage()
|
presenter.requestNext()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -352,11 +359,7 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
fun swapDisplayMode() {
|
fun swapDisplayMode() {
|
||||||
presenter.swapDisplayMode()
|
presenter.swapDisplayMode()
|
||||||
val isListMode = presenter.isListMode
|
val isListMode = presenter.isListMode
|
||||||
val icon = if (isListMode)
|
activity.invalidateOptionsMenu()
|
||||||
R.drawable.ic_view_module_white_24dp
|
|
||||||
else
|
|
||||||
R.drawable.ic_view_list_white_24dp
|
|
||||||
displayMode?.setIcon(icon)
|
|
||||||
switcher.showNext()
|
switcher.showNext()
|
||||||
if (!isListMode) {
|
if (!isListMode) {
|
||||||
// Initialize mangas if going to grid view
|
// Initialize mangas if going to grid view
|
||||||
@ -444,4 +447,27 @@ class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleViewHold
|
|||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the filter dialog for the source.
|
||||||
|
*/
|
||||||
|
private fun showFiltersDialog() {
|
||||||
|
val allFilters = presenter.source.filters
|
||||||
|
val selectedFilters = presenter.filters
|
||||||
|
.map { filter -> allFilters.indexOf(filter) }
|
||||||
|
.toTypedArray()
|
||||||
|
|
||||||
|
MaterialDialog.Builder(context)
|
||||||
|
.title(R.string.action_set_filter)
|
||||||
|
.items(allFilters.map { it.name })
|
||||||
|
.itemsCallbackMultiChoice(selectedFilters) { dialog, positions, text ->
|
||||||
|
val newFilters = positions.map { allFilters[it] }
|
||||||
|
showProgressBar()
|
||||||
|
presenter.setSourceFilter(newFilters)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
.positiveText(android.R.string.ok)
|
||||||
|
.negativeText(android.R.string.cancel)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,28 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.catalogue
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter
|
||||||
|
import rx.Observable
|
||||||
|
|
||||||
|
open class CataloguePager(val source: OnlineSource, val query: String, val filters: List<Filter>): Pager() {
|
||||||
|
|
||||||
|
override fun requestNext(transformer: (Observable<MangasPage>) -> Observable<MangasPage>): Observable<MangasPage> {
|
||||||
|
val lastPage = lastPage
|
||||||
|
|
||||||
|
val page = if (lastPage == null)
|
||||||
|
MangasPage(1)
|
||||||
|
else
|
||||||
|
MangasPage(lastPage.page + 1).apply { url = lastPage.nextPageUrl!! }
|
||||||
|
|
||||||
|
val observable = if (query.isBlank() && filters.isEmpty())
|
||||||
|
source.fetchPopularManga(page)
|
||||||
|
else
|
||||||
|
source.fetchSearchManga(page, query, filters)
|
||||||
|
|
||||||
|
return transformer(observable)
|
||||||
|
.doOnNext { results.onNext(it) }
|
||||||
|
.doOnNext { this@CataloguePager.lastPage = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -12,19 +12,21 @@ import eu.kanade.tachiyomi.data.source.SourceManager
|
|||||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.data.source.online.LoginSource
|
import eu.kanade.tachiyomi.data.source.online.LoginSource
|
||||||
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
import eu.kanade.tachiyomi.util.RxPager
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import rx.Subscription
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
import rx.subjects.PublishSubject
|
import rx.subjects.PublishSubject
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.util.NoSuchElementException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presenter of [CatalogueFragment].
|
* Presenter of [CatalogueFragment].
|
||||||
*/
|
*/
|
||||||
class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source manager.
|
* Source manager.
|
||||||
@ -64,14 +66,14 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pager containing a list of manga results.
|
* Active filters.
|
||||||
*/
|
*/
|
||||||
private var pager = RxPager<Manga>()
|
var filters: List<Filter> = emptyList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last fetched page from network.
|
* Pager containing a list of manga results.
|
||||||
*/
|
*/
|
||||||
private var lastMangasPage: MangasPage? = null
|
private lateinit var pager: Pager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subject that initializes a list of manga.
|
* Subject that initializes a list of manga.
|
||||||
@ -84,80 +86,93 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
|||||||
var isListMode: Boolean = false
|
var isListMode: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
/**
|
||||||
* Id of the restartable that delivers a list of manga.
|
* Subscription for the pager.
|
||||||
*/
|
*/
|
||||||
const val PAGER = 1
|
private var pagerSubscription: Subscription? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Id of the restartable that requests a page of manga from network.
|
* Subscription for one request from the pager.
|
||||||
*/
|
*/
|
||||||
const val REQUEST_PAGE = 2
|
private var pageSubscription: Subscription? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Id of the restartable that initializes the details of manga.
|
* Subscription to initialize manga details.
|
||||||
*/
|
*/
|
||||||
const val GET_MANGA_DETAILS = 3
|
private var initializerSubscription: Subscription? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Key to save and restore [query] from a [Bundle].
|
|
||||||
*/
|
|
||||||
const val QUERY_KEY = "query_key"
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
override fun onCreate(savedState: Bundle?) {
|
||||||
super.onCreate(savedState)
|
super.onCreate(savedState)
|
||||||
|
|
||||||
|
try {
|
||||||
source = getLastUsedSource()
|
source = getLastUsedSource()
|
||||||
|
} catch (error: NoSuchElementException) {
|
||||||
if (savedState != null) {
|
return
|
||||||
query = savedState.getString(QUERY_KEY, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startableLatestCache(GET_MANGA_DETAILS,
|
if (savedState != null) {
|
||||||
{ mangaDetailSubject.observeOn(Schedulers.io())
|
query = savedState.getString(CataloguePresenter::query.name, "")
|
||||||
.flatMap { Observable.from(it) }
|
}
|
||||||
.filter { !it.initialized }
|
|
||||||
.concatMap { getMangaDetailsObservable(it) }
|
|
||||||
.onBackpressureBuffer()
|
|
||||||
.observeOn(AndroidSchedulers.mainThread()) },
|
|
||||||
{ view, manga -> view.onMangaInitialized(manga) },
|
|
||||||
{ view, error -> Timber.e(error.message) })
|
|
||||||
|
|
||||||
add(prefs.catalogueAsList().asObservable()
|
add(prefs.catalogueAsList().asObservable()
|
||||||
.subscribe { setDisplayMode(it) })
|
.subscribe { setDisplayMode(it) })
|
||||||
|
|
||||||
startableReplay(PAGER,
|
restartPager()
|
||||||
{ pager.results() },
|
|
||||||
{ view, pair -> view.onAddPage(pair.first, pair.second) })
|
|
||||||
|
|
||||||
startableFirst(REQUEST_PAGE,
|
|
||||||
{ pager.request { page -> getMangasPageObservable(page + 1) } },
|
|
||||||
{ view, next -> },
|
|
||||||
{ view, error -> view.onAddPageError(error) })
|
|
||||||
|
|
||||||
start(PAGER)
|
|
||||||
start(REQUEST_PAGE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSave(state: Bundle) {
|
override fun onSave(state: Bundle) {
|
||||||
state.putString(QUERY_KEY, query)
|
state.putString(CataloguePresenter::query.name, query)
|
||||||
super.onSave(state)
|
super.onSave(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the display mode.
|
* Restarts the pager for the active source with the provided query and filters.
|
||||||
*
|
*
|
||||||
* @param asList whether the current mode is in list or not.
|
* @param query the query.
|
||||||
|
* @param filters the list of active filters (for search mode).
|
||||||
*/
|
*/
|
||||||
private fun setDisplayMode(asList: Boolean) {
|
fun restartPager(query: String = this.query, filters: List<Filter> = this.filters) {
|
||||||
isListMode = asList
|
this.query = query
|
||||||
if (asList) {
|
this.filters = filters
|
||||||
stop(GET_MANGA_DETAILS)
|
|
||||||
} else {
|
if (!isListMode) {
|
||||||
start(GET_MANGA_DETAILS)
|
subscribeToMangaInitializer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new pager.
|
||||||
|
pager = createPager(query, filters)
|
||||||
|
|
||||||
|
// Prepare the pager.
|
||||||
|
pagerSubscription?.let { remove(it) }
|
||||||
|
pagerSubscription = pager.results()
|
||||||
|
.subscribeReplay({ view, page ->
|
||||||
|
view.onAddPage(page.page, page.mangas)
|
||||||
|
}, { view, error ->
|
||||||
|
Timber.e(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request first page.
|
||||||
|
requestNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests the next page for the active pager.
|
||||||
|
*/
|
||||||
|
fun requestNext() {
|
||||||
|
if (!hasNextPage()) return
|
||||||
|
|
||||||
|
pageSubscription?.let { remove(it) }
|
||||||
|
pageSubscription = pager.requestNext { getPageTransformer(it) }
|
||||||
|
.subscribeFirst({ view, page ->
|
||||||
|
// Nothing to do when onNext is emitted.
|
||||||
|
}, CatalogueFragment::onAddPageError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the last fetched page has a next page.
|
||||||
|
*/
|
||||||
|
fun hasNextPage(): Boolean {
|
||||||
|
return pager.hasNextPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -168,73 +183,64 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
|||||||
fun setActiveSource(source: OnlineSource) {
|
fun setActiveSource(source: OnlineSource) {
|
||||||
prefs.lastUsedCatalogueSource().set(source.id)
|
prefs.lastUsedCatalogueSource().set(source.id)
|
||||||
this.source = source
|
this.source = source
|
||||||
restartPager()
|
|
||||||
|
restartPager(query = "", filters = emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restarts the request for the active source.
|
* Sets the display mode.
|
||||||
*
|
*
|
||||||
* @param query the query, or null if searching popular manga.
|
* @param asList whether the current mode is in list or not.
|
||||||
*/
|
*/
|
||||||
fun restartPager(query: String = "") {
|
private fun setDisplayMode(asList: Boolean) {
|
||||||
this.query = query
|
isListMode = asList
|
||||||
stop(REQUEST_PAGE)
|
if (asList) {
|
||||||
lastMangasPage = null
|
initializerSubscription?.let { remove(it) }
|
||||||
|
} else {
|
||||||
if (!isListMode) {
|
subscribeToMangaInitializer()
|
||||||
start(GET_MANGA_DETAILS)
|
|
||||||
}
|
|
||||||
start(PAGER)
|
|
||||||
start(REQUEST_PAGE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requests the next page for the active pager.
|
|
||||||
*/
|
|
||||||
fun requestNext() {
|
|
||||||
if (hasNextPage()) {
|
|
||||||
start(REQUEST_PAGE)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the last fetched page has a next page.
|
* Subscribes to the initializer of manga details and updates the view if needed.
|
||||||
*/
|
*/
|
||||||
fun hasNextPage(): Boolean {
|
private fun subscribeToMangaInitializer() {
|
||||||
return lastMangasPage?.nextPageUrl != null
|
initializerSubscription?.let { remove(it) }
|
||||||
}
|
initializerSubscription = mangaDetailSubject.observeOn(Schedulers.io())
|
||||||
|
.flatMap { Observable.from(it) }
|
||||||
/**
|
.filter { !it.initialized }
|
||||||
* Retries the current request that failed.
|
.concatMap { getMangaDetailsObservable(it) }
|
||||||
*/
|
.onBackpressureBuffer()
|
||||||
fun retryPage() {
|
|
||||||
start(REQUEST_PAGE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the observable of the network request for a page.
|
|
||||||
*
|
|
||||||
* @param page the page number to request.
|
|
||||||
* @return an observable of the network request.
|
|
||||||
*/
|
|
||||||
private fun getMangasPageObservable(page: Int): Observable<List<Manga>> {
|
|
||||||
val nextMangasPage = MangasPage(page)
|
|
||||||
if (page != 1) {
|
|
||||||
nextMangasPage.url = lastMangasPage!!.nextPageUrl!!
|
|
||||||
}
|
|
||||||
|
|
||||||
val observable = if (query.isEmpty())
|
|
||||||
source.fetchPopularManga(nextMangasPage)
|
|
||||||
else
|
|
||||||
source.fetchSearchManga(nextMangasPage, query)
|
|
||||||
|
|
||||||
return observable.subscribeOn(Schedulers.io())
|
|
||||||
.doOnNext { lastMangasPage = it }
|
|
||||||
.flatMap { Observable.from(it.mangas) }
|
|
||||||
.map { networkToLocalManga(it) }
|
|
||||||
.toList()
|
|
||||||
.doOnNext { initializeMangas(it) }
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe({ manga ->
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
view?.onMangaInitialized(manga)
|
||||||
|
}, { error ->
|
||||||
|
Timber.e(error)
|
||||||
|
})
|
||||||
|
.apply { add(this) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the function to apply to the observable of the list of manga from the source.
|
||||||
|
*
|
||||||
|
* @param observable the observable from the source.
|
||||||
|
* @return the function to apply.
|
||||||
|
*/
|
||||||
|
fun getPageTransformer(observable: Observable<MangasPage>): Observable<MangasPage> {
|
||||||
|
return observable.subscribeOn(Schedulers.io())
|
||||||
|
.doOnNext { it.mangas.replace { networkToLocalManga(it) } }
|
||||||
|
.doOnNext { initializeMangas(it.mangas) }
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces an object in the list with another.
|
||||||
|
*/
|
||||||
|
fun <T> MutableList<T>.replace(block: (T) -> T) {
|
||||||
|
forEachIndexed { i, obj ->
|
||||||
|
set(i, block(obj))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -299,7 +305,7 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
|||||||
* @param source the source to check.
|
* @param source the source to check.
|
||||||
* @return true if the source is valid, false otherwise.
|
* @return true if the source is valid, false otherwise.
|
||||||
*/
|
*/
|
||||||
fun isValidSource(source: Source?): Boolean {
|
open fun isValidSource(source: Source?): Boolean {
|
||||||
if (source == null) return false
|
if (source == null) return false
|
||||||
|
|
||||||
if (source is LoginSource) {
|
if (source is LoginSource) {
|
||||||
@ -321,8 +327,9 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
|||||||
/**
|
/**
|
||||||
* Returns a list of enabled sources ordered by language and name.
|
* Returns a list of enabled sources ordered by language and name.
|
||||||
*/
|
*/
|
||||||
private fun getEnabledSources(): List<OnlineSource> {
|
open protected fun getEnabledSources(): List<OnlineSource> {
|
||||||
val languages = prefs.enabledLanguages().getOrDefault()
|
val languages = prefs.enabledLanguages().getOrDefault()
|
||||||
|
val hiddenCatalogues = prefs.hiddenCatalogues().getOrDefault()
|
||||||
|
|
||||||
// Ensure at least one language
|
// Ensure at least one language
|
||||||
if (languages.isEmpty()) {
|
if (languages.isEmpty()) {
|
||||||
@ -331,6 +338,7 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
|||||||
|
|
||||||
return sourceManager.getOnlineSources()
|
return sourceManager.getOnlineSources()
|
||||||
.filter { it.lang.code in languages }
|
.filter { it.lang.code in languages }
|
||||||
|
.filterNot { it.id.toString() in hiddenCatalogues }
|
||||||
.sortedBy { "(${it.lang.code}) ${it.name}" }
|
.sortedBy { "(${it.lang.code}) ${it.name}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -354,4 +362,17 @@ class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
|||||||
prefs.catalogueAsList().set(!isListMode)
|
prefs.catalogueAsList().set(!isListMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active filters for the current source.
|
||||||
|
*
|
||||||
|
* @param selectedFilters a list of active filters.
|
||||||
|
*/
|
||||||
|
fun setSourceFilter(selectedFilters: List<Filter>) {
|
||||||
|
restartPager(filters = selectedFilters)
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun createPager(query: String, filters: List<Filter>): Pager {
|
||||||
|
return CataloguePager(source, query, filters)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
25
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/Pager.kt
Normal file
25
app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/Pager.kt
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.catalogue
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
|
import rx.subjects.PublishSubject
|
||||||
|
import rx.Observable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A general pager for source requests (latest updates, popular, search)
|
||||||
|
*/
|
||||||
|
abstract class Pager {
|
||||||
|
|
||||||
|
protected var lastPage: MangasPage? = null
|
||||||
|
|
||||||
|
protected val results = PublishSubject.create<MangasPage>()
|
||||||
|
|
||||||
|
fun results(): Observable<MangasPage> {
|
||||||
|
return results.asObservable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasNextPage(): Boolean {
|
||||||
|
return lastPage == null || lastPage?.nextPageUrl != null
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun requestNext(transformer: (Observable<MangasPage>) -> Observable<MangasPage>): Observable<MangasPage>
|
||||||
|
}
|
@ -55,9 +55,9 @@ class CategoryActivity : BaseRxActivity<CategoryPresenter>(), ActionMode.Callbac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedState: Bundle?) {
|
||||||
setAppTheme()
|
setAppTheme()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedState)
|
||||||
|
|
||||||
// Inflate activity_edit_categories.xml.
|
// Inflate activity_edit_categories.xml.
|
||||||
setContentView(R.layout.activity_edit_categories)
|
setContentView(R.layout.activity_edit_categories)
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
package eu.kanade.tachiyomi.ui.category
|
package eu.kanade.tachiyomi.ui.category
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||||
import eu.kanade.tachiyomi.data.database.models.Category
|
import eu.kanade.tachiyomi.data.database.models.Category
|
||||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||||
|
import eu.kanade.tachiyomi.util.toast
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
@ -15,57 +17,41 @@ import uy.kohesive.injekt.injectLazy
|
|||||||
class CategoryPresenter : BasePresenter<CategoryActivity>() {
|
class CategoryPresenter : BasePresenter<CategoryActivity>() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to connect to database
|
* Used to connect to database.
|
||||||
*/
|
*/
|
||||||
val db: DatabaseHelper by injectLazy()
|
private val db: DatabaseHelper by injectLazy()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List containing categories
|
* List containing categories.
|
||||||
*/
|
*/
|
||||||
private var categories: List<Category>? = null
|
private var categories: List<Category> = emptyList()
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* The id of the restartable.
|
|
||||||
*/
|
|
||||||
final private val GET_CATEGORIES = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedState: Bundle?) {
|
override fun onCreate(savedState: Bundle?) {
|
||||||
super.onCreate(savedState)
|
super.onCreate(savedState)
|
||||||
|
|
||||||
// Get categories as list
|
|
||||||
restartableLatestCache(GET_CATEGORIES,
|
|
||||||
{
|
|
||||||
db.getCategories().asRxObservable()
|
db.getCategories().asRxObservable()
|
||||||
.doOnNext { categories -> this.categories = categories }
|
.doOnNext { categories = it }
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
}, CategoryActivity::setCategories)
|
.subscribeLatestCache(CategoryActivity::setCategories)
|
||||||
|
|
||||||
// Start get categories as list task
|
|
||||||
start(GET_CATEGORIES)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create category and add it to database
|
* Create category and add it to database
|
||||||
*
|
*
|
||||||
* @param name name of category
|
* @param name name of category
|
||||||
*/
|
*/
|
||||||
fun createCategory(name: String) {
|
fun createCategory(name: String) {
|
||||||
|
// Do not allow duplicate categories.
|
||||||
|
if (categories.any { it.name.equals(name, true) }) {
|
||||||
|
context.toast(R.string.error_category_exists)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Create category.
|
// Create category.
|
||||||
val cat = Category.create(name)
|
val cat = Category.create(name)
|
||||||
|
|
||||||
// Set the new item in the last position.
|
// Set the new item in the last position.
|
||||||
var max = 0
|
cat.order = categories.map { it.order + 1 }.max() ?: 0
|
||||||
if (categories != null) {
|
|
||||||
for (cat2 in categories!!) {
|
|
||||||
if (cat2.order > max) {
|
|
||||||
max = cat2.order + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cat.order = max
|
|
||||||
|
|
||||||
// Insert into database.
|
// Insert into database.
|
||||||
db.insertCategory(cat).asRxObservable().subscribe()
|
db.insertCategory(cat).asRxObservable().subscribe()
|
||||||
@ -86,8 +72,8 @@ class CategoryPresenter : BasePresenter<CategoryActivity>() {
|
|||||||
* @param categories list of categories
|
* @param categories list of categories
|
||||||
*/
|
*/
|
||||||
fun reorderCategories(categories: List<Category>) {
|
fun reorderCategories(categories: List<Category>) {
|
||||||
for (i in categories.indices) {
|
categories.forEachIndexed { i, category ->
|
||||||
categories[i].order = i
|
category.order = i
|
||||||
}
|
}
|
||||||
|
|
||||||
db.insertCategories(categories).asRxObservable().subscribe()
|
db.insertCategories(categories).asRxObservable().subscribe()
|
||||||
@ -100,6 +86,12 @@ class CategoryPresenter : BasePresenter<CategoryActivity>() {
|
|||||||
* @param name new name of category
|
* @param name new name of category
|
||||||
*/
|
*/
|
||||||
fun renameCategory(category: Category, name: String) {
|
fun renameCategory(category: Category, name: String) {
|
||||||
|
// Do not allow duplicate categories.
|
||||||
|
if (categories.any { it.name.equals(name, true) }) {
|
||||||
|
context.toast(R.string.error_category_exists)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
category.name = name
|
category.name = name
|
||||||
db.insertCategory(category).asRxObservable().subscribe()
|
db.insertCategory(category).asRxObservable().subscribe()
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import android.view.*
|
|||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||||
import eu.kanade.tachiyomi.util.plusAssign
|
import eu.kanade.tachiyomi.util.plusAssign
|
||||||
@ -30,21 +31,6 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
|||||||
*/
|
*/
|
||||||
private lateinit var adapter: DownloadAdapter
|
private lateinit var adapter: DownloadAdapter
|
||||||
|
|
||||||
/**
|
|
||||||
* Menu item to start the queue.
|
|
||||||
*/
|
|
||||||
private var startButton: MenuItem? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menu item to pause the queue.
|
|
||||||
*/
|
|
||||||
private var pauseButton: MenuItem? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menu item to clear the queue.
|
|
||||||
*/
|
|
||||||
private var clearButton: MenuItem? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscription list to be cleared during [onDestroyView].
|
* Subscription list to be cleared during [onDestroyView].
|
||||||
*/
|
*/
|
||||||
@ -95,15 +81,15 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
|||||||
recycler.setHasFixedSize(true)
|
recycler.setHasFixedSize(true)
|
||||||
|
|
||||||
// Suscribe to changes
|
// Suscribe to changes
|
||||||
subscriptions += presenter.downloadManager.runningSubject
|
subscriptions += DownloadService.runningRelay
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe { onQueueStatusChange(it) }
|
.subscribe { onQueueStatusChange(it) }
|
||||||
|
|
||||||
subscriptions += presenter.getStatusObservable()
|
subscriptions += presenter.getDownloadStatusObservable()
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe { onStatusChange(it) }
|
.subscribe { onStatusChange(it) }
|
||||||
|
|
||||||
subscriptions += presenter.getProgressObservable()
|
subscriptions += presenter.getDownloadProgressObservable()
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe { onUpdateDownloadedPages(it) }
|
.subscribe { onUpdateDownloadedPages(it) }
|
||||||
}
|
}
|
||||||
@ -119,23 +105,17 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
|||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
inflater.inflate(R.menu.download_queue, menu)
|
inflater.inflate(R.menu.download_queue, menu)
|
||||||
|
|
||||||
// Set start button visibility.
|
|
||||||
startButton = menu.findItem(R.id.start_queue).apply {
|
|
||||||
isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||||
|
// Set start button visibility.
|
||||||
|
menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
|
||||||
|
|
||||||
// Set pause button visibility.
|
// Set pause button visibility.
|
||||||
pauseButton = menu.findItem(R.id.pause_queue).apply {
|
menu.findItem(R.id.pause_queue).isVisible = isRunning
|
||||||
isVisible = isRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set clear button visibility.
|
// Set clear button visibility.
|
||||||
clearButton = menu.findItem(R.id.clear_queue).apply {
|
menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
|
||||||
if (!presenter.downloadQueue.isEmpty()) {
|
|
||||||
isVisible = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
@ -182,7 +162,7 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
|||||||
// Get the sum of percentages for all the pages.
|
// Get the sum of percentages for all the pages.
|
||||||
.flatMap {
|
.flatMap {
|
||||||
Observable.from(download.pages)
|
Observable.from(download.pages)
|
||||||
.map { it.progress }
|
.map(Page::progress)
|
||||||
.reduce { x, y -> x + y }
|
.reduce { x, y -> x + y }
|
||||||
}
|
}
|
||||||
// Keep only the latest emission to avoid backpressure.
|
// Keep only the latest emission to avoid backpressure.
|
||||||
@ -218,9 +198,7 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
|||||||
*/
|
*/
|
||||||
private fun onQueueStatusChange(running: Boolean) {
|
private fun onQueueStatusChange(running: Boolean) {
|
||||||
isRunning = running
|
isRunning = running
|
||||||
startButton?.isVisible = !running && !presenter.downloadQueue.isEmpty()
|
activity.supportInvalidateOptionsMenu()
|
||||||
pauseButton?.isVisible = running
|
|
||||||
clearButton?.isVisible = !presenter.downloadQueue.isEmpty()
|
|
||||||
|
|
||||||
// Check if download queue is empty and update information accordingly.
|
// Check if download queue is empty and update information accordingly.
|
||||||
setInformationView()
|
setInformationView()
|
||||||
@ -232,13 +210,11 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
|
|||||||
* @param downloads the downloads from the queue.
|
* @param downloads the downloads from the queue.
|
||||||
*/
|
*/
|
||||||
fun onNextDownloads(downloads: List<Download>) {
|
fun onNextDownloads(downloads: List<Download>) {
|
||||||
|
activity.supportInvalidateOptionsMenu()
|
||||||
|
setInformationView()
|
||||||
adapter.setItems(downloads)
|
adapter.setItems(downloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onDownloadRemoved(position: Int) {
|
|
||||||
adapter.notifyItemRemoved(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the progress of a download changes.
|
* Called when the progress of a download changes.
|
||||||
*
|
*
|
||||||
|
@ -30,35 +30,20 @@ class DownloadPresenter : BasePresenter<DownloadFragment>() {
|
|||||||
override fun onCreate(savedState: Bundle?) {
|
override fun onCreate(savedState: Bundle?) {
|
||||||
super.onCreate(savedState)
|
super.onCreate(savedState)
|
||||||
|
|
||||||
Observable.just(ArrayList(downloadQueue))
|
downloadQueue.getUpdatedObservable()
|
||||||
.doOnNext { syncQueue(it) }
|
|
||||||
.subscribeLatestCache({ view, downloads ->
|
|
||||||
view.onNextDownloads(downloads)
|
|
||||||
}, { view, error ->
|
|
||||||
Timber.e(error, error.message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun syncQueue(queue: MutableList<Download>) {
|
|
||||||
add(downloadQueue.getRemovedObservable()
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe { download ->
|
.map { ArrayList(it) }
|
||||||
val position = queue.indexOf(download)
|
.subscribeLatestCache(DownloadFragment::onNextDownloads, { view, error ->
|
||||||
if (position != -1) {
|
Timber.e(error)
|
||||||
queue.removeAt(position)
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
view?.onDownloadRemoved(position)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStatusObservable(): Observable<Download> {
|
fun getDownloadStatusObservable(): Observable<Download> {
|
||||||
return downloadQueue.getStatusObservable()
|
return downloadQueue.getStatusObservable()
|
||||||
.startWith(downloadQueue.getActiveDownloads())
|
.startWith(downloadQueue.getActiveDownloads())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getProgressObservable(): Observable<Download> {
|
fun getDownloadProgressObservable(): Observable<Download> {
|
||||||
return downloadQueue.getProgressObservable()
|
return downloadQueue.getProgressObservable()
|
||||||
.onBackpressureBuffer()
|
.onBackpressureBuffer()
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.latest_updates
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment
|
||||||
|
import nucleus.factory.RequiresPresenter
|
||||||
|
import android.view.*
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment that shows the manga from the catalogue. Inherit CatalogueFragment.
|
||||||
|
*/
|
||||||
|
@RequiresPresenter(LatestUpdatesPresenter::class)
|
||||||
|
class LatestUpdatesFragment : CatalogueFragment() {
|
||||||
|
|
||||||
|
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||||
|
super.onPrepareOptionsMenu(menu)
|
||||||
|
menu.findItem(R.id.action_search).isVisible = false
|
||||||
|
menu.findItem(R.id.action_set_filter).isVisible = false
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun newInstance(): LatestUpdatesFragment {
|
||||||
|
return LatestUpdatesFragment()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.latest_updates
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
|
import eu.kanade.tachiyomi.ui.catalogue.Pager
|
||||||
|
import rx.Observable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LatestUpdatesPager inherited from the general Pager.
|
||||||
|
*/
|
||||||
|
class LatestUpdatesPager(val source: OnlineSource): Pager() {
|
||||||
|
|
||||||
|
override fun requestNext(transformer: (Observable<MangasPage>) -> Observable<MangasPage>): Observable<MangasPage> {
|
||||||
|
val lastPage = lastPage
|
||||||
|
|
||||||
|
val page = if (lastPage == null)
|
||||||
|
MangasPage(1)
|
||||||
|
else
|
||||||
|
MangasPage(lastPage.page + 1).apply { url = lastPage.nextPageUrl!! }
|
||||||
|
|
||||||
|
val observable = source.fetchLatestUpdates(page)
|
||||||
|
|
||||||
|
return transformer(observable)
|
||||||
|
.doOnNext { results.onNext(it) }
|
||||||
|
.doOnNext { this@LatestUpdatesPager.lastPage = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.latest_updates
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.source.Source
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource
|
||||||
|
import eu.kanade.tachiyomi.ui.catalogue.CataloguePresenter
|
||||||
|
import eu.kanade.tachiyomi.ui.catalogue.Pager
|
||||||
|
import eu.kanade.tachiyomi.data.source.online.OnlineSource.Filter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presenter of [LatestUpdatesFragment]. Inherit CataloguePresenter.
|
||||||
|
*/
|
||||||
|
class LatestUpdatesPresenter : CataloguePresenter() {
|
||||||
|
|
||||||
|
override fun createPager(query: String, filters: List<Filter>): Pager {
|
||||||
|
return LatestUpdatesPager(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getEnabledSources(): List<OnlineSource> {
|
||||||
|
return super.getEnabledSources().filter { it.supportsLatest }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isValidSource(source: Source?): Boolean {
|
||||||
|
return super.isValidSource(source) && (source as OnlineSource).supportsLatest
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,23 +1,23 @@
|
|||||||
package eu.kanade.tachiyomi.ui.library
|
package eu.kanade.tachiyomi.ui.library
|
||||||
|
|
||||||
import android.support.v4.app.Fragment
|
import android.view.View
|
||||||
import android.support.v4.app.FragmentManager
|
import android.view.ViewGroup
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Category
|
import eu.kanade.tachiyomi.data.database.models.Category
|
||||||
import eu.kanade.tachiyomi.ui.base.adapter.SmartFragmentStatePagerAdapter
|
import eu.kanade.tachiyomi.util.inflate
|
||||||
|
import eu.kanade.tachiyomi.widget.RecyclerViewPagerAdapter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This adapter stores the categories from the library, used with a ViewPager.
|
* This adapter stores the categories from the library, used with a ViewPager.
|
||||||
*
|
*
|
||||||
* @param fm the fragment manager.
|
|
||||||
* @constructor creates an instance of the adapter.
|
* @constructor creates an instance of the adapter.
|
||||||
*/
|
*/
|
||||||
class LibraryAdapter(fm: FragmentManager) : SmartFragmentStatePagerAdapter(fm) {
|
class LibraryAdapter(private val fragment: LibraryFragment) : RecyclerViewPagerAdapter() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The categories to bind in the adapter.
|
* The categories to bind in the adapter.
|
||||||
*/
|
*/
|
||||||
var categories: List<Category>? = null
|
var categories: List<Category> = emptyList()
|
||||||
// This setter helps to not refresh the adapter if the reference to the list doesn't change.
|
// This setter helps to not refresh the adapter if the reference to the list doesn't change.
|
||||||
set(value) {
|
set(value) {
|
||||||
if (field !== value) {
|
if (field !== value) {
|
||||||
@ -27,13 +27,34 @@ class LibraryAdapter(fm: FragmentManager) : SmartFragmentStatePagerAdapter(fm) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new fragment for the given position when it's called.
|
* Creates a new view for this adapter.
|
||||||
*
|
*
|
||||||
* @param position the position to instantiate.
|
* @return a new view.
|
||||||
* @return a fragment for the given position.
|
|
||||||
*/
|
*/
|
||||||
override fun getItem(position: Int): Fragment {
|
override fun createView(container: ViewGroup): View {
|
||||||
return LibraryCategoryFragment.newInstance(position)
|
val view = container.inflate(R.layout.item_library_category) as LibraryCategoryView
|
||||||
|
view.onCreate(fragment)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds a view with a position.
|
||||||
|
*
|
||||||
|
* @param view the view to bind.
|
||||||
|
* @param position the position in the adapter.
|
||||||
|
*/
|
||||||
|
override fun bindView(view: View, position: Int) {
|
||||||
|
(view as LibraryCategoryView).onBind(categories[position])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recycles a view.
|
||||||
|
*
|
||||||
|
* @param view the view to recycle.
|
||||||
|
* @param position the position in the adapter.
|
||||||
|
*/
|
||||||
|
override fun recycleView(view: View, position: Int) {
|
||||||
|
(view as LibraryCategoryView).onRecycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +63,7 @@ class LibraryAdapter(fm: FragmentManager) : SmartFragmentStatePagerAdapter(fm) {
|
|||||||
* @return the number of categories or 0 if the list is null.
|
* @return the number of categories or 0 if the list is null.
|
||||||
*/
|
*/
|
||||||
override fun getCount(): Int {
|
override fun getCount(): Int {
|
||||||
return categories?.size ?: 0
|
return categories.size
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -52,28 +73,16 @@ class LibraryAdapter(fm: FragmentManager) : SmartFragmentStatePagerAdapter(fm) {
|
|||||||
* @return the title to display.
|
* @return the title to display.
|
||||||
*/
|
*/
|
||||||
override fun getPageTitle(position: Int): CharSequence {
|
override fun getPageTitle(position: Int): CharSequence {
|
||||||
return categories!![position].name
|
return categories[position].name
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to enable or disable the action mode (multiple selection) for all the instantiated
|
* Returns the position of the view.
|
||||||
* fragments.
|
|
||||||
*
|
|
||||||
* @param mode the mode to set.
|
|
||||||
*/
|
*/
|
||||||
fun setSelectionMode(mode: Int) {
|
override fun getItemPosition(obj: Any?): Int {
|
||||||
for (fragment in getRegisteredFragments()) {
|
val view = obj as? LibraryCategoryView ?: return POSITION_NONE
|
||||||
(fragment as LibraryCategoryFragment).setSelectionMode(mode)
|
val index = categories.indexOfFirst { it.id == view.category.id }
|
||||||
}
|
return if (index == -1) POSITION_NONE else index
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifies the adapters in all the registered fragments to refresh their content.
|
|
||||||
*/
|
|
||||||
fun refreshRegisteredAdapters() {
|
|
||||||
for (fragment in getRegisteredFragments()) {
|
|
||||||
(fragment as LibraryCategoryFragment).adapter.notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -8,7 +8,7 @@ import eu.davidea.flexibleadapter.FlexibleAdapter
|
|||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.util.inflate
|
import eu.kanade.tachiyomi.util.inflate
|
||||||
import kotlinx.android.synthetic.main.fragment_library_category.*
|
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
|
||||||
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
|
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@ -17,7 +17,7 @@ import java.util.*
|
|||||||
*
|
*
|
||||||
* @param fragment the fragment containing this adapter.
|
* @param fragment the fragment containing this adapter.
|
||||||
*/
|
*/
|
||||||
class LibraryCategoryAdapter(val fragment: LibraryCategoryFragment) :
|
class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
|
||||||
FlexibleAdapter<LibraryHolder, Manga>() {
|
FlexibleAdapter<LibraryHolder, Manga>() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -84,11 +84,18 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryFragment) :
|
|||||||
* @return a new view holder for a manga.
|
* @return a new view holder for a manga.
|
||||||
*/
|
*/
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LibraryHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LibraryHolder {
|
||||||
|
// Depending on preferences, display a list or display a grid
|
||||||
|
if (parent is AutofitRecyclerView) {
|
||||||
val view = parent.inflate(R.layout.item_catalogue_grid).apply {
|
val view = parent.inflate(R.layout.item_catalogue_grid).apply {
|
||||||
|
val coverHeight = parent.itemWidth / 3 * 4
|
||||||
card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight)
|
card.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight)
|
||||||
gradient.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM)
|
gradient.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, coverHeight / 2, Gravity.BOTTOM)
|
||||||
}
|
}
|
||||||
return LibraryHolder(view, this, fragment)
|
return LibraryGridHolder(view, this, fragment)
|
||||||
|
} else {
|
||||||
|
val view = parent.inflate(R.layout.item_library_list)
|
||||||
|
return LibraryListHolder(view, this, fragment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -101,14 +108,17 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryFragment) :
|
|||||||
val manga = getItem(position)
|
val manga = getItem(position)
|
||||||
|
|
||||||
holder.onSetValues(manga)
|
holder.onSetValues(manga)
|
||||||
//When user scrolls this bind the correct selection status
|
// When user scrolls this bind the correct selection status
|
||||||
holder.itemView.isActivated = isSelected(position)
|
holder.itemView.isActivated = isSelected(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Property to return the height for the covers based on the width to keep an aspect ratio.
|
* Returns the position in the adapter for the given manga.
|
||||||
|
*
|
||||||
|
* @param manga the manga to find.
|
||||||
*/
|
*/
|
||||||
val coverHeight: Int
|
fun indexOf(manga: Manga): Int {
|
||||||
get() = fragment.recycler.itemWidth / 3 * 4
|
return mangas.orEmpty().indexOfFirst { it.id == manga.id }
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,277 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.ui.library
|
|
||||||
|
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.support.v7.widget.LinearLayoutManager
|
|
||||||
import android.support.v7.widget.RecyclerView
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import com.f2prateek.rx.preferences.Preference
|
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
|
||||||
import eu.kanade.tachiyomi.R
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
|
||||||
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
|
||||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseFragment
|
|
||||||
import eu.kanade.tachiyomi.ui.manga.MangaActivity
|
|
||||||
import eu.kanade.tachiyomi.util.toast
|
|
||||||
import kotlinx.android.synthetic.main.fragment_library_category.*
|
|
||||||
import rx.Subscription
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fragment containing the library manga for a certain category.
|
|
||||||
* Uses R.layout.fragment_library_category.
|
|
||||||
*/
|
|
||||||
class LibraryCategoryFragment : BaseFragment(), FlexibleViewHolder.OnListItemClickListener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapter to hold the manga in this category.
|
|
||||||
*/
|
|
||||||
lateinit var adapter: LibraryCategoryAdapter
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Position in the adapter from [LibraryAdapter].
|
|
||||||
*/
|
|
||||||
private var position: Int = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription for the library manga.
|
|
||||||
*/
|
|
||||||
private var libraryMangaSubscription: Subscription? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription of the number of manga per row.
|
|
||||||
*/
|
|
||||||
private var numColumnsSubscription: Subscription? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription of the library search.
|
|
||||||
*/
|
|
||||||
private var searchSubscription: Subscription? = null
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Key to save and restore [position] from a [Bundle].
|
|
||||||
*/
|
|
||||||
const val POSITION_KEY = "position_key"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new instance of this class.
|
|
||||||
*
|
|
||||||
* @param position the position in the adapter from [LibraryAdapter].
|
|
||||||
* @return a new instance of [LibraryCategoryFragment].
|
|
||||||
*/
|
|
||||||
fun newInstance(position: Int): LibraryCategoryFragment {
|
|
||||||
val fragment = LibraryCategoryFragment()
|
|
||||||
fragment.position = position
|
|
||||||
return fragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
|
||||||
return inflater.inflate(R.layout.fragment_library_category, container, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
|
||||||
adapter = LibraryCategoryAdapter(this)
|
|
||||||
recycler.setHasFixedSize(true)
|
|
||||||
recycler.adapter = adapter
|
|
||||||
|
|
||||||
if (libraryFragment.actionMode != null) {
|
|
||||||
setSelectionMode(FlexibleAdapter.MODE_MULTI)
|
|
||||||
}
|
|
||||||
|
|
||||||
numColumnsSubscription = getColumnsPreferenceForCurrentOrientation().asObservable()
|
|
||||||
.doOnNext { recycler.spanCount = it }
|
|
||||||
.skip(1)
|
|
||||||
// Set again the adapter to recalculate the covers height
|
|
||||||
.subscribe { recycler.adapter = adapter }
|
|
||||||
|
|
||||||
searchSubscription = libraryPresenter.searchSubject.subscribe { text ->
|
|
||||||
adapter.searchText = text
|
|
||||||
adapter.updateDataSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedState != null) {
|
|
||||||
position = savedState.getInt(POSITION_KEY)
|
|
||||||
adapter.onRestoreInstanceState(savedState)
|
|
||||||
|
|
||||||
if (adapter.mode == FlexibleAdapter.MODE_SINGLE) {
|
|
||||||
adapter.clearSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
|
||||||
override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) {
|
|
||||||
// Disable swipe refresh when view is not at the top
|
|
||||||
val firstPos = (recycler.layoutManager as LinearLayoutManager)
|
|
||||||
.findFirstCompletelyVisibleItemPosition()
|
|
||||||
swipe_refresh.isEnabled = firstPos == 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Double the distance required to trigger sync
|
|
||||||
swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
|
|
||||||
swipe_refresh.setOnRefreshListener {
|
|
||||||
if (!LibraryUpdateService.isRunning(activity)) {
|
|
||||||
libraryPresenter.categories.getOrNull(position)?.let {
|
|
||||||
LibraryUpdateService.start(activity, true, it)
|
|
||||||
context.toast(R.string.updating_category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// It can be a very long operation, so we disable swipe refresh and show a toast.
|
|
||||||
swipe_refresh.isRefreshing = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
numColumnsSubscription?.unsubscribe()
|
|
||||||
searchSubscription?.unsubscribe()
|
|
||||||
super.onDestroyView()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
libraryMangaSubscription = libraryPresenter.libraryMangaSubject
|
|
||||||
.subscribe { onNextLibraryManga(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
libraryMangaSubscription?.unsubscribe()
|
|
||||||
super.onPause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
outState.putInt(POSITION_KEY, position)
|
|
||||||
adapter.onSaveInstanceState(outState)
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the
|
|
||||||
* adapter.
|
|
||||||
*
|
|
||||||
* @param event the event received.
|
|
||||||
*/
|
|
||||||
fun onNextLibraryManga(event: LibraryMangaEvent) {
|
|
||||||
// Get the categories from the parent fragment.
|
|
||||||
val categories = libraryFragment.adapter.categories ?: return
|
|
||||||
|
|
||||||
// When a category is deleted, the index can be greater than the number of categories.
|
|
||||||
if (position >= categories.size) return
|
|
||||||
|
|
||||||
// Get the manga list for this category.
|
|
||||||
val mangaForCategory = event.getMangaForCategory(categories[position]) ?: emptyList()
|
|
||||||
|
|
||||||
// Update the category with its manga.
|
|
||||||
adapter.setItems(mangaForCategory)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a manga is clicked.
|
|
||||||
*
|
|
||||||
* @param position the position of the element clicked.
|
|
||||||
* @return true if the item should be selected, false otherwise.
|
|
||||||
*/
|
|
||||||
override fun onListItemClick(position: Int): Boolean {
|
|
||||||
// If the action mode is created and the position is valid, toggle the selection.
|
|
||||||
val item = adapter.getItem(position) ?: return false
|
|
||||||
if (libraryFragment.actionMode != null) {
|
|
||||||
toggleSelection(position)
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
openManga(item)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a manga is long clicked.
|
|
||||||
*
|
|
||||||
* @param position the position of the element clicked.
|
|
||||||
*/
|
|
||||||
override fun onListItemLongClick(position: Int) {
|
|
||||||
libraryFragment.createActionModeIfNeeded()
|
|
||||||
toggleSelection(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens a manga.
|
|
||||||
*
|
|
||||||
* @param manga the manga to open.
|
|
||||||
*/
|
|
||||||
protected fun openManga(manga: Manga) {
|
|
||||||
// Notify the presenter a manga is being opened.
|
|
||||||
libraryPresenter.onOpenManga()
|
|
||||||
|
|
||||||
// Create a new activity with the manga.
|
|
||||||
val intent = MangaActivity.newIntent(activity, manga)
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggles the selection for a manga.
|
|
||||||
*
|
|
||||||
* @param position the position to toggle.
|
|
||||||
*/
|
|
||||||
private fun toggleSelection(position: Int) {
|
|
||||||
val library = libraryFragment
|
|
||||||
|
|
||||||
// Toggle the selection.
|
|
||||||
adapter.toggleSelection(position, false)
|
|
||||||
|
|
||||||
// Notify the selection to the presenter.
|
|
||||||
library.presenter.setSelection(adapter.getItem(position), adapter.isSelected(position))
|
|
||||||
|
|
||||||
// Get the selected count.
|
|
||||||
val count = library.presenter.selectedMangas.size
|
|
||||||
if (count == 0) {
|
|
||||||
// Destroy action mode if there are no items selected.
|
|
||||||
library.destroyActionModeIfNeeded()
|
|
||||||
} else {
|
|
||||||
// Update action mode with the new selection.
|
|
||||||
library.setContextTitle(count)
|
|
||||||
library.setVisibilityOfCoverEdit(count)
|
|
||||||
library.invalidateActionMode()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a preference for the number of manga per row based on the current orientation.
|
|
||||||
*
|
|
||||||
* @return the preference.
|
|
||||||
*/
|
|
||||||
fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
|
|
||||||
return if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
|
||||||
libraryPresenter.preferences.portraitColumns()
|
|
||||||
else
|
|
||||||
libraryPresenter.preferences.landscapeColumns()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the mode for the adapter.
|
|
||||||
*
|
|
||||||
* @param mode the mode to set. It should be MODE_SINGLE or MODE_MULTI.
|
|
||||||
*/
|
|
||||||
fun setSelectionMode(mode: Int) {
|
|
||||||
adapter.mode = mode
|
|
||||||
if (mode == FlexibleAdapter.MODE_SINGLE) {
|
|
||||||
adapter.clearSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property to get the library fragment.
|
|
||||||
*/
|
|
||||||
private val libraryFragment: LibraryFragment
|
|
||||||
get() = parentFragment as LibraryFragment
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Property to get the library presenter.
|
|
||||||
*/
|
|
||||||
private val libraryPresenter: LibraryPresenter
|
|
||||||
get() = libraryFragment.presenter
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,266 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.library
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.support.v7.widget.LinearLayoutManager
|
||||||
|
import android.support.v7.widget.RecyclerView
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Category
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||||
|
import eu.kanade.tachiyomi.ui.manga.MangaActivity
|
||||||
|
import eu.kanade.tachiyomi.util.inflate
|
||||||
|
import eu.kanade.tachiyomi.util.toast
|
||||||
|
import eu.kanade.tachiyomi.widget.AutofitRecyclerView
|
||||||
|
import kotlinx.android.synthetic.main.item_library_category.view.*
|
||||||
|
import rx.Subscription
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment containing the library manga for a certain category.
|
||||||
|
* Uses R.layout.fragment_library_category.
|
||||||
|
*/
|
||||||
|
class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
|
||||||
|
: FrameLayout(context, attrs), FlexibleViewHolder.OnListItemClickListener {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences.
|
||||||
|
*/
|
||||||
|
private val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fragment containing this view.
|
||||||
|
*/
|
||||||
|
private lateinit var fragment: LibraryFragment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category for this view.
|
||||||
|
*/
|
||||||
|
lateinit var category: Category
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recycler view of the list of manga.
|
||||||
|
*/
|
||||||
|
private lateinit var recycler: RecyclerView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter to hold the manga in this category.
|
||||||
|
*/
|
||||||
|
private lateinit var adapter: LibraryCategoryAdapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription for the library manga.
|
||||||
|
*/
|
||||||
|
private var libraryMangaSubscription: Subscription? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription of the library search.
|
||||||
|
*/
|
||||||
|
private var searchSubscription: Subscription? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription of the library selections.
|
||||||
|
*/
|
||||||
|
private var selectionSubscription: Subscription? = null
|
||||||
|
|
||||||
|
fun onCreate(fragment: LibraryFragment) {
|
||||||
|
this.fragment = fragment
|
||||||
|
|
||||||
|
recycler = if (preferences.libraryAsList().getOrDefault()) {
|
||||||
|
(swipe_refresh.inflate(R.layout.library_list_recycler) as RecyclerView).apply {
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(swipe_refresh.inflate(R.layout.library_grid_recycler) as AutofitRecyclerView).apply {
|
||||||
|
spanCount = fragment.mangaPerRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter = LibraryCategoryAdapter(this)
|
||||||
|
|
||||||
|
recycler.setHasFixedSize(true)
|
||||||
|
recycler.adapter = adapter
|
||||||
|
swipe_refresh.addView(recycler)
|
||||||
|
|
||||||
|
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
|
override fun onScrollStateChanged(recycler: RecyclerView, newState: Int) {
|
||||||
|
// Disable swipe refresh when view is not at the top
|
||||||
|
val firstPos = (recycler.layoutManager as LinearLayoutManager)
|
||||||
|
.findFirstCompletelyVisibleItemPosition()
|
||||||
|
swipe_refresh.isEnabled = firstPos == 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Double the distance required to trigger sync
|
||||||
|
swipe_refresh.setDistanceToTriggerSync((2 * 64 * resources.displayMetrics.density).toInt())
|
||||||
|
swipe_refresh.setOnRefreshListener {
|
||||||
|
if (!LibraryUpdateService.isRunning(context)) {
|
||||||
|
LibraryUpdateService.start(context, category)
|
||||||
|
context.toast(R.string.updating_category)
|
||||||
|
}
|
||||||
|
// It can be a very long operation, so we disable swipe refresh and show a toast.
|
||||||
|
swipe_refresh.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBind(category: Category) {
|
||||||
|
this.category = category
|
||||||
|
|
||||||
|
val presenter = fragment.presenter
|
||||||
|
|
||||||
|
searchSubscription = presenter.searchSubject.subscribe { text ->
|
||||||
|
adapter.searchText = text
|
||||||
|
adapter.updateDataSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter.mode = if (presenter.selectedMangas.isNotEmpty()) {
|
||||||
|
FlexibleAdapter.MODE_MULTI
|
||||||
|
} else {
|
||||||
|
FlexibleAdapter.MODE_SINGLE
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryMangaSubscription = presenter.libraryMangaSubject
|
||||||
|
.subscribe { onNextLibraryManga(it) }
|
||||||
|
|
||||||
|
selectionSubscription = presenter.selectionSubject
|
||||||
|
.subscribe { onSelectionChanged(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRecycle() {
|
||||||
|
adapter.setItems(emptyList())
|
||||||
|
adapter.clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
searchSubscription?.unsubscribe()
|
||||||
|
libraryMangaSubscription?.unsubscribe()
|
||||||
|
selectionSubscription?.unsubscribe()
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to [LibraryMangaEvent]. When an event is received, it updates the content of the
|
||||||
|
* adapter.
|
||||||
|
*
|
||||||
|
* @param event the event received.
|
||||||
|
*/
|
||||||
|
fun onNextLibraryManga(event: LibraryMangaEvent) {
|
||||||
|
// Get the manga list for this category.
|
||||||
|
val mangaForCategory = event.getMangaForCategory(category).orEmpty()
|
||||||
|
|
||||||
|
// Update the category with its manga.
|
||||||
|
adapter.setItems(mangaForCategory)
|
||||||
|
|
||||||
|
if (adapter.mode == FlexibleAdapter.MODE_MULTI) {
|
||||||
|
fragment.presenter.selectedMangas.forEach { manga ->
|
||||||
|
val position = adapter.indexOf(manga)
|
||||||
|
if (position != -1 && !adapter.isSelected(position)) {
|
||||||
|
adapter.toggleSelection(position)
|
||||||
|
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to [LibrarySelectionEvent]. When an event is received, it updates the selection
|
||||||
|
* depending on the type of event received.
|
||||||
|
*
|
||||||
|
* @param event the selection event received.
|
||||||
|
*/
|
||||||
|
private fun onSelectionChanged(event: LibrarySelectionEvent) {
|
||||||
|
when (event) {
|
||||||
|
is LibrarySelectionEvent.Selected -> {
|
||||||
|
if (adapter.mode != FlexibleAdapter.MODE_MULTI) {
|
||||||
|
adapter.mode = FlexibleAdapter.MODE_MULTI
|
||||||
|
}
|
||||||
|
findAndToggleSelection(event.manga)
|
||||||
|
}
|
||||||
|
is LibrarySelectionEvent.Unselected -> {
|
||||||
|
findAndToggleSelection(event.manga)
|
||||||
|
if (fragment.presenter.selectedMangas.isEmpty()) {
|
||||||
|
adapter.mode = FlexibleAdapter.MODE_SINGLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LibrarySelectionEvent.Cleared -> {
|
||||||
|
adapter.mode = FlexibleAdapter.MODE_SINGLE
|
||||||
|
adapter.clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the selection for the given manga and updates the view if needed.
|
||||||
|
*
|
||||||
|
* @param manga the manga to toggle.
|
||||||
|
*/
|
||||||
|
private fun findAndToggleSelection(manga: Manga) {
|
||||||
|
val position = adapter.indexOf(manga)
|
||||||
|
if (position != -1) {
|
||||||
|
adapter.toggleSelection(position)
|
||||||
|
(recycler.findViewHolderForItemId(manga.id!!) as? LibraryHolder)?.toggleActivation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a manga is clicked.
|
||||||
|
*
|
||||||
|
* @param position the position of the element clicked.
|
||||||
|
* @return true if the item should be selected, false otherwise.
|
||||||
|
*/
|
||||||
|
override fun onListItemClick(position: Int): Boolean {
|
||||||
|
// If the action mode is created and the position is valid, toggle the selection.
|
||||||
|
val item = adapter.getItem(position) ?: return false
|
||||||
|
if (adapter.mode == FlexibleAdapter.MODE_MULTI) {
|
||||||
|
toggleSelection(position)
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
openManga(item)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a manga is long clicked.
|
||||||
|
*
|
||||||
|
* @param position the position of the element clicked.
|
||||||
|
*/
|
||||||
|
override fun onListItemLongClick(position: Int) {
|
||||||
|
fragment.createActionModeIfNeeded()
|
||||||
|
toggleSelection(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a manga.
|
||||||
|
*
|
||||||
|
* @param manga the manga to open.
|
||||||
|
*/
|
||||||
|
private fun openManga(manga: Manga) {
|
||||||
|
// Notify the presenter a manga is being opened.
|
||||||
|
fragment.presenter.onOpenManga()
|
||||||
|
|
||||||
|
// Create a new activity with the manga.
|
||||||
|
val intent = MangaActivity.newIntent(context, manga)
|
||||||
|
fragment.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the presenter to toggle the selection for the given position.
|
||||||
|
*
|
||||||
|
* @param position the position to toggle.
|
||||||
|
*/
|
||||||
|
private fun toggleSelection(position: Int) {
|
||||||
|
val manga = adapter.getItem(position) ?: return
|
||||||
|
|
||||||
|
fragment.presenter.setSelection(manga, !adapter.isSelected(position))
|
||||||
|
fragment.invalidateActionMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.library
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.design.widget.TabLayout
|
import android.support.design.widget.TabLayout
|
||||||
import android.support.v4.view.ViewPager
|
import android.support.v4.view.ViewPager
|
||||||
@ -9,11 +10,12 @@ import android.support.v7.view.ActionMode
|
|||||||
import android.support.v7.widget.SearchView
|
import android.support.v7.widget.SearchView
|
||||||
import android.view.*
|
import android.view.*
|
||||||
import com.afollestad.materialdialogs.MaterialDialog
|
import com.afollestad.materialdialogs.MaterialDialog
|
||||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
import com.f2prateek.rx.preferences.Preference
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Category
|
import eu.kanade.tachiyomi.data.database.models.Category
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||||
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||||
import eu.kanade.tachiyomi.ui.category.CategoryActivity
|
import eu.kanade.tachiyomi.ui.category.CategoryActivity
|
||||||
@ -22,6 +24,9 @@ import eu.kanade.tachiyomi.util.toast
|
|||||||
import kotlinx.android.synthetic.main.activity_main.*
|
import kotlinx.android.synthetic.main.activity_main.*
|
||||||
import kotlinx.android.synthetic.main.fragment_library.*
|
import kotlinx.android.synthetic.main.fragment_library.*
|
||||||
import nucleus.factory.RequiresPresenter
|
import nucleus.factory.RequiresPresenter
|
||||||
|
import rx.Subscription
|
||||||
|
import timber.log.Timber
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,6 +42,11 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
lateinit var adapter: LibraryAdapter
|
lateinit var adapter: LibraryAdapter
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences.
|
||||||
|
*/
|
||||||
|
val preferences: PreferencesHelper by injectLazy()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TabLayout of the categories.
|
* TabLayout of the categories.
|
||||||
*/
|
*/
|
||||||
@ -56,8 +66,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
/**
|
/**
|
||||||
* Action mode for manga selection.
|
* Action mode for manga selection.
|
||||||
*/
|
*/
|
||||||
var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selected manga for editing its cover.
|
* Selected manga for editing its cover.
|
||||||
@ -74,6 +83,17 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
*/
|
*/
|
||||||
var isFilterUnread = false
|
var isFilterUnread = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of manga per row in grid mode.
|
||||||
|
*/
|
||||||
|
var mangaPerRow = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription for the number of manga per row.
|
||||||
|
*/
|
||||||
|
private var numColumnsSubscription: Subscription? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* Key to change the cover of a manga in [onActivityResult].
|
* Key to change the cover of a manga in [onActivityResult].
|
||||||
@ -103,8 +123,8 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
override fun onCreate(savedState: Bundle?) {
|
override fun onCreate(savedState: Bundle?) {
|
||||||
super.onCreate(savedState)
|
super.onCreate(savedState)
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
isFilterDownloaded = presenter.preferences.filterDownloaded().get() as Boolean
|
isFilterDownloaded = preferences.filterDownloaded().get() as Boolean
|
||||||
isFilterUnread = presenter.preferences.filterUnread().get() as Boolean
|
isFilterUnread = preferences.filterUnread().get() as Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||||
@ -114,11 +134,11 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||||
setToolbarTitle(getString(R.string.label_library))
|
setToolbarTitle(getString(R.string.label_library))
|
||||||
|
|
||||||
adapter = LibraryAdapter(childFragmentManager)
|
adapter = LibraryAdapter(this)
|
||||||
view_pager.adapter = adapter
|
view_pager.adapter = adapter
|
||||||
view_pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
|
view_pager.addOnPageChangeListener(object : ViewPager.SimpleOnPageChangeListener() {
|
||||||
override fun onPageSelected(position: Int) {
|
override fun onPageSelected(position: Int) {
|
||||||
presenter.preferences.lastUsedCategory().set(position)
|
preferences.lastUsedCategory().set(position)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
tabs.setupWithViewPager(view_pager)
|
tabs.setupWithViewPager(view_pager)
|
||||||
@ -127,9 +147,18 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
activeCategory = savedState.getInt(CATEGORY_KEY)
|
activeCategory = savedState.getInt(CATEGORY_KEY)
|
||||||
query = savedState.getString(QUERY_KEY)
|
query = savedState.getString(QUERY_KEY)
|
||||||
presenter.searchSubject.onNext(query)
|
presenter.searchSubject.onNext(query)
|
||||||
} else {
|
if (presenter.selectedMangas.isNotEmpty()) {
|
||||||
activeCategory = presenter.preferences.lastUsedCategory().getOrDefault()
|
createActionModeIfNeeded()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
activeCategory = preferences.lastUsedCategory().getOrDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
numColumnsSubscription = getColumnsPreferenceForCurrentOrientation().asObservable()
|
||||||
|
.doOnNext { mangaPerRow = it }
|
||||||
|
.skip(1)
|
||||||
|
// Set again the adapter to recalculate the covers height
|
||||||
|
.subscribe { reattachAdapter() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@ -138,6 +167,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
numColumnsSubscription?.unsubscribe()
|
||||||
tabs.setupWithViewPager(null)
|
tabs.setupWithViewPager(null)
|
||||||
tabs.visibility = View.GONE
|
tabs.visibility = View.GONE
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
@ -178,6 +208,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
@ -186,7 +217,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
// Change unread filter status.
|
// Change unread filter status.
|
||||||
isFilterUnread = !isFilterUnread
|
isFilterUnread = !isFilterUnread
|
||||||
// Update settings.
|
// Update settings.
|
||||||
presenter.preferences.filterUnread().set(isFilterUnread)
|
preferences.filterUnread().set(isFilterUnread)
|
||||||
// Apply filter.
|
// Apply filter.
|
||||||
onFilterCheckboxChanged()
|
onFilterCheckboxChanged()
|
||||||
}
|
}
|
||||||
@ -194,7 +225,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
// Change downloaded filter status.
|
// Change downloaded filter status.
|
||||||
isFilterDownloaded = !isFilterDownloaded
|
isFilterDownloaded = !isFilterDownloaded
|
||||||
// Update settings.
|
// Update settings.
|
||||||
presenter.preferences.filterDownloaded().set(isFilterDownloaded)
|
preferences.filterDownloaded().set(isFilterDownloaded)
|
||||||
// Apply filter.
|
// Apply filter.
|
||||||
onFilterCheckboxChanged()
|
onFilterCheckboxChanged()
|
||||||
}
|
}
|
||||||
@ -203,13 +234,14 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
isFilterUnread = false
|
isFilterUnread = false
|
||||||
isFilterDownloaded = false
|
isFilterDownloaded = false
|
||||||
// Update settings.
|
// Update settings.
|
||||||
presenter.preferences.filterUnread().set(isFilterUnread)
|
preferences.filterUnread().set(isFilterUnread)
|
||||||
presenter.preferences.filterDownloaded().set(isFilterDownloaded)
|
preferences.filterDownloaded().set(isFilterDownloaded)
|
||||||
// Apply filter
|
// Apply filter
|
||||||
onFilterCheckboxChanged()
|
onFilterCheckboxChanged()
|
||||||
}
|
}
|
||||||
|
R.id.action_library_display_mode -> swapDisplayMode()
|
||||||
R.id.action_update_library -> {
|
R.id.action_update_library -> {
|
||||||
LibraryUpdateService.start(activity, true)
|
LibraryUpdateService.start(activity)
|
||||||
}
|
}
|
||||||
R.id.action_edit_categories -> {
|
R.id.action_edit_categories -> {
|
||||||
val intent = CategoryActivity.newIntent(activity)
|
val intent = CategoryActivity.newIntent(activity)
|
||||||
@ -225,12 +257,41 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
* Applies filter change
|
* Applies filter change
|
||||||
*/
|
*/
|
||||||
private fun onFilterCheckboxChanged() {
|
private fun onFilterCheckboxChanged() {
|
||||||
presenter.updateLibrary()
|
presenter.resubscribeLibrary()
|
||||||
adapter.notifyDataSetChanged()
|
|
||||||
adapter.refreshRegisteredAdapters()
|
|
||||||
activity.supportInvalidateOptionsMenu()
|
activity.supportInvalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swap display mode
|
||||||
|
*/
|
||||||
|
private fun swapDisplayMode() {
|
||||||
|
presenter.swapDisplayMode()
|
||||||
|
reattachAdapter()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reattaches the adapter to the view pager to recreate fragments
|
||||||
|
*/
|
||||||
|
private fun reattachAdapter() {
|
||||||
|
val position = view_pager.currentItem
|
||||||
|
adapter.recycle = false
|
||||||
|
view_pager.adapter = adapter
|
||||||
|
view_pager.currentItem = position
|
||||||
|
adapter.recycle = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a preference for the number of manga per row based on the current orientation.
|
||||||
|
*
|
||||||
|
* @return the preference.
|
||||||
|
*/
|
||||||
|
private fun getColumnsPreferenceForCurrentOrientation(): Preference<Int> {
|
||||||
|
return if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||||
|
preferences.portraitColumns()
|
||||||
|
else
|
||||||
|
preferences.landscapeColumns()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the query.
|
* Updates the query.
|
||||||
*
|
*
|
||||||
@ -257,7 +318,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
R.string.information_empty_library, R.drawable.ic_book_black_128dp)
|
R.string.information_empty_library, R.drawable.ic_book_black_128dp)
|
||||||
|
|
||||||
// Get the current active category.
|
// Get the current active category.
|
||||||
val activeCat = if (adapter.categories != null) view_pager.currentItem else activeCategory
|
val activeCat = if (adapter.categories.isNotEmpty()) view_pager.currentItem else activeCategory
|
||||||
|
|
||||||
// Set the categories
|
// Set the categories
|
||||||
adapter.categories = categories
|
adapter.categories = categories
|
||||||
@ -273,31 +334,42 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the title of the action mode.
|
* Creates the action mode if it's not created already.
|
||||||
*
|
|
||||||
* @param count the number of items selected.
|
|
||||||
*/
|
*/
|
||||||
fun setContextTitle(count: Int) {
|
fun createActionModeIfNeeded() {
|
||||||
actionMode?.title = getString(R.string.label_selected, count)
|
if (actionMode == null) {
|
||||||
|
actionMode = activity.startSupportActionMode(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the visibility of the edit cover item.
|
* Destroys the action mode.
|
||||||
*
|
|
||||||
* @param count the number of items selected.
|
|
||||||
*/
|
*/
|
||||||
fun setVisibilityOfCoverEdit(count: Int) {
|
fun destroyActionModeIfNeeded() {
|
||||||
// If count = 1 display edit button
|
actionMode?.finish()
|
||||||
actionMode?.menu?.findItem(R.id.action_edit_cover)?.isVisible = count == 1
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the action mode, forcing it to refresh its content.
|
||||||
|
*/
|
||||||
|
fun invalidateActionMode() {
|
||||||
|
actionMode?.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
mode.menuInflater.inflate(R.menu.library_selection, menu)
|
mode.menuInflater.inflate(R.menu.library_selection, menu)
|
||||||
adapter.setSelectionMode(FlexibleAdapter.MODE_MULTI)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
val count = presenter.selectedMangas.size
|
||||||
|
if (count == 0) {
|
||||||
|
// Destroy action mode if there are no items selected.
|
||||||
|
destroyActionModeIfNeeded()
|
||||||
|
} else {
|
||||||
|
mode.title = getString(R.string.label_selected, count)
|
||||||
|
menu.findItem(R.id.action_edit_cover)?.isVisible = count == 1
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,18 +387,10 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
override fun onDestroyActionMode(mode: ActionMode) {
|
||||||
adapter.setSelectionMode(FlexibleAdapter.MODE_SINGLE)
|
presenter.clearSelections()
|
||||||
presenter.selectedMangas.clear()
|
|
||||||
actionMode = null
|
actionMode = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroys the action mode.
|
|
||||||
*/
|
|
||||||
fun destroyActionModeIfNeeded() {
|
|
||||||
actionMode?.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes the cover for the selected manga.
|
* Changes the cover for the selected manga.
|
||||||
*
|
*
|
||||||
@ -356,14 +420,14 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
context.contentResolver.openInputStream(data.data).use {
|
context.contentResolver.openInputStream(data.data).use {
|
||||||
// Update cover to selected file, show error if something went wrong
|
// Update cover to selected file, show error if something went wrong
|
||||||
if (presenter.editCoverWithStream(it, manga)) {
|
if (presenter.editCoverWithStream(it, manga)) {
|
||||||
adapter.refreshRegisteredAdapters()
|
// TODO refresh cover
|
||||||
} else {
|
} else {
|
||||||
context.toast(R.string.notification_manga_update_failed)
|
context.toast(R.string.notification_manga_update_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (error: IOException) {
|
||||||
context.toast(R.string.notification_manga_update_failed)
|
context.toast(R.string.notification_manga_update_failed)
|
||||||
e.printStackTrace()
|
Timber.e(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -410,20 +474,4 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the action mode if it's not created already.
|
|
||||||
*/
|
|
||||||
fun createActionModeIfNeeded() {
|
|
||||||
if (actionMode == null) {
|
|
||||||
actionMode = activity.startSupportActionMode(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidates the action mode, forcing it to refresh its content.
|
|
||||||
*/
|
|
||||||
fun invalidateActionMode() {
|
|
||||||
actionMode?.invalidate()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.library
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
|
||||||
|
* All the elements from the layout file "item_catalogue_grid" are available in this class.
|
||||||
|
*
|
||||||
|
* @param view the inflated view for this holder.
|
||||||
|
* @param adapter the adapter handling this holder.
|
||||||
|
* @param listener a listener to react to single tap and long tap events.
|
||||||
|
* @constructor creates a new library holder.
|
||||||
|
*/
|
||||||
|
class LibraryGridHolder(private val view: View,
|
||||||
|
private val adapter: LibraryCategoryAdapter,
|
||||||
|
listener: FlexibleViewHolder.OnListItemClickListener)
|
||||||
|
: LibraryHolder(view, adapter, listener) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
|
||||||
|
* holder with the given manga.
|
||||||
|
*
|
||||||
|
* @param manga the manga to bind.
|
||||||
|
*/
|
||||||
|
override fun onSetValues(manga: Manga) {
|
||||||
|
// Update the title of the manga.
|
||||||
|
view.title.text = manga.title
|
||||||
|
|
||||||
|
// Update the unread count and its visibility.
|
||||||
|
with(view.unread_text) {
|
||||||
|
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
|
||||||
|
text = manga.unread.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the cover.
|
||||||
|
Glide.clear(view.thumbnail)
|
||||||
|
Glide.with(view.context)
|
||||||
|
.load(manga)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.RESULT)
|
||||||
|
.centerCrop()
|
||||||
|
.into(view.thumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,23 +1,18 @@
|
|||||||
package eu.kanade.tachiyomi.ui.library
|
package eu.kanade.tachiyomi.ui.library
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||||
import kotlinx.android.synthetic.main.item_catalogue_grid.view.*
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
|
* Generic class used to hold the displayed data of a manga in the library.
|
||||||
* All the elements from the layout file "item_catalogue_grid" are available in this class.
|
|
||||||
*
|
|
||||||
* @param view the inflated view for this holder.
|
* @param view the inflated view for this holder.
|
||||||
* @param adapter the adapter handling this holder.
|
* @param adapter the adapter handling this holder.
|
||||||
* @param listener a listener to react to single tap and long tap events.
|
* @param listener a listener to react to the single tap and long tap events.
|
||||||
* @constructor creates a new library holder.
|
|
||||||
*/
|
*/
|
||||||
class LibraryHolder(private val view: View,
|
|
||||||
private val adapter: LibraryCategoryAdapter,
|
abstract class LibraryHolder(private val view: View,
|
||||||
|
adapter: LibraryCategoryAdapter,
|
||||||
listener: FlexibleViewHolder.OnListItemClickListener)
|
listener: FlexibleViewHolder.OnListItemClickListener)
|
||||||
: FlexibleViewHolder(view, adapter, listener) {
|
: FlexibleViewHolder(view, adapter, listener) {
|
||||||
|
|
||||||
@ -27,23 +22,6 @@ class LibraryHolder(private val view: View,
|
|||||||
*
|
*
|
||||||
* @param manga the manga to bind.
|
* @param manga the manga to bind.
|
||||||
*/
|
*/
|
||||||
fun onSetValues(manga: Manga) {
|
abstract fun onSetValues(manga: Manga)
|
||||||
// Update the title of the manga.
|
|
||||||
view.title.text = manga.title
|
|
||||||
|
|
||||||
// Update the unread count and its visibility.
|
|
||||||
with(view.unread_text) {
|
|
||||||
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
|
|
||||||
text = manga.unread.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the cover.
|
|
||||||
Glide.clear(view.thumbnail)
|
|
||||||
Glide.with(view.context)
|
|
||||||
.load(manga)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.RESULT)
|
|
||||||
.centerCrop()
|
|
||||||
.into(view.thumbnail)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.library
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.item_library_list.view.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class used to hold the displayed data of a manga in the library, like the cover or the title.
|
||||||
|
* All the elements from the layout file "item_library_list" are available in this class.
|
||||||
|
*
|
||||||
|
* @param view the inflated view for this holder.
|
||||||
|
* @param adapter the adapter handling this holder.
|
||||||
|
* @param listener a listener to react to single tap and long tap events.
|
||||||
|
* @constructor creates a new library holder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class LibraryListHolder(private val view: View,
|
||||||
|
private val adapter: LibraryCategoryAdapter,
|
||||||
|
listener: FlexibleViewHolder.OnListItemClickListener)
|
||||||
|
: LibraryHolder(view, adapter, listener) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method called from [LibraryCategoryAdapter.onBindViewHolder]. It updates the data for this
|
||||||
|
* holder with the given manga.
|
||||||
|
*
|
||||||
|
* @param manga the manga to bind.
|
||||||
|
*/
|
||||||
|
override fun onSetValues(manga: Manga) {
|
||||||
|
// Update the title of the manga.
|
||||||
|
itemView.title.text = manga.title
|
||||||
|
|
||||||
|
// Update the unread count and its visibility.
|
||||||
|
with(itemView.unread_text) {
|
||||||
|
visibility = if (manga.unread > 0) View.VISIBLE else View.GONE
|
||||||
|
text = manga.unread.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create thumbnail onclick to simulate long click
|
||||||
|
itemView.thumbnail.setOnClickListener {
|
||||||
|
// Simulate long click on this view to enter selection mode
|
||||||
|
onLongClick(itemView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the cover.
|
||||||
|
Glide.clear(itemView.thumbnail)
|
||||||
|
Glide.with(itemView.context)
|
||||||
|
.load(manga)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.RESULT)
|
||||||
|
.centerCrop()
|
||||||
|
.dontAnimate()
|
||||||
|
.into(itemView.thumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -16,6 +16,7 @@ import rx.Observable
|
|||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
import rx.subjects.BehaviorSubject
|
import rx.subjects.BehaviorSubject
|
||||||
|
import rx.subjects.PublishSubject
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@ -29,22 +30,27 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
|||||||
/**
|
/**
|
||||||
* Categories of the library.
|
* Categories of the library.
|
||||||
*/
|
*/
|
||||||
lateinit var categories: List<Category>
|
var categories: List<Category> = emptyList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Currently selected manga.
|
* Currently selected manga.
|
||||||
*/
|
*/
|
||||||
var selectedMangas = mutableListOf<Manga>()
|
val selectedMangas = mutableListOf<Manga>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search query of the library.
|
* Search query of the library.
|
||||||
*/
|
*/
|
||||||
val searchSubject = BehaviorSubject.create<String>()
|
val searchSubject: BehaviorSubject<String> = BehaviorSubject.create()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subject to notify the library's viewpager for updates.
|
* Subject to notify the library's viewpager for updates.
|
||||||
*/
|
*/
|
||||||
val libraryMangaSubject = BehaviorSubject.create<LibraryMangaEvent>()
|
val libraryMangaSubject: BehaviorSubject<LibraryMangaEvent> = BehaviorSubject.create()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subject to notify the UI of selection updates.
|
||||||
|
*/
|
||||||
|
val selectionSubject: PublishSubject<LibrarySelectionEvent> = PublishSubject.create()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database.
|
* Database.
|
||||||
@ -149,7 +155,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
|||||||
/**
|
/**
|
||||||
* Resubscribes to library.
|
* Resubscribes to library.
|
||||||
*/
|
*/
|
||||||
fun updateLibrary() {
|
fun resubscribeLibrary() {
|
||||||
start(GET_LIBRARY)
|
start(GET_LIBRARY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,15 +185,10 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (prefFilterDownloaded) {
|
if (prefFilterDownloaded) {
|
||||||
val mangaDir = downloadManager.getAbsoluteMangaDirectory(source, manga)
|
val mangaDir = downloadManager.findMangaDir(source, manga)
|
||||||
|
|
||||||
if (mangaDir.exists()) {
|
if (mangaDir != null) {
|
||||||
for (file in mangaDir.listFiles()) {
|
hasDownloaded = mangaDir.listFiles()?.any { it.isDirectory } ?: false
|
||||||
if (file.isDirectory && file.listFiles().isNotEmpty()) {
|
|
||||||
hasDownloaded = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,17 +220,27 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
|||||||
fun setSelection(manga: Manga, selected: Boolean) {
|
fun setSelection(manga: Manga, selected: Boolean) {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
selectedMangas.add(manga)
|
selectedMangas.add(manga)
|
||||||
|
selectionSubject.onNext(LibrarySelectionEvent.Selected(manga))
|
||||||
} else {
|
} else {
|
||||||
selectedMangas.remove(manga)
|
selectedMangas.remove(manga)
|
||||||
|
selectionSubject.onNext(LibrarySelectionEvent.Unselected(manga))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all the manga selections and notifies the UI.
|
||||||
|
*/
|
||||||
|
fun clearSelections() {
|
||||||
|
selectedMangas.clear()
|
||||||
|
selectionSubject.onNext(LibrarySelectionEvent.Cleared())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the common categories for the given list of manga.
|
* Returns the common categories for the given list of manga.
|
||||||
*
|
*
|
||||||
* @param mangas the list of manga.
|
* @param mangas the list of manga.
|
||||||
*/
|
*/
|
||||||
fun getCommonCategories(mangas: List<Manga>) = mangas.toSet()
|
fun getCommonCategories(mangas: List<Manga>): Collection<Category> = mangas.toSet()
|
||||||
.map { db.getCategoriesForManga(it).executeAsBlocking() }
|
.map { db.getCategoriesForManga(it).executeAsBlocking() }
|
||||||
.reduce { set1: Iterable<Category>, set2 -> set1.intersect(set2) }
|
.reduce { set1: Iterable<Category>, set2 -> set1.intersect(set2) }
|
||||||
|
|
||||||
@ -285,4 +296,12 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the active display mode.
|
||||||
|
*/
|
||||||
|
fun swapDisplayMode() {
|
||||||
|
val displayAsList = preferences.libraryAsList().getOrDefault()
|
||||||
|
preferences.libraryAsList().set(!displayAsList)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.library
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
|
||||||
|
sealed class LibrarySelectionEvent {
|
||||||
|
|
||||||
|
class Selected(val manga: Manga) : LibrarySelectionEvent()
|
||||||
|
class Unselected(val manga: Manga) : LibrarySelectionEvent()
|
||||||
|
class Cleared() : LibrarySelectionEvent()
|
||||||
|
}
|
@ -9,17 +9,36 @@ import android.util.AttributeSet
|
|||||||
import com.afollestad.materialdialogs.MaterialDialog
|
import com.afollestad.materialdialogs.MaterialDialog
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
||||||
import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView
|
import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class ChangelogDialogFragment : DialogFragment() {
|
class ChangelogDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun show(preferences: PreferencesHelper, fragmentManager: FragmentManager) {
|
fun show(context: Context, preferences: PreferencesHelper, fm: FragmentManager) {
|
||||||
if (preferences.lastVersionCode().getOrDefault() < BuildConfig.VERSION_CODE) {
|
val oldVersion = preferences.lastVersionCode().getOrDefault()
|
||||||
|
if (oldVersion < BuildConfig.VERSION_CODE) {
|
||||||
preferences.lastVersionCode().set(BuildConfig.VERSION_CODE)
|
preferences.lastVersionCode().set(BuildConfig.VERSION_CODE)
|
||||||
ChangelogDialogFragment().show(fragmentManager, "changelog")
|
ChangelogDialogFragment().show(fm, "changelog")
|
||||||
|
|
||||||
|
// TODO better upgrades management
|
||||||
|
if (oldVersion == 0) return
|
||||||
|
|
||||||
|
if (oldVersion < 14) {
|
||||||
|
// Restore jobs after upgrading to evernote's job scheduler.
|
||||||
|
if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) {
|
||||||
|
UpdateCheckerJob.setupTask()
|
||||||
|
}
|
||||||
|
LibraryUpdateJob.setupTask()
|
||||||
|
}
|
||||||
|
if (oldVersion < 15) {
|
||||||
|
// Delete internal chapter cache dir.
|
||||||
|
File(context.cacheDir, "chapter_disk_cache").deleteRecursively()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -27,7 +46,7 @@ class ChangelogDialogFragment : DialogFragment() {
|
|||||||
override fun onCreateDialog(savedState: Bundle?): Dialog {
|
override fun onCreateDialog(savedState: Bundle?): Dialog {
|
||||||
val view = WhatsNewRecyclerView(context)
|
val view = WhatsNewRecyclerView(context)
|
||||||
return MaterialDialog.Builder(activity)
|
return MaterialDialog.Builder(activity)
|
||||||
.title("Changelog")
|
.title(if (BuildConfig.DEBUG) "Notices" else "Changelog")
|
||||||
.customView(view, false)
|
.customView(view, false)
|
||||||
.positiveText(android.R.string.yes)
|
.positiveText(android.R.string.yes)
|
||||||
.build()
|
.build()
|
||||||
|
@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.ui.backup.BackupFragment
|
|||||||
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
||||||
import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment
|
import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment
|
||||||
import eu.kanade.tachiyomi.ui.download.DownloadFragment
|
import eu.kanade.tachiyomi.ui.download.DownloadFragment
|
||||||
|
import eu.kanade.tachiyomi.ui.latest_updates.LatestUpdatesFragment
|
||||||
import eu.kanade.tachiyomi.ui.library.LibraryFragment
|
import eu.kanade.tachiyomi.ui.library.LibraryFragment
|
||||||
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersFragment
|
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersFragment
|
||||||
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadFragment
|
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadFragment
|
||||||
@ -58,9 +59,10 @@ class MainActivity : BaseActivity() {
|
|||||||
val id = item.itemId
|
val id = item.itemId
|
||||||
when (id) {
|
when (id) {
|
||||||
R.id.nav_drawer_library -> setFragment(LibraryFragment.newInstance(), id)
|
R.id.nav_drawer_library -> setFragment(LibraryFragment.newInstance(), id)
|
||||||
R.id.nav_drawer_recently_read -> setFragment(RecentlyReadFragment.newInstance(), id)
|
|
||||||
R.id.nav_drawer_recent_updates -> setFragment(RecentChaptersFragment.newInstance(), id)
|
R.id.nav_drawer_recent_updates -> setFragment(RecentChaptersFragment.newInstance(), id)
|
||||||
|
R.id.nav_drawer_recently_read -> setFragment(RecentlyReadFragment.newInstance(), id)
|
||||||
R.id.nav_drawer_catalogues -> setFragment(CatalogueFragment.newInstance(), id)
|
R.id.nav_drawer_catalogues -> setFragment(CatalogueFragment.newInstance(), id)
|
||||||
|
R.id.nav_drawer_latest_updates -> setFragment(LatestUpdatesFragment.newInstance(), id)
|
||||||
R.id.nav_drawer_downloads -> setFragment(DownloadFragment.newInstance(), id)
|
R.id.nav_drawer_downloads -> setFragment(DownloadFragment.newInstance(), id)
|
||||||
R.id.nav_drawer_settings -> {
|
R.id.nav_drawer_settings -> {
|
||||||
val intent = Intent(this, SettingsActivity::class.java)
|
val intent = Intent(this, SettingsActivity::class.java)
|
||||||
@ -77,7 +79,7 @@ class MainActivity : BaseActivity() {
|
|||||||
setSelectedDrawerItem(startScreenId)
|
setSelectedDrawerItem(startScreenId)
|
||||||
|
|
||||||
// Show changelog if needed
|
// Show changelog if needed
|
||||||
ChangelogDialogFragment.show(preferences, supportFragmentManager)
|
ChangelogDialogFragment.show(this, preferences, supportFragmentManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ class MangaActivity : BaseRxActivity<MangaPresenter>() {
|
|||||||
|
|
||||||
const val FROM_CATALOGUE_EXTRA = "from_catalogue"
|
const val FROM_CATALOGUE_EXTRA = "from_catalogue"
|
||||||
const val MANGA_EXTRA = "manga"
|
const val MANGA_EXTRA = "manga"
|
||||||
|
const val FROM_LAUNCHER_EXTRA = "from_launcher"
|
||||||
const val INFO_FRAGMENT = 0
|
const val INFO_FRAGMENT = 0
|
||||||
const val CHAPTERS_FRAGMENT = 1
|
const val CHAPTERS_FRAGMENT = 1
|
||||||
const val MYANIMELIST_FRAGMENT = 2
|
const val MYANIMELIST_FRAGMENT = 2
|
||||||
@ -47,6 +48,11 @@ class MangaActivity : BaseRxActivity<MangaPresenter>() {
|
|||||||
super.onCreate(savedState)
|
super.onCreate(savedState)
|
||||||
setContentView(R.layout.activity_manga)
|
setContentView(R.layout.activity_manga)
|
||||||
|
|
||||||
|
val fromLauncher = intent.getBooleanExtra(FROM_LAUNCHER_EXTRA, false)
|
||||||
|
|
||||||
|
//Remove any current manga if we are launching from launcher
|
||||||
|
if(fromLauncher) SharedData.remove(MangaEvent::class.java)
|
||||||
|
|
||||||
presenter.setMangaEvent(SharedData.getOrPut(MangaEvent::class.java) {
|
presenter.setMangaEvent(SharedData.getOrPut(MangaEvent::class.java) {
|
||||||
val id = intent.getLongExtra(MANGA_EXTRA, 0)
|
val id = intent.getLongExtra(MANGA_EXTRA, 0)
|
||||||
MangaEvent(presenter.db.getManga(id).executeAsBlocking()!!)
|
MangaEvent(presenter.db.getManga(id).executeAsBlocking()!!)
|
||||||
|
@ -11,6 +11,13 @@ class ChaptersAdapter(val fragment: ChaptersFragment) : FlexibleAdapter<Chapters
|
|||||||
setHasStableIds(true)
|
setHasStableIds(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var items: List<ChapterModel>
|
||||||
|
get() = mItems
|
||||||
|
set(value) {
|
||||||
|
mItems = value
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateDataSet(param: String) {
|
override fun updateDataSet(param: String) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,8 +39,4 @@ class ChaptersAdapter(val fragment: ChaptersFragment) : FlexibleAdapter<Chapters
|
|||||||
return mItems[position].id!!
|
return mItems[position].id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setItems(chapters: List<ChapterModel>) {
|
|
||||||
mItems = chapters
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import android.content.Intent
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.v4.app.DialogFragment
|
import android.support.v4.app.DialogFragment
|
||||||
import android.support.v7.view.ActionMode
|
import android.support.v7.view.ActionMode
|
||||||
|
import android.support.v7.widget.DividerItemDecoration
|
||||||
import android.support.v7.widget.LinearLayoutManager
|
import android.support.v7.widget.LinearLayoutManager
|
||||||
import android.view.*
|
import android.view.*
|
||||||
import com.afollestad.materialdialogs.MaterialDialog
|
import com.afollestad.materialdialogs.MaterialDialog
|
||||||
@ -19,10 +20,8 @@ import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
|||||||
import eu.kanade.tachiyomi.ui.manga.MangaActivity
|
import eu.kanade.tachiyomi.ui.manga.MangaActivity
|
||||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||||
import eu.kanade.tachiyomi.util.getCoordinates
|
import eu.kanade.tachiyomi.util.getCoordinates
|
||||||
import eu.kanade.tachiyomi.util.getResourceDrawable
|
|
||||||
import eu.kanade.tachiyomi.util.toast
|
import eu.kanade.tachiyomi.util.toast
|
||||||
import eu.kanade.tachiyomi.widget.DeletingChaptersDialog
|
import eu.kanade.tachiyomi.widget.DeletingChaptersDialog
|
||||||
import eu.kanade.tachiyomi.widget.DividerItemDecoration
|
|
||||||
import kotlinx.android.synthetic.main.fragment_manga_chapters.*
|
import kotlinx.android.synthetic.main.fragment_manga_chapters.*
|
||||||
import nucleus.factory.RequiresPresenter
|
import nucleus.factory.RequiresPresenter
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@ -67,8 +66,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
|
|
||||||
recycler.adapter = adapter
|
recycler.adapter = adapter
|
||||||
recycler.layoutManager = LinearLayoutManager(activity)
|
recycler.layoutManager = LinearLayoutManager(activity)
|
||||||
recycler.addItemDecoration(DividerItemDecoration(
|
recycler.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
|
||||||
context.theme.getResourceDrawable(R.attr.divider_drawable)))
|
|
||||||
recycler.setHasFixedSize(true)
|
recycler.setHasFixedSize(true)
|
||||||
|
|
||||||
swipe_refresh.setOnRefreshListener { fetchChapters() }
|
swipe_refresh.setOnRefreshListener { fetchChapters() }
|
||||||
@ -116,8 +114,27 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
inflater.inflate(R.menu.chapters, menu)
|
inflater.inflate(R.menu.chapters, menu)
|
||||||
menu.findItem(R.id.action_filter_unread).isChecked = presenter.onlyUnread()
|
}
|
||||||
menu.findItem(R.id.action_filter_downloaded).isChecked = presenter.onlyDownloaded()
|
|
||||||
|
override fun onPrepareOptionsMenu(menu: Menu) {
|
||||||
|
// Initialize menu items.
|
||||||
|
val menuFilterRead = menu.findItem(R.id.action_filter_read)
|
||||||
|
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
|
||||||
|
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
|
||||||
|
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
|
||||||
|
|
||||||
|
// Set correct checkbox values.
|
||||||
|
menuFilterRead.isChecked = presenter.onlyRead()
|
||||||
|
menuFilterUnread.isChecked = presenter.onlyUnread()
|
||||||
|
menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
|
||||||
|
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
|
||||||
|
|
||||||
|
if (presenter.onlyRead())
|
||||||
|
//Disable unread filter option if read filter is enabled.
|
||||||
|
menuFilterUnread.isEnabled = false
|
||||||
|
if (presenter.onlyUnread())
|
||||||
|
//Disable read filter option if unread filter is enabled.
|
||||||
|
menuFilterRead.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
@ -126,13 +143,23 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
R.id.manga_download -> showDownloadDialog()
|
R.id.manga_download -> showDownloadDialog()
|
||||||
R.id.action_sorting_mode -> showSortingDialog()
|
R.id.action_sorting_mode -> showSortingDialog()
|
||||||
R.id.action_filter_unread -> {
|
R.id.action_filter_unread -> {
|
||||||
|
item.isChecked = !item.isChecked
|
||||||
|
presenter.setUnreadFilter(item.isChecked)
|
||||||
|
activity.supportInvalidateOptionsMenu()
|
||||||
|
}
|
||||||
|
R.id.action_filter_read -> {
|
||||||
item.isChecked = !item.isChecked
|
item.isChecked = !item.isChecked
|
||||||
presenter.setReadFilter(item.isChecked)
|
presenter.setReadFilter(item.isChecked)
|
||||||
|
activity.supportInvalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
R.id.action_filter_downloaded -> {
|
R.id.action_filter_downloaded -> {
|
||||||
item.isChecked = !item.isChecked
|
item.isChecked = !item.isChecked
|
||||||
presenter.setDownloadedFilter(item.isChecked)
|
presenter.setDownloadedFilter(item.isChecked)
|
||||||
}
|
}
|
||||||
|
R.id.action_filter_bookmarked -> {
|
||||||
|
item.isChecked = !item.isChecked
|
||||||
|
presenter.setBookmarkedFilter(item.isChecked)
|
||||||
|
}
|
||||||
R.id.action_filter_empty -> {
|
R.id.action_filter_empty -> {
|
||||||
presenter.removeFilters()
|
presenter.removeFilters()
|
||||||
activity.supportInvalidateOptionsMenu()
|
activity.supportInvalidateOptionsMenu()
|
||||||
@ -145,8 +172,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
|
|
||||||
fun onNextManga(manga: Manga) {
|
fun onNextManga(manga: Manga) {
|
||||||
// Set initial values
|
// Set initial values
|
||||||
setReadFilter()
|
activity.supportInvalidateOptionsMenu()
|
||||||
setDownloadedFilter()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onNextChapters(chapters: List<ChapterModel>) {
|
fun onNextChapters(chapters: List<ChapterModel>) {
|
||||||
@ -156,7 +182,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
initialFetchChapters()
|
initialFetchChapters()
|
||||||
|
|
||||||
destroyActionModeIfNeeded()
|
destroyActionModeIfNeeded()
|
||||||
adapter.setItems(chapters)
|
adapter.items = chapters
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initialFetchChapters() {
|
private fun initialFetchChapters() {
|
||||||
@ -241,7 +267,8 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
.itemsCallback { dialog, view, i, charSequence ->
|
.itemsCallback { dialog, view, i, charSequence ->
|
||||||
|
|
||||||
fun getUnreadChaptersSorted() = presenter.chapters
|
fun getUnreadChaptersSorted() = presenter.chapters
|
||||||
.filter { !it.read && !it.isDownloaded }
|
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
|
||||||
|
.distinctBy { it.name }
|
||||||
.sortedByDescending { it.source_order }
|
.sortedByDescending { it.source_order }
|
||||||
|
|
||||||
// i = 0: Download 1
|
// i = 0: Download 1
|
||||||
@ -333,7 +360,11 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun markPreviousAsRead(chapter: ChapterModel) {
|
fun markPreviousAsRead(chapter: ChapterModel) {
|
||||||
presenter.markPreviousChaptersAsRead(chapter)
|
val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
|
||||||
|
val chapterPos = chapters.indexOf(chapter)
|
||||||
|
if (chapterPos != -1) {
|
||||||
|
presenter.markChaptersRead(chapters.take(chapterPos), true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun downloadChapters(chapters: List<ChapterModel>) {
|
fun downloadChapters(chapters: List<ChapterModel>) {
|
||||||
@ -341,6 +372,11 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
presenter.downloadChapters(chapters)
|
presenter.downloadChapters(chapters)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun bookmarkChapters(chapters: List<ChapterModel>, bookmarked: Boolean) {
|
||||||
|
destroyActionModeIfNeeded()
|
||||||
|
presenter.bookmarkChapters(chapters, bookmarked)
|
||||||
|
}
|
||||||
|
|
||||||
fun deleteChapters(chapters: List<ChapterModel>) {
|
fun deleteChapters(chapters: List<ChapterModel>) {
|
||||||
destroyActionModeIfNeeded()
|
destroyActionModeIfNeeded()
|
||||||
DeletingChaptersDialog().show(childFragmentManager, DeletingChaptersDialog.TAG)
|
DeletingChaptersDialog().show(childFragmentManager, DeletingChaptersDialog.TAG)
|
||||||
@ -354,7 +390,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
|
|
||||||
fun onChaptersDeletedError(error: Throwable) {
|
fun onChaptersDeletedError(error: Throwable) {
|
||||||
dismissDeletingDialog()
|
dismissDeletingDialog()
|
||||||
Timber.e(error, error.message)
|
Timber.e(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissDeletingDialog() {
|
fun dismissDeletingDialog() {
|
||||||
@ -394,12 +430,4 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
|
|||||||
private fun setContextTitle(count: Int) {
|
private fun setContextTitle(count: Int) {
|
||||||
actionMode?.title = getString(R.string.label_selected, count)
|
actionMode?.title = getString(R.string.label_selected, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setReadFilter() {
|
|
||||||
activity.supportInvalidateOptionsMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDownloadedFilter() {
|
|
||||||
activity.supportInvalidateOptionsMenu()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ class ChaptersHolder(
|
|||||||
|
|
||||||
private val readColor = view.context.theme.getResourceColor(android.R.attr.textColorHint)
|
private val readColor = view.context.theme.getResourceColor(android.R.attr.textColorHint)
|
||||||
private val unreadColor = view.context.theme.getResourceColor(android.R.attr.textColorPrimary)
|
private val unreadColor = view.context.theme.getResourceColor(android.R.attr.textColorPrimary)
|
||||||
|
private val bookmarkedColor = view.context.theme.getResourceColor(R.attr.colorAccent)
|
||||||
private val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
|
private val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
|
||||||
private val df = DateFormat.getDateInstance(DateFormat.SHORT)
|
private val df = DateFormat.getDateInstance(DateFormat.SHORT)
|
||||||
|
|
||||||
@ -43,7 +44,10 @@ class ChaptersHolder(
|
|||||||
}
|
}
|
||||||
else -> chapter.name
|
else -> chapter.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set correct text color
|
||||||
chapter_title.setTextColor(if (chapter.read) readColor else unreadColor)
|
chapter_title.setTextColor(if (chapter.read) readColor else unreadColor)
|
||||||
|
if (chapter.bookmark) chapter_title.setTextColor(bookmarkedColor)
|
||||||
|
|
||||||
if (chapter.date_upload > 0) {
|
if (chapter.date_upload > 0) {
|
||||||
chapter_date.text = df.format(Date(chapter.date_upload))
|
chapter_date.text = df.format(Date(chapter.date_upload))
|
||||||
@ -84,6 +88,10 @@ class ChaptersHolder(
|
|||||||
popup.menu.findItem(R.id.action_delete).isVisible = true
|
popup.menu.findItem(R.id.action_delete).isVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide bookmark if bookmark
|
||||||
|
popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark
|
||||||
|
popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark
|
||||||
|
|
||||||
// Hide mark as unread when the chapter is unread
|
// Hide mark as unread when the chapter is unread
|
||||||
if (!chapter.read && chapter.last_page_read == 0) {
|
if (!chapter.read && chapter.last_page_read == 0) {
|
||||||
popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false
|
popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false
|
||||||
@ -101,6 +109,8 @@ class ChaptersHolder(
|
|||||||
with(adapter.fragment) {
|
with(adapter.fragment) {
|
||||||
when (menuItem.itemId) {
|
when (menuItem.itemId) {
|
||||||
R.id.action_download -> downloadChapters(chapterList)
|
R.id.action_download -> downloadChapters(chapterList)
|
||||||
|
R.id.action_bookmark -> bookmarkChapters(chapterList, true)
|
||||||
|
R.id.action_remove_bookmark -> bookmarkChapters(chapterList, false)
|
||||||
R.id.action_delete -> deleteChapters(chapterList)
|
R.id.action_delete -> deleteChapters(chapterList)
|
||||||
R.id.action_mark_as_read -> markAsRead(chapterList)
|
R.id.action_mark_as_read -> markAsRead(chapterList)
|
||||||
R.id.action_mark_as_unread -> markAsUnread(chapterList)
|
R.id.action_mark_as_unread -> markAsUnread(chapterList)
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user