Compare commits

..

102 Commits

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

* Add requested changes

* Cleanup App.kt Imports and add pref_language_key

* Acutally use @string for key

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

* Tint icon when there's a filter active

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

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

* Added all sort options

* Fixes

* Removed sort by added. Some renaming

* Removed date added database calls

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

* italian language: fix aapt error

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

* Rename imagePath to uri

* Handle android < 21

* Minor changes

* Separate downloader from the manager. Optimize folder lookups

* Persist downloads across restarts

* Fix for #511

* Updated ReactiveNetwork. Add some documentation

* More documentation and minor fixes

* Handle persistent notifications. Other minor changes

* Improve downloader and add documentation

* Rename pageNumber to index in Page class

* Remove unused methods

* Use chop method

* Make sure dest dir is created

* Reset downloads dir preference

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

* Fix empty download queue after application restart

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

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

* Removed network call now copies from page image

* Format fix + notification feedback

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

* Now uses glide for notification

* Fixed webtoon page

* Fixes + API 16 support

* fixes

* Fixed API 24 FileProvider error

* Added page.ready check

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

* Fixes
2016-11-06 13:33:00 +01:00
len
b418169c20 Exclude backup empty fields 2016-11-06 13:31:01 +01:00
len
f4d12ba622 Update travis 2016-11-05 20:16:54 +01:00
len
c64d8c8b6b Fix tests 2016-11-05 19:41:52 +01:00
len
10a1ba95d6 Support API 25 again. Bump dependencies 2016-11-05 19:28:47 +01:00
27d3daf918 Add support for latest updates to Readmangatoday (#512) 2016-11-03 16:17:37 +01:00
len
dcbd72e64d Release 0.3.2 2016-10-30 17:39:16 +01:00
len
52e1e93f9d Added another image decoder. It should be faster than Rapid and more reliable than Skia. 2016-10-28 19:26:47 +02:00
7d3d0999f3 Fixed API 24 FileProvider error 2016-10-25 17:34:49 +02:00
93f90b5a62 fixes 2016-10-25 16:08:33 +02:00
c2b113ac0a Fixes + API 16 support 2016-10-25 15:49:27 +02:00
8ff8ab4f27 Fixed webtoon page 2016-10-25 15:49:22 +02:00
414b8c9f21 Now uses glide for notification 2016-10-25 15:49:21 +02:00
4975787afa Added code to prevent OutOfMemory error. Made notification optional. Can now save image on long press. Bug fixes 2016-10-25 15:49:20 +02:00
1210691fdd Format fix + notification feedback 2016-10-25 15:49:19 +02:00
2a4527a8d6 Removed network call now copies from page image 2016-10-25 15:49:18 +02:00
2991906a85 Added option to download page or set page as cover 2016-10-25 15:49:17 +02:00
len
5b1f4f189b Reader fixes 2016-10-24 22:16:50 +02:00
len
d77a1e6925 Change webtoon image callback to onReady 2016-10-24 00:12:32 +02:00
len
19c713ebb2 Minor changes 2016-10-23 22:37:20 +02:00
len
90e0e0b72a Webtoon reader now shows download progress. Keep the progress bar until the image is decoded 2016-10-23 18:59:25 +02:00
len
22bbcaeed0 Remove builtin decoders from Rapid 2016-10-23 16:42:48 +02:00
len
d7b8015df7 Drop support for reencode images 2016-10-23 13:22:14 +02:00
len
c1ac47e1ce Revert support lib 25 (broken as usual), update subsampling lib 2016-10-22 21:43:37 +02:00
len
e375101132 Revert "Support API 25. Use new DividerItemDecoration."
This reverts commit 05b14bae7b.
2016-10-22 21:42:48 +02:00
len
05b14bae7b Support API 25. Use new DividerItemDecoration. 2016-10-22 20:21:25 +02:00
len
eb15fe3898 Remove 2048 bitmap size limit 2016-10-21 21:21:31 +02:00
4f5518bdd8 Fixed wrong chapter recognition for S0 - Chapter 00 (#499) 2016-10-20 16:28:25 +02:00
c9e1e6e020 Release 0.3.1 2016-10-17 08:43:19 +02:00
ade73e6892 Keep project classes 2016-10-17 08:43:19 +02:00
166 changed files with 4708 additions and 2264 deletions

View File

@ -5,8 +5,8 @@ android:
- tools - tools
# The BuildTools version used by your project # The BuildTools version used by your project
- build-tools-24.0.2 - build-tools-25.0.1
- android-24 - android-25
- extra-android-m2repository - extra-android-m2repository
- extra-google-m2repository - extra-google-m2repository
- extra-android-support - extra-android-support
@ -18,7 +18,7 @@ jdk:
before_script: before_script:
- chmod +x gradlew - chmod +x gradlew
#Build, and run tests #Build, and run tests
script: "./gradlew clean buildDebug" script: "./gradlew clean buildStandardDebug"
sudo: false sudo: false
before_cache: before_cache:

View File

@ -28,27 +28,22 @@ ext {
} }
} }
def includeUpdater() {
return hasProperty("include_updater")
}
android { android {
compileSdkVersion 24 compileSdkVersion 25
buildToolsVersion "24.0.2" buildToolsVersion "25.0.1"
publishNonDefault true publishNonDefault true
defaultConfig { defaultConfig {
applicationId "eu.kanade.tachiyomi" applicationId "eu.kanade.tachiyomi"
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 24 targetSdkVersion 25
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 11 versionCode 17
versionName "0.3.0" versionName "0.4.1"
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
@ -71,6 +66,16 @@ android {
} }
} }
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'
@ -93,11 +98,10 @@ android {
dependencies { dependencies {
// Modified dependencies // Modified dependencies
compile 'com.github.inorichi:subsampling-scale-image-view:2d9c854' 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 = '24.2.1' 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"
@ -108,15 +112,16 @@ dependencies {
compile 'com.android.support:multidex:1.0.1' compile 'com.android.support:multidex:1.0.1'
compile 'com.google.android.gms:play-services-gcm:9.6.1'
// ReactiveX // ReactiveX
compile 'io.reactivex:rxandroid:1.2.1' compile 'io.reactivex:rxandroid:1.2.1'
compile 'io.reactivex:rxjava:1.2.1' 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.4.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'
@ -124,24 +129,26 @@ 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.10.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.4.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:1.0.0' 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'
@ -176,8 +183,8 @@ dependencies {
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.9.0.2' compile 'com.afollestad.material-dialogs:core:0.9.1.0'
compile 'net.xpece.android:support-preference:1.0.3' 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 'de.hdodenhof:circleimageview:2.1.0' compile 'de.hdodenhof:circleimageview:2.1.0'
@ -185,15 +192,17 @@ dependencies {
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.2'
testCompile 'org.robolectric:shadows-multidex:3.1.2' final robolectric_version = '3.1.4'
testCompile 'org.robolectric:shadows-play-services:3.1.2' 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.4' ext.kotlin_version = '1.0.5-2'
repositories { repositories {
mavenCentral() mavenCentral()
} }

View File

@ -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 *; }

View File

@ -54,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"/>
@ -63,32 +73,14 @@
<service android:name=".data.mangasync.UpdateMangaSyncService" <service android:name=".data.mangasync.UpdateMangaSyncService"
android:exported="false"/> android:exported="false"/>
<service
android:name=".data.library.LibraryUpdateTrigger"
android:exported="true"
android:permission="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE">
<intent-filter>
<action android:name="com.google.android.gms.gcm.ACTION_TASK_READY" />
</intent-filter>
</service>
<service
android:name=".data.updater.UpdateCheckerService"
android:exported="true"
android:permission="com.google.android.gms.permission.BIND_NETWORK_TASK_SERVICE">
<intent-filter>
<action android:name="com.google.android.gms.gcm.ACTION_TASK_READY" />
</intent-filter>
</service>
<service android:name=".data.updater.UpdateDownloaderService" <service android:name=".data.updater.UpdateDownloaderService"
android:exported="false"/> android:exported="false"/>
<receiver android:name=".data.updater.UpdateNotificationReceiver"/> <receiver android:name=".data.updater.UpdateNotificationReceiver"/>
<receiver <receiver android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver" />
android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver">
</receiver> <receiver android:name=".ui.reader.notification.ImageNotificationReceiver" />
<meta-data <meta-data
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule" android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"

View File

@ -2,7 +2,12 @@ package eu.kanade.tachiyomi
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.support.multidex.MultiDex import android.support.multidex.MultiDex
import com.evernote.android.job.JobManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import eu.kanade.tachiyomi.util.LocaleHelper
import org.acra.ACRA import org.acra.ACRA
import org.acra.annotation.ReportsCrashes import org.acra.annotation.ReportsCrashes
import timber.log.Timber import timber.log.Timber
@ -27,6 +32,9 @@ open class App : Application() {
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
setupAcra() setupAcra()
setupJobManager()
LocaleHelper.updateCfg(this, baseContext.resources.configuration)
} }
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {
@ -36,8 +44,23 @@ open class App : Application() {
} }
} }
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
LocaleHelper.updateCfg(this, newConfig)
}
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
}
}
}
} }

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,450 +1,172 @@
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)
}
}, { error ->
DownloadService.stop(context)
Timber.e(error)
downloadNotifier.onError(error.message)
})
if (!isRunning) {
isRunning = true
runningSubject.onNext(true)
}
}
fun destroySubscriptions() {
if (isRunning) {
isRunning = false
runningSubject.onNext(false)
}
if (downloadsSubscription != null) {
downloadsSubscription?.unsubscribe()
downloadsSubscription = null
}
if (threadsSubscription != null) {
threadsSubscription?.unsubscribe()
}
}
// Create a download object for every chapter and add them to the downloads queue
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
// Add chapters to queue from the start
val sortedChapters = chapters.sortedByDescending { it.source_order }
// Used to avoid downloading chapters with the same name
val addedChapters = ArrayList<String>()
val pending = ArrayList<Download>()
for (chapter in sortedChapters) {
if (addedChapters.contains(chapter.name))
continue
addedChapters.add(chapter.name)
val download = Download(source, manga, chapter)
if (!prepareDownload(download)) {
queue.add(download)
pending.add(download)
}
}
// Initialize queue size
downloadNotifier.initialQueueSize = queue.size
// Show notification
downloadNotifier.onProgressChange(queue)
if (isRunning) downloadsQueueSubject.onNext(pending)
}
// Public method to check if a chapter is downloaded
fun isChapterDownloaded(source: Source, manga: Manga, chapter: Chapter): Boolean {
val directory = getAbsoluteChapterDirectory(source, manga, chapter)
if (!directory.exists())
return false
val pages = getSavedPageList(source, manga, chapter)
return isChapterDownloaded(directory, pages)
}
// Prepare the download. Returns true if the chapter is already downloaded
private fun prepareDownload(download: Download): Boolean {
// If the chapter is already queued, don't add it again
for (queuedDownload in queue) {
if (download.chapter.id == queuedDownload.chapter.id)
return true
}
// Add the directory to the download object for future access
download.directory = getAbsoluteChapterDirectory(download)
// If the directory doesn't exist, the chapter isn't downloaded.
if (!download.directory.exists()) {
return false
}
// If the page list doesn't exist, the chapter isn't downloaded
val savedPages = getSavedPageList(download) ?: return false
// Add the page list to the download object for future access
download.pages = savedPages
// If the number of files matches the number of pages, the chapter is downloaded.
// We have the index file, so we check one file more
return isChapterDownloaded(download.directory, download.pages)
}
// Check that all the images are downloaded
private fun isChapterDownloaded(directory: File, pages: List<Page>?): Boolean {
return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size
}
// Download the entire chapter
private fun downloadChapter(download: Download): Observable<Download> {
DiskUtils.createDirectory(download.directory)
val pageListObservable: Observable<List<Page>> = if (download.pages == null)
// Pull page list from network and add them to download object
download.source.fetchPageListFromNetwork(download.chapter)
.doOnNext { pages ->
download.pages = pages
savePageList(download)
}
else
// Or if the page list already exists, start from the file
Observable.just(download.pages)
return Observable.defer {
pageListObservable
.doOnNext { pages ->
download.downloadedImages = 0
download.status = Download.DOWNLOADING
}
// Get all the URLs to the source images, fetch pages if necessary
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
// Start downloading images, consider we can have downloaded images already
.concatMap { page -> getOrDownloadImage(page, download) }
// Do when page is downloaded.
.doOnNext {
downloadNotifier.onProgressChange(download, queue)
}
// Do after download completes
.doOnCompleted { onDownloadCompleted(download) }
.toList()
.map { pages -> download }
// If the page list threw, it will resume here
.onErrorResumeNext { error ->
download.status = Download.ERROR
downloadNotifier.onError(error.message, download.chapter.name)
Observable.just(download)
}
}.subscribeOn(Schedulers.io())
}
// Get the image from the filesystem if it exists or download from network
private fun getOrDownloadImage(page: Page, download: Download): Observable<Page> {
// If the image URL is empty, do nothing
if (page.imageUrl == null)
return Observable.just(page)
val filename = getImageFilename(page)
val imagePath = File(download.directory, filename)
// If the image is already downloaded, do nothing. Otherwise download from network
val pageObservable = if (isImageDownloaded(imagePath))
Observable.just(page)
else
downloadImage(page, download.source, download.directory, filename)
return pageObservable
// When the image is ready, set image path, progress (just in case) and status
.doOnNext {
page.imagePath = imagePath.absolutePath
page.progress = 100
download.downloadedImages++
page.status = Page.READY
}
// Mark this page as error and allow to download the remaining
.onErrorResumeNext {
page.progress = 0
page.status = Page.ERROR
Observable.just(page)
}
}
// Save image on disk
private fun downloadImage(page: Page, source: OnlineSource, directory: File, filename: String): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE
return source.imageResponse(page)
.map {
val file = File(directory, filename)
try {
file.parentFile.mkdirs()
it.body().source().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 (error: Exception) {
Timber.e(error)
}
}
}
// Shortcut for the method above
private fun savePageList(download: Download) {
savePageList(download.source, download.manga, download.chapter, download.pages!!)
}
fun getAbsoluteMangaDirectory(source: Source, manga: Manga): File {
val mangaRelativePath = source.toString() +
File.separator +
manga.title.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
return File(preferences.downloadsDirectory().getOrDefault(), mangaRelativePath)
}
// Get the absolute path to the chapter directory
fun getAbsoluteChapterDirectory(source: Source, manga: Manga, chapter: Chapter): File {
val chapterRelativePath = chapter.name.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
return File(getAbsoluteMangaDirectory(source, manga), chapterRelativePath)
}
// Shortcut for the method above
private fun getAbsoluteChapterDirectory(download: Download): File {
return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter)
}
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
val path = getAbsoluteChapterDirectory(source, manga, chapter)
DiskUtils.deleteFiles(path)
}
fun areAllDownloadsFinished(): Boolean {
for (download in queue) {
if (download.status <= Download.DOWNLOADING)
return false
}
return true
}
/**
* This class is used to manage chapter downloads in the application. It must be instantiated once
* and retrieved through dependency injection. You can use this class to queue new chapters or query
* downloaded chapters.
*
* @param context the application context.
*/
class DownloadManager(context: Context) {
/**
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
*/
private val provider = DownloadProvider(context)
/**
* Downloader whose only task is to download chapters.
*/
private val downloader = Downloader(context, provider)
/**
* Downloads queue, where the pending chapters are stored.
*/
val queue: DownloadQueue
get() = downloader.queue
/**
* Subject for subscribing to downloader status.
*/
val runningRelay: BehaviorRelay<Boolean>
get() = downloader.runningRelay
/**
* Tells the downloader to begin downloads.
*
* @return true if it's started, false otherwise (empty queue).
*/
fun startDownloads(): Boolean { 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 a manga.
*
* @param manga the manga to query.
*/
fun getMangaDirName(manga: Manga): String {
return provider.getMangaDirName(manga)
}
/**
* Returns the directory name for the given chapter.
*
* @param chapter the chapter to query.
*/
fun getChapterDirName(chapter: Chapter): String {
return provider.getChapterDirName(chapter)
}
/**
* Returns the download directory for a source if it exists.
*
* @param source the source to query.
*/
fun findSourceDir(source: Source): UniFile? {
return provider.findSourceDir(source)
}
/**
* Returns the directory for the given manga, if it exists.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
fun findMangaDir(source: Source, manga: Manga): UniFile? {
return provider.findMangaDir(source, manga)
}
/**
* Returns the directory for the given chapter, if it exists.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to query.
*/
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
return provider.findChapterDir(source, manga, chapter)
}
/**
* Deletes the directory of a downloaded chapter.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to delete.
*/
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
provider.findChapterDir(source, manga, chapter)?.delete()
} }
} }

View File

@ -1,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)
@ -105,11 +121,7 @@ class DownloadNotifier(private val context: Context) {
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)
@ -118,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)
@ -136,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
@ -144,30 +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 // Reset download information
onClear()
isDownloading = false isDownloading = false
} }
} }

View File

@ -0,0 +1,111 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.net.Uri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.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 var downloadsDir = preferences.downloadsDirectory().getOrDefault().let {
UniFile.fromUri(context, Uri.parse(it))
}
init {
preferences.downloadsDirectory().asObservable()
.skip(1)
.subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) }
}
/**
* Returns the download directory for a manga. For internal use only.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
internal fun getMangaDir(source: Source, manga: Manga): UniFile {
return downloadsDir
.createDirectory(getSourceDirName(source))
.createDirectory(getMangaDirName(manga))
}
/**
* Returns the download directory for a source if it exists.
*
* @param source the source to query.
*/
fun findSourceDir(source: Source): UniFile? {
return downloadsDir.findFile(getSourceDirName(source))
}
/**
* Returns the download directory for a manga if it exists.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
fun findMangaDir(source: Source, manga: Manga): UniFile? {
val sourceDir = findSourceDir(source)
return sourceDir?.findFile(getMangaDirName(manga))
}
/**
* Returns the download directory for a chapter if it exists.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to query.
*/
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
val mangaDir = findMangaDir(source, manga)
return mangaDir?.findFile(getChapterDirName(chapter))
}
/**
* Returns the download directory name for a source.
*
* @param source the source to query.
*/
fun getSourceDirName(source: Source): String {
return source.toString()
}
/**
* Returns the download directory name for a manga.
*
* @param manga the manga to query.
*/
fun getMangaDirName(manga: Manga): String {
return DiskUtil.buildValidFilename(manga.title)
}
/**
* Returns the chapter directory name for a chapter.
*
* @param chapter the chapter to query.
*/
fun getChapterDirName(chapter: Chapter): String {
return DiskUtil.buildValidFilename(chapter.name)
}
}

View File

@ -3,130 +3,177 @@ package eu.kanade.tachiyomi.data.download
import android.app.Service import android.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()
}
} }
} }

View File

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

View File

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

View File

@ -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

View File

@ -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>) {
download.setStatusSubject(statusSubject) downloads.forEach { download ->
download.status = Download.QUEUE download.setStatusSubject(statusSubject)
return queue.add(download) download.status = Download.QUEUE
}
queue.addAll(downloads)
store.addAll(downloads)
updatedRelay.call(Unit)
} }
fun del(download: Download) { fun remove(download: Download) {
val removed = queue.remove(download) 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()

View File

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

View File

@ -5,7 +5,6 @@ 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.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
@ -14,17 +13,17 @@ import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
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.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
import eu.kanade.tachiyomi.util.AndroidComponentUtil import eu.kanade.tachiyomi.util.*
import eu.kanade.tachiyomi.util.notification
import eu.kanade.tachiyomi.util.notificationManager
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
@ -57,6 +56,8 @@ class LibraryUpdateService : Service() {
*/ */
val preferences: PreferencesHelper by injectLazy() val preferences: PreferencesHelper by injectLazy()
val downloadManager: DownloadManager by injectLazy()
/** /**
* Wake lock that will be held until the service is destroyed. * Wake lock that will be held until the service is destroyed.
*/ */
@ -73,7 +74,9 @@ class LibraryUpdateService : Service() {
private val notificationId: Int private val notificationId: Int
get() = Constants.NOTIFICATION_LIBRARY_ID get() = Constants.NOTIFICATION_LIBRARY_ID
private var notificationBitmap: Bitmap? = null private val notificationBitmap by lazy {
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
companion object { companion object {
@ -141,8 +144,6 @@ class LibraryUpdateService : Service() {
*/ */
override fun onDestroy() { override fun onDestroy() {
subscription?.unsubscribe() subscription?.unsubscribe()
notificationBitmap?.recycle()
notificationBitmap = null
destroyWakeLock() destroyWakeLock()
super.onDestroy() super.onDestroy()
} }
@ -171,10 +172,6 @@ class LibraryUpdateService : Service() {
// Update favorite manga. Destroy service when completed or in case of an error. // Update favorite manga. Destroy service when completed or in case of an error.
subscription = Observable subscription = Observable
.defer { .defer {
if (notificationBitmap == null) {
notificationBitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
val mangaList = getMangaToUpdate(intent) val mangaList = getMangaToUpdate(intent)
// Update either chapter list or manga details. // Update either chapter list or manga details.
@ -251,33 +248,55 @@ class LibraryUpdateService : Service() {
// If there's any error, return empty update and continue. // If there's any error, return empty update and continue.
.onErrorReturn { .onErrorReturn {
failedUpdates.add(manga) failedUpdates.add(manga)
Pair(0, 0) Pair(emptyList<Chapter>(), emptyList<Chapter>())
} }
// Filter out mangas without new chapters (or failed). // Filter out mangas without new chapters (or failed).
.filter { pair -> pair.first > 0 } .filter { pair -> pair.first.size > 0 }
.doOnNext {
if (preferences.downloadNew()) {
downloadChapters(manga, it.first)
}
}
// Convert to the manga that contains new chapters. // Convert to the manga that contains new chapters.
.map { manga } .map { manga }
} }
// Add manga with new chapters to the list. // Add manga with new chapters to the list.
.doOnNext { newUpdates.add(it) } .doOnNext { manga ->
// Set last updated time
manga.last_update = Date().time
db.updateLastUpdated(manga).executeAsBlocking()
// Add to the list
newUpdates.add(manga)
}
// Notify result of the overall update. // Notify result of the overall update.
.doOnCompleted { .doOnCompleted {
if (newUpdates.isEmpty()) { if (newUpdates.isEmpty()) {
cancelNotification() cancelNotification()
} else { } else {
if (preferences.downloadNew()) {
DownloadService.start(this)
}
showResultNotification(newUpdates, failedUpdates) showResultNotification(newUpdates, failedUpdates)
} }
LibraryUpdateTrigger.setupTask(this)
} }
} }
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
// we need to get the chapters from the db so we have chapter ids
val mangaChapters = db.getChapters(manga).executeAsBlocking()
val dbChapters = chapters.map {
mangaChapters.find { mangaChapter -> mangaChapter.url == it.url }!!
}
downloadManager.downloadChapters(manga, dbChapters)
}
/** /**
* Updates the chapters for the given manga and adds them to the database. * Updates the chapters for the given manga and adds them to the database.
* *
* @param manga the manga to update. * @param manga the manga to update.
* @return a pair of the inserted and removed chapters. * @return a pair of the inserted and removed chapters.
*/ */
fun updateManga(manga: Manga): Observable<Pair<Int, Int>> { fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return Observable.empty() val source = sourceManager.get(manga.source) as? OnlineSource ?: return Observable.empty()
return source.fetchChapterList(manga) return source.fetchChapterList(manga)
.map { syncChaptersWithSource(db, it, manga, source) } .map { syncChaptersWithSource(db, it, manga, source) }
@ -328,7 +347,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")
@ -336,7 +355,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()) {
@ -344,10 +363,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()
} }
} }
@ -376,7 +394,7 @@ 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) setLargeIcon(notificationBitmap)
setContentTitle(title) setContentTitle(title)
@ -392,7 +410,7 @@ 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) setLargeIcon(notificationBitmap)
setContentTitle(manga.title) setContentTitle(manga.title)
@ -413,7 +431,7 @@ 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) setLargeIcon(notificationBitmap)
setContentTitle(title) setContentTitle(title)

View File

@ -1,52 +0,0 @@
package eu.kanade.tachiyomi.data.library
import android.content.Context
import com.google.android.gms.gcm.*
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class LibraryUpdateTrigger : GcmTaskService() {
override fun onInitializeTasks() {
setupTask(this)
}
override fun onRunTask(params: TaskParams): Int {
LibraryUpdateService.start(this)
return GcmNetworkManager.RESULT_SUCCESS
}
companion object {
fun setupTask(context: Context, prefInterval: Int? = null) {
val preferences = Injekt.get<PreferencesHelper>()
val interval = prefInterval ?: preferences.libraryUpdateInterval().getOrDefault()
if (interval > 0) {
val restrictions = preferences.libraryUpdateRestriction()
val acRestriction = "ac" in restrictions
val wifiRestriction = if ("wifi" in restrictions)
Task.NETWORK_STATE_UNMETERED
else
Task.NETWORK_STATE_ANY
val task = PeriodicTask.Builder()
.setService(LibraryUpdateTrigger::class.java)
.setTag("Library periodic update")
.setPeriod(interval * 60 * 60L)
.setFlex(5 * 60)
.setRequiredNetwork(wifiRestriction)
.setRequiresCharging(acRestriction)
.setUpdateCurrent(true)
.setPersisted(true)
.build()
GcmNetworkManager.getInstance(context).schedule(task)
}
}
fun cancelTask(context: Context) {
GcmNetworkManager.getInstance(context).cancelAllTasks(LibraryUpdateTrigger::class.java)
}
}
}

View File

@ -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 {
@ -51,6 +46,15 @@ fun Call.asObservable(): Observable<Response> {
} }
} }
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw Exception("Unsuccessful code ${response.code()}")
}
}
}
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call { fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder() val progressClient = newBuilder()
.cache(null) .cache(null)

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.R
* in the file "keys.xml". By using this class we can define preferences in one place and get them * 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)
@ -44,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)
@ -84,10 +83,14 @@ class PreferenceKeys(context: Context) {
val filterUnread = context.getString(R.string.pref_filter_unread_key) val filterUnread = context.getString(R.string.pref_filter_unread_key)
val librarySortingMode = context.getString(R.string.pref_library_sorting_mode_key)
val automaticUpdates = context.getString(R.string.pref_enable_automatic_updates_key) val 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)
val downloadNew = context.getString(R.string.pref_download_new_key)
fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId" fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId"
fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId" fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId"
@ -96,6 +99,8 @@ 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) val libraryAsList = context.getString(R.string.pref_display_library_as_list)
val lang = context.getString(R.string.pref_language_key)
} }

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.preference package eu.kanade.tachiyomi.data.preference
import android.content.Context import android.content.Context
import android.net.Uri
import android.os.Environment import android.os.Environment
import android.preference.PreferenceManager import android.preference.PreferenceManager
import com.f2prateek.rx.preferences.Preference import com.f2prateek.rx.preferences.Preference
@ -9,10 +10,11 @@ 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()!!
fun Preference<Boolean>.invert(): Boolean = getOrDefault().let { set(!it); !it }
class PreferencesHelper(context: Context) { class PreferencesHelper(context: Context) {
val keys = PreferenceKeys(context) val keys = PreferenceKeys(context)
@ -20,17 +22,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)
@ -70,8 +64,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)
@ -114,7 +106,7 @@ 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)
@ -136,8 +128,16 @@ class PreferencesHelper(context: Context) {
fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false) fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false)
fun librarySortingMode() = rxPrefs.getInteger(keys.librarySortingMode, 0)
fun librarySortingAscending() = rxPrefs.getBoolean("library_sorting_ascending", true)
fun automaticUpdates() = prefs.getBoolean(keys.automaticUpdates, false) fun automaticUpdates() = prefs.getBoolean(keys.automaticUpdates, false)
fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet()) fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet())
fun downloadNew() = prefs.getBoolean(keys.downloadNew, false)
fun lang() = prefs.getInt(keys.lang, 0)
} }

View File

@ -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

View File

@ -1,11 +1,12 @@
package eu.kanade.tachiyomi.data.source.online package eu.kanade.tachiyomi.data.source.online
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
import eu.kanade.tachiyomi.data.network.GET import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.NetworkHelper import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.network.asObservable import eu.kanade.tachiyomi.data.network.asObservableSuccess
import eu.kanade.tachiyomi.data.network.newCallWithProgress import eu.kanade.tachiyomi.data.network.newCallWithProgress
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.Language import eu.kanade.tachiyomi.data.source.Language
@ -92,7 +93,7 @@ abstract class OnlineSource() : Source {
*/ */
open fun fetchPopularManga(page: MangasPage): Observable<MangasPage> = client open fun fetchPopularManga(page: MangasPage): Observable<MangasPage> = client
.newCall(popularMangaRequest(page)) .newCall(popularMangaRequest(page))
.asObservable() .asObservableSuccess()
.map { response -> .map { response ->
popularMangaParse(response, page) popularMangaParse(response, page)
page page
@ -135,7 +136,7 @@ abstract class OnlineSource() : Source {
*/ */
open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client
.newCall(searchMangaRequest(page, query, filters)) .newCall(searchMangaRequest(page, query, filters))
.asObservable() .asObservableSuccess()
.map { response -> .map { response ->
searchMangaParse(response, page, query, filters) searchMangaParse(response, page, query, filters)
page page
@ -177,7 +178,7 @@ abstract class OnlineSource() : Source {
*/ */
open fun fetchLatestUpdates(page: MangasPage): Observable<MangasPage> = client open fun fetchLatestUpdates(page: MangasPage): Observable<MangasPage> = client
.newCall(latestUpdatesRequest(page)) .newCall(latestUpdatesRequest(page))
.asObservable() .asObservableSuccess()
.map { response -> .map { response ->
latestUpdatesParse(response, page) latestUpdatesParse(response, page)
page page
@ -211,7 +212,7 @@ abstract class OnlineSource() : Source {
*/ */
override fun fetchMangaDetails(manga: Manga): Observable<Manga> = client override fun fetchMangaDetails(manga: Manga): Observable<Manga> = client
.newCall(mangaDetailsRequest(manga)) .newCall(mangaDetailsRequest(manga))
.asObservable() .asObservableSuccess()
.map { response -> .map { response ->
Manga.create(manga.url, id).apply { Manga.create(manga.url, id).apply {
mangaDetailsParse(response, this) mangaDetailsParse(response, this)
@ -245,7 +246,7 @@ abstract class OnlineSource() : Source {
*/ */
override fun fetchChapterList(manga: Manga): Observable<List<Chapter>> = client override fun fetchChapterList(manga: Manga): Observable<List<Chapter>> = client
.newCall(chapterListRequest(manga)) .newCall(chapterListRequest(manga))
.asObservable() .asObservableSuccess()
.map { response -> .map { response ->
mutableListOf<Chapter>().apply { mutableListOf<Chapter>().apply {
chapterListParse(response, this) chapterListParse(response, this)
@ -291,11 +292,8 @@ abstract class OnlineSource() : Source {
*/ */
open fun fetchPageListFromNetwork(chapter: Chapter): Observable<List<Page>> = client open fun fetchPageListFromNetwork(chapter: Chapter): Observable<List<Page>> = client
.newCall(pageListRequest(chapter)) .newCall(pageListRequest(chapter))
.asObservable() .asObservableSuccess()
.map { response -> .map { response ->
if (!response.isSuccessful) {
throw Exception("Webpage sent ${response.code()} code")
}
mutableListOf<Page>().apply { mutableListOf<Page>().apply {
pageListParse(response, this) pageListParse(response, this)
if (isEmpty()) { if (isEmpty()) {
@ -337,7 +335,7 @@ abstract class OnlineSource() : Source {
page.status = Page.LOAD_PAGE page.status = Page.LOAD_PAGE
return client return client
.newCall(imageUrlRequest(page)) .newCall(imageUrlRequest(page))
.asObservable() .asObservableSuccess()
.map { imageUrlParse(it) } .map { imageUrlParse(it) }
.doOnError { page.status = Page.ERROR } .doOnError { page.status = Page.ERROR }
.onErrorReturn { null } .onErrorReturn { null }
@ -380,13 +378,7 @@ abstract class OnlineSource() : Source {
*/ */
fun imageResponse(page: Page): Observable<Response> = client fun imageResponse(page: Page): Observable<Response> = client
.newCallWithProgress(imageRequest(page), page) .newCallWithProgress(imageRequest(page), page)
.asObservable() .asObservableSuccess()
.doOnNext {
if (!it.isSuccessful) {
it.close()
throw RuntimeException("Not a valid response")
}
}
/** /**
* Returns the request for getting the source image. Override only if it's needed to override * Returns the request for getting the source image. Override only if it's needed to override
@ -416,7 +408,7 @@ abstract class OnlineSource() : 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 }
@ -431,7 +423,7 @@ abstract class OnlineSource() : 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 }
} }
@ -461,8 +453,14 @@ abstract class OnlineSource() : 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) {
} }

View File

@ -201,7 +201,7 @@ class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource {
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) {

View File

@ -80,10 +80,22 @@ class Mangahere(override val id: Int) : ParsedOnlineSource() {
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
} }

View File

@ -1,125 +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(override val id: Int) : ParsedOnlineSource() { 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
override val supportsLatest = false override val supportsLatest = true
private val datePattern = Pattern.compile("(\\d+)\\s+(.*?)s? (from now|ago).*") private val recentUpdatesPattern = Pattern.compile("(.*?)\\s(\\d+\\.?\\d*)\\s?(Completed)?")
private val dateFields = HashMap<String, Int>().apply { private val indexPattern = Pattern.compile("-index-(.*?)-")
put("second", Calendar.SECOND)
put("minute", Calendar.MINUTE) override fun popularMangaInitialUrl() = "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending"
put("hour", Calendar.HOUR)
put("day", Calendar.DATE) override fun popularMangaSelector() = "div.requested > div.row"
put("week", Calendar.WEEK_OF_YEAR)
put("month", Calendar.MONTH) override fun popularMangaRequest(page: MangasPage): Request {
put("year", Calendar.YEAR) if (page.page == 1) {
page.url = popularMangaInitialUrl()
}
val (body, requestUrl) = convertQueryToPost(page)
return POST(requestUrl, headers, body.build())
} }
private val dateRelationFields = HashMap<String, Int>().apply { override fun popularMangaParse(response: Response, page: MangasPage) {
put("from now", 1) val document = response.asJsoup()
put("ago", -1) for (element in document.select(popularMangaSelector())) {
Manga.create(id).apply {
popularMangaFromElement(element, this)
page.mangas.add(this)
}
}
page.nextPageUrl = page.url
} }
override fun popularMangaInitialUrl() = "$baseUrl/search_result.php?Action=Yes&order=popularity&numResultPerPage=20&sort=desc"
override fun popularMangaSelector() = "div.well > table > tbody > tr"
override fun popularMangaFromElement(element: Element, manga: Manga) { 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, filters: List<Filter>) = override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/advanced-search/result.php?sortBy=alphabet&direction=ASC&textOnly=no&resPerPage=20&page=1&seriesName=$query&${filters.map { it.id + "=Yes" }.joinToString("&")}" "$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 > 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)
val relation = m.group(3)
return Calendar.getInstance().apply {
add(dateFields[unit]!!, dateRelationFields[relation]!! * amount)
}.time.time
} else {
return 0
}
} }
override fun pageListParse(response: Response, pages: MutableList<Page>) { 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)
} }
@ -128,7 +166,7 @@ class Mangasee(override val id: Int) : ParsedOnlineSource() {
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') // [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
// http://mangasee.co/advanced-search/ // http://mangasee.co/advanced-search/
@ -171,20 +209,45 @@ class Mangasee(override val id: Int) : ParsedOnlineSource() {
Filter("Yuri", "Yuri") Filter("Yuri", "Yuri")
) )
override fun latestUpdatesInitialUrl(): String { override fun latestUpdatesInitialUrl(): String = "http://mangaseeonline.net/home/latest.request.php"
throw UnsupportedOperationException("not implemented")
// 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 latestUpdatesNextPageSelector(): String { override fun latestUpdatesParse(response: Response, page: MangasPage) {
throw UnsupportedOperationException("not implemented") 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) { override fun latestUpdatesFromElement(element: Element, manga: Manga) {
throw UnsupportedOperationException("not implemented") 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
}
} }
override fun latestUpdatesSelector(): String { }
throw UnsupportedOperationException("not implemented")
}
}

View File

@ -25,7 +25,7 @@ class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
override val lang: Language get() = EN override val lang: Language get() = EN
override val supportsLatest = false override val supportsLatest = true
override val client: OkHttpClient get() = network.cloudflareClient override val client: OkHttpClient get() = network.cloudflareClient
@ -39,8 +39,12 @@ class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
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"))
@ -48,8 +52,14 @@ class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
} }
} }
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)" override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
override fun latestUpdatesNextPageSelector(): String = "div.hot-manga > ul.pagination > li > a:contains(»)"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/service/advanced_search" "$baseUrl/service/advanced_search"
@ -183,21 +193,4 @@ class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
Filter("36", "Yaoi"), Filter("36", "Yaoi"),
Filter("37", "Yuri") Filter("37", "Yuri")
) )
override fun latestUpdatesInitialUrl(): String {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesNextPageSelector(): String {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
throw UnsupportedOperationException("not implemented")
}
override fun latestUpdatesSelector(): String {
throw UnsupportedOperationException("not implemented")
}
} }

View File

@ -1,12 +1,13 @@
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
@ -25,13 +26,25 @@ class Mangachan(override val id: Int) : ParsedOnlineSource() {
override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites" override fun popularMangaInitialUrl() = "$baseUrl/mostfavorites"
override fun latestUpdatesInitialUrl() = "$baseUrl/manga/new" override fun latestUpdatesInitialUrl() = "$baseUrl/newestch"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/?do=search&subaction=search&story=$query" 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() = "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 {
@ -41,20 +54,48 @@ class Mangachan(override val id: Int) : ParsedOnlineSource() {
} }
override fun latestUpdatesFromElement(element: Element, manga: Manga) { override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga) element.select("a:nth-child(1)").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
} }
override fun popularMangaNextPageSelector() = "a:contains(Вперед)"
override fun latestUpdatesNextPageSelector() = "a:contains(Вперед)"
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() 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()
@ -95,13 +136,74 @@ class Mangachan(override val id: Int) : ParsedOnlineSource() {
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("ёнкома", "ёнкома")
)
} }

View File

@ -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
@ -90,7 +89,7 @@ class Mintmanga(override val id: Int) : ParsedOnlineSource() {
} ?: 0 } ?: 0
} }
override fun parseChapterNumber(chapter: Chapter) { override fun prepareNewChapter(chapter: Chapter, manga: Manga) {
chapter.chapter_number = -2f chapter.chapter_number = -2f
} }
@ -115,9 +114,9 @@ class Mintmanga(override val id: Int) : ParsedOnlineSource() {
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) => { /* [...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); * const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n') * return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
* on http://mintmanga.com/search * on http://mintmanga.com/search
*/ */
override fun getFilterList(): List<Filter> = listOf( override fun getFilterList(): List<Filter> = listOf(
Filter("el_2220", "арт"), Filter("el_2220", "арт"),
@ -163,5 +162,4 @@ class Mintmanga(override val id: Int) : ParsedOnlineSource() {
Filter("el_1315", "юри"), Filter("el_1315", "юри"),
Filter("el_1336", "яой") Filter("el_1336", "яой")
) )
} }

View File

@ -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
@ -53,10 +52,7 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
override fun searchMangaSelector() = popularMangaSelector() override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element, manga: Manga) { override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("h3 > a").first().let { popularMangaFromElement(element, manga)
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
} }
// max 200 results // max 200 results
@ -93,7 +89,7 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
} ?: 0 } ?: 0
} }
override fun parseChapterNumber(chapter: Chapter) { override fun prepareNewChapter(chapter: Chapter, manga: Manga) {
chapter.chapter_number = -2f chapter.chapter_number = -2f
} }
@ -118,9 +114,9 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
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) => { /* [...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); * const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n') * return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
* on http://readmanga.me/search * on http://readmanga.me/search
*/ */
override fun getFilterList(): List<Filter> = listOf( override fun getFilterList(): List<Filter> = listOf(
Filter("el_5685", "арт"), Filter("el_5685", "арт"),
@ -136,6 +132,7 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
Filter("el_2118", "драма"), Filter("el_2118", "драма"),
Filter("el_2154", "игра"), Filter("el_2154", "игра"),
Filter("el_2119", "история"), Filter("el_2119", "история"),
Filter("el_8032", "киберпанк"),
Filter("el_2137", "кодомо"), Filter("el_2137", "кодомо"),
Filter("el_2136", "комедия"), Filter("el_2136", "комедия"),
Filter("el_2147", "махо-сёдзё"), Filter("el_2147", "махо-сёдзё"),
@ -164,5 +161,4 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
Filter("el_2149", "этти"), Filter("el_2149", "этти"),
Filter("el_2123", "юри") Filter("el_2123", "юри")
) )
} }

View File

@ -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)
}
}
}

View File

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

View File

@ -106,15 +106,18 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
throw Exception("Unsuccessful response") throw Exception("Unsuccessful response")
} }
val installIntent = UpdateNotificationReceiver.installApkIntent(ctx, apkFile.absolutePath)
// Prompt the user to install the new update. // Prompt the user to install the new update.
NotificationCompat.Builder(this).update { NotificationCompat.Builder(this).update {
setContentTitle(getString(R.string.app_name)) setContentTitle(getString(R.string.app_name))
setContentText(getString(R.string.update_check_notification_download_complete)) setContentText(getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done) setSmallIcon(android.R.drawable.stat_sys_download_done)
// Install action // Install action
setContentIntent(installIntent)
addAction(R.drawable.ic_system_update_grey_24dp_img, addAction(R.drawable.ic_system_update_grey_24dp_img,
getString(R.string.action_install), getString(R.string.action_install),
UpdateNotificationReceiver.installApkIntent(ctx, apkFile.absolutePath)) installIntent)
// Cancel action // Cancel action
addAction(R.drawable.ic_clear_grey_24dp_img, addAction(R.drawable.ic_clear_grey_24dp_img,
getString(R.string.action_cancel), getString(R.string.action_cancel),

View File

@ -1,9 +1,27 @@
package eu.kanade.tachiyomi.ui.base.activity package eu.kanade.tachiyomi.ui.base.activity
import android.support.v7.app.AppCompatActivity import android.support.v7.app.AppCompatActivity
import eu.kanade.tachiyomi.util.LocaleHelper
abstract class BaseActivity : AppCompatActivity(), ActivityMixin { abstract class BaseActivity : AppCompatActivity(), ActivityMixin {
init {
LocaleHelper.updateCfg(this)
}
override fun getActivity() = this override fun getActivity() = this
var resumed = false
private set
override fun onResume() {
super.onResume()
resumed = true
}
override fun onPause() {
resumed = false
super.onPause()
}
} }

View File

@ -3,10 +3,15 @@ package eu.kanade.tachiyomi.ui.base.activity
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.App import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.LocaleHelper
import nucleus.view.NucleusAppCompatActivity import nucleus.view.NucleusAppCompatActivity
abstract class BaseRxActivity<P : BasePresenter<*>> : NucleusAppCompatActivity<P>(), ActivityMixin { abstract class BaseRxActivity<P : BasePresenter<*>> : NucleusAppCompatActivity<P>(), ActivityMixin {
init {
LocaleHelper.updateCfg(this)
}
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
val superFactory = presenterFactory val superFactory = presenterFactory
setPresenterFactory { setPresenterFactory {
@ -20,4 +25,17 @@ abstract class BaseRxActivity<P : BasePresenter<*>> : NucleusAppCompatActivity<P
override fun getActivity() = this override fun getActivity() = this
var resumed = false
private set
override fun onResume() {
super.onResume()
resumed = true
}
override fun onPause() {
resumed = false
super.onPause()
}
} }

View File

@ -3,10 +3,7 @@ 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.design.widget.Snackbar import android.support.design.widget.Snackbar
import android.support.v7.widget.GridLayoutManager import android.support.v7.widget.*
import android.support.v7.widget.LinearLayoutManager
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.ArrayAdapter import android.widget.ArrayAdapter
@ -21,10 +18,8 @@ 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 eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.fragment_catalogue.* import kotlinx.android.synthetic.main.fragment_catalogue.*
@ -149,9 +144,7 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleVie
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( catalogue_list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
DividerItemDecoration(context.theme.getResourceDrawable(R.attr.divider_drawable)))
if (presenter.isListMode) { if (presenter.isListMode) {
switcher.showNext() switcher.showNext()
} }
@ -228,6 +221,7 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleVie
// Setup filters button // Setup filters button
menu.findItem(R.id.action_set_filter).apply { menu.findItem(R.id.action_set_filter).apply {
icon.mutate()
if (presenter.source.filters.isEmpty()) { if (presenter.source.filters.isEmpty()) {
isEnabled = false isEnabled = false
icon.alpha = 128 icon.alpha = 128

View File

@ -21,7 +21,7 @@ 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 import java.util.*
/** /**
* Presenter of [CatalogueFragment]. * Presenter of [CatalogueFragment].

View File

@ -27,7 +27,9 @@ import nucleus.factory.RequiresPresenter
* UI related actions should be called from here. * UI related actions should be called from here.
*/ */
@RequiresPresenter(CategoryPresenter::class) @RequiresPresenter(CategoryPresenter::class)
class CategoryActivity : BaseRxActivity<CategoryPresenter>(), ActionMode.Callback, FlexibleViewHolder.OnListItemClickListener, OnStartDragListener { class CategoryActivity :
BaseRxActivity<CategoryPresenter>(),
ActionMode.Callback, FlexibleViewHolder.OnListItemClickListener, OnStartDragListener {
/** /**
* Object used to show actionMode toolbar. * Object used to show actionMode toolbar.

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.category package eu.kanade.tachiyomi.ui.category
import android.view.ViewGroup import android.view.ViewGroup
import com.amulyakhare.textdrawable.util.ColorGenerator
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
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
@ -17,18 +16,10 @@ import java.util.*
* @param activity activity that created adapter * @param activity activity that created adapter
* @constructor Creates a CategoryAdapter object * @constructor Creates a CategoryAdapter object
*/ */
class CategoryAdapter(private val activity: CategoryActivity) : FlexibleAdapter<CategoryHolder, Category>(), ItemTouchHelperAdapter { class CategoryAdapter(private val activity: CategoryActivity) :
FlexibleAdapter<CategoryHolder, Category>(), ItemTouchHelperAdapter {
/**
* Generator used to generate circle letter icons
*/
private val generator: ColorGenerator
init { init {
// Let generator use Material Design colors.
// Material design is love, material design is live!
generator = ColorGenerator.MATERIAL
// Set unique id's // Set unique id's
setHasStableIds(true) setHasStableIds(true)
} }
@ -54,7 +45,7 @@ class CategoryAdapter(private val activity: CategoryActivity) : FlexibleAdapter<
override fun onBindViewHolder(holder: CategoryHolder, position: Int) { override fun onBindViewHolder(holder: CategoryHolder, position: Int) {
// Update holder values. // Update holder values.
val category = getItem(position) val category = getItem(position)
holder.onSetValues(category, generator) holder.onSetValues(category)
//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)

View File

@ -24,7 +24,12 @@ import kotlinx.android.synthetic.main.item_edit_categories.view.*
* *
* @constructor Create CategoryHolder object * @constructor Create CategoryHolder object
*/ */
class CategoryHolder(view: View, adapter: CategoryAdapter, listener: FlexibleViewHolder.OnListItemClickListener, dragListener: OnStartDragListener) : FlexibleViewHolder(view, adapter, listener) { class CategoryHolder(
view: View,
adapter: CategoryAdapter,
listener: FlexibleViewHolder.OnListItemClickListener,
dragListener: OnStartDragListener
) : FlexibleViewHolder(view, adapter, listener) {
init { init {
// Create round letter image onclick to simulate long click // Create round letter image onclick to simulate long click
@ -46,29 +51,31 @@ class CategoryHolder(view: View, adapter: CategoryAdapter, listener: FlexibleVie
* Update category item values. * Update category item values.
* *
* @param category category of item. * @param category category of item.
* @param generator generator used to generate circle letter icons.
*/ */
fun onSetValues(category: Category, generator: ColorGenerator) { fun onSetValues(category: Category) {
// Set capitalized title. // Set capitalized title.
itemView.title.text = category.name.capitalize() itemView.title.text = category.name.capitalize()
// Update circle letter image. // Update circle letter image.
itemView.image.setImageDrawable(getRound(category.name.substring(0, 1).toUpperCase(), generator)) itemView.post {
itemView.image.setImageDrawable(getRound(category.name.take(1).toUpperCase()))
}
} }
/** /**
* Returns circle letter image * Returns circle letter image
* *
* @param text first letter of string * @param text first letter of string
* @param generator the generator used to generate circle letter image
*/ */
private fun getRound(text: String, generator: ColorGenerator): TextDrawable { private fun getRound(text: String): TextDrawable {
val size = Math.min(itemView.image.width, itemView.image.height)
return TextDrawable.builder() return TextDrawable.builder()
.beginConfig() .beginConfig()
.width(size)
.height(size)
.textColor(Color.WHITE) .textColor(Color.WHITE)
.useFont(Typeface.DEFAULT) .useFont(Typeface.DEFAULT)
.toUpperCase()
.endConfig() .endConfig()
.buildRound(text, generator.getColor(text)) .buildRound(text, ColorGenerator.MATERIAL.getColor(text))
} }
} }

View File

@ -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 db.getCategories().asRxObservable()
restartableLatestCache(GET_CATEGORIES, .doOnNext { categories = it }
{ .observeOn(AndroidSchedulers.mainThread())
db.getCategories().asRxObservable() .subscribeLatestCache(CategoryActivity::setCategories)
.doOnNext { categories -> this.categories = categories }
.observeOn(AndroidSchedulers.mainThread())
}, 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()
} }

View File

@ -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)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Set start button visibility. // Set start button visibility.
startButton = menu.findItem(R.id.start_queue).apply { menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
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.
* *

View File

@ -48,8 +48,9 @@ class DownloadHolder(private val view: View) : RecyclerView.ViewHolder(view) {
* Updates the progress bar of the download. * Updates the progress bar of the download.
*/ */
fun notifyProgress() { fun notifyProgress() {
val pages = download.pages ?: return
if (view.download_progress.max == 1) { if (view.download_progress.max == 1) {
view.download_progress.max = download.pages!!.size * 100 view.download_progress.max = pages.size * 100
} }
view.download_progress.progress = download.totalProgress view.download_progress.progress = download.totalProgress
} }
@ -58,7 +59,8 @@ class DownloadHolder(private val view: View) : RecyclerView.ViewHolder(view) {
* Updates the text field of the number of downloaded pages. * Updates the text field of the number of downloaded pages.
*/ */
fun notifyDownloadedPages() { fun notifyDownloadedPages() {
view.download_progress_text.text = "${download.downloadedImages}/${download.pages!!.size}" val pages = download.pages ?: return
view.download_progress_text.text = "${download.downloadedImages}/${pages.size}"
} }
} }

View File

@ -29,36 +29,21 @@ 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) } .observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, downloads -> .map { ArrayList(it) }
view.onNextDownloads(downloads) .subscribeLatestCache(DownloadFragment::onNextDownloads, { view, error ->
}, { view, error ->
Timber.e(error) Timber.e(error)
}) })
} }
private fun syncQueue(queue: MutableList<Download>) { fun getDownloadStatusObservable(): Observable<Download> {
add(downloadQueue.getRemovedObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { download ->
val position = queue.indexOf(download)
if (position != -1) {
queue.removeAt(position)
@Suppress("DEPRECATION")
view?.onDownloadRemoved(position)
}
})
}
fun getStatusObservable(): 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()
} }

View File

@ -23,7 +23,7 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
/** /**
* The list of manga in this category. * The list of manga in this category.
*/ */
private var mangas: List<Manga>? = null private var mangas: List<Manga> = emptyList()
init { init {
setHasStableIds(true) setHasStableIds(true)
@ -37,7 +37,7 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
fun setItems(list: List<Manga>) { fun setItems(list: List<Manga>) {
mItems = list mItems = list
// A copy of manga that it's always unfiltered // A copy of manga always unfiltered.
mangas = ArrayList(list) mangas = ArrayList(list)
updateDataSet(null) updateDataSet(null)
} }
@ -58,10 +58,8 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
* @param param the filter. Not used. * @param param the filter. Not used.
*/ */
override fun updateDataSet(param: String?) { override fun updateDataSet(param: String?) {
mangas?.let { filterItems(mangas)
filterItems(it) notifyDataSetChanged()
notifyDataSetChanged()
}
} }
/** /**

View File

@ -3,9 +3,12 @@ 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.content.res.Configuration
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.support.design.widget.TabLayout import android.support.design.widget.TabLayout
import android.support.v4.graphics.drawable.DrawableCompat
import android.support.v4.view.ViewPager import android.support.v4.view.ViewPager
import android.support.v4.widget.DrawerLayout
import android.support.v7.view.ActionMode import android.support.v7.view.ActionMode
import android.support.v7.widget.SearchView import android.support.v7.widget.SearchView
import android.view.* import android.view.*
@ -20,6 +23,7 @@ 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
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.toast 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.*
@ -73,22 +77,36 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
*/ */
private var selectedCoverManga: Manga? = null private var selectedCoverManga: Manga? = null
/**
* Status of isFilterDownloaded
*/
var isFilterDownloaded = false
/**
* Status of isFilterUnread
*/
var isFilterUnread = false
/** /**
* Number of manga per row in grid mode. * Number of manga per row in grid mode.
*/ */
var mangaPerRow = 0 var mangaPerRow = 0
private set private set
/**
* Navigation view containing filter/sort/display items.
*/
private lateinit var navView: LibraryNavigationView
/**
* Drawer listener to allow swipe only for closing the drawer.
*/
private val drawerListener by lazy {
object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerClosed(drawerView: View) {
if (drawerView == navView) {
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
}
}
override fun onDrawerOpened(drawerView: View) {
if (drawerView == navView) {
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, navView)
}
}
}
}
/** /**
* Subscription for the number of manga per row. * Subscription for the number of manga per row.
*/ */
@ -123,8 +141,6 @@ 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 = preferences.filterDownloaded().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? {
@ -146,7 +162,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
if (savedState != null) { if (savedState != null) {
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.call(query)
if (presenter.selectedMangas.isNotEmpty()) { if (presenter.selectedMangas.isNotEmpty()) {
createActionModeIfNeeded() createActionModeIfNeeded()
} }
@ -159,6 +175,25 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
.skip(1) .skip(1)
// Set again the adapter to recalculate the covers height // Set again the adapter to recalculate the covers height
.subscribe { reattachAdapter() } .subscribe { reattachAdapter() }
// Inflate and prepare drawer
navView = activity.drawer.inflate(R.layout.library_drawer) as LibraryNavigationView
activity.drawer.addView(navView)
activity.drawer.addDrawerListener(drawerListener)
navView.post {
if (isAdded && !activity.drawer.isDrawerOpen(navView))
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
}
navView.onGroupClicked = { group ->
when (group) {
is LibraryNavigationView.FilterGroup -> onFilterChanged()
is LibraryNavigationView.SortGroup -> onSortChanged()
is LibraryNavigationView.DisplayGroup -> reattachAdapter()
}
}
} }
override fun onResume() { override fun onResume() {
@ -167,6 +202,8 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
} }
override fun onDestroyView() { override fun onDestroyView() {
activity.drawer.removeDrawerListener(drawerListener)
activity.drawer.removeView(navView)
numColumnsSubscription?.unsubscribe() numColumnsSubscription?.unsubscribe()
tabs.setupWithViewPager(null) tabs.setupWithViewPager(null)
tabs.visibility = View.GONE tabs.visibility = View.GONE
@ -182,9 +219,6 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.library, menu) inflater.inflate(R.menu.library, menu)
// Initialize search menu
val filterDownloadedItem = menu.findItem(R.id.action_filter_downloaded)
val filterUnreadItem = menu.findItem(R.id.action_filter_unread)
val searchItem = menu.findItem(R.id.action_search) val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView val searchView = searchItem.actionView as SearchView
@ -194,8 +228,8 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
searchView.clearFocus() searchView.clearFocus()
} }
filterDownloadedItem.isChecked = isFilterDownloaded // Mutate the filter icon because it needs to be tinted and the resource is shared.
filterUnreadItem.isChecked = isFilterUnread menu.findItem(R.id.action_filter).icon.mutate()
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean { override fun onQueryTextSubmit(query: String): Boolean {
@ -211,35 +245,19 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
} }
override fun onPrepareOptionsMenu(menu: Menu) {
val filterItem = menu.findItem(R.id.action_filter)
// Tint icon if there's a filter active
val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE
DrawableCompat.setTint(filterItem.icon, filterColor)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_filter_unread -> { R.id.action_filter -> {
// Change unread filter status. activity.drawer.openDrawer(Gravity.END)
isFilterUnread = !isFilterUnread
// Update settings.
preferences.filterUnread().set(isFilterUnread)
// Apply filter.
onFilterCheckboxChanged()
} }
R.id.action_filter_downloaded -> {
// Change downloaded filter status.
isFilterDownloaded = !isFilterDownloaded
// Update settings.
preferences.filterDownloaded().set(isFilterDownloaded)
// Apply filter.
onFilterCheckboxChanged()
}
R.id.action_filter_empty -> {
// Remove filter status.
isFilterUnread = false
isFilterDownloaded = false
// Update settings.
preferences.filterUnread().set(isFilterUnread)
preferences.filterDownloaded().set(isFilterDownloaded)
// Apply filter
onFilterCheckboxChanged()
}
R.id.action_library_display_mode -> swapDisplayMode()
R.id.action_update_library -> { R.id.action_update_library -> {
LibraryUpdateService.start(activity) LibraryUpdateService.start(activity)
} }
@ -254,19 +272,18 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
} }
/** /**
* Applies filter change * Called when a filter is changed.
*/ */
private fun onFilterCheckboxChanged() { private fun onFilterChanged() {
presenter.resubscribeLibrary() presenter.requestFilterUpdate()
activity.supportInvalidateOptionsMenu() activity.supportInvalidateOptionsMenu()
} }
/** /**
* Swap display mode * Called when the sorting mode is changed.
*/ */
private fun swapDisplayMode() { private fun onSortChanged() {
presenter.swapDisplayMode() presenter.requestSortUpdate()
reattachAdapter()
} }
/** /**
@ -302,7 +319,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
// Notify the subject the query has changed. // Notify the subject the query has changed.
if (isResumed) { if (isResumed) {
presenter.searchSubject.onNext(query) presenter.searchSubject.call(query)
} }
} }
@ -330,7 +347,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
view_pager.post { if (isAdded) tabs.setScrollPosition(view_pager.currentItem, 0f, true) } view_pager.post { if (isAdded) tabs.setScrollPosition(view_pager.currentItem, 0f, true) }
// Send the manga map to child fragments after the adapter is updated. // Send the manga map to child fragments after the adapter is updated.
presenter.libraryMangaSubject.onNext(LibraryMangaEvent(mangaMap)) presenter.libraryMangaSubject.call(LibraryMangaEvent(mangaMap))
} }
/** /**

View File

@ -0,0 +1,191 @@
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.util.AttributeSet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE
import uy.kohesive.injekt.injectLazy
/**
* The navigation view shown in a drawer with the different options to show the library.
*/
class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: ExtendedNavigationView(context, attrs) {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* List of groups shown in the view.
*/
private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup())
/**
* Adapter instance.
*/
private val adapter = Adapter(groups.map { it.createItems() }.flatten())
/**
* Click listener to notify the parent fragment when an item from a group is clicked.
*/
var onGroupClicked: (Group) -> Unit = {}
init {
recycler.adapter = adapter
groups.forEach { it.initModels() }
}
/**
* Returns true if there's at least one filter from [FilterGroup] active.
*/
fun hasActiveFilters(): Boolean {
return (groups[0] as FilterGroup).items.any { it.checked }
}
/**
* Adapter of the recycler view.
*/
inner class Adapter(items: List<Item>) : ExtendedNavigationView.Adapter(items) {
override fun onItemClicked(item: Item) {
if (item is GroupedItem) {
item.group.onItemClicked(item)
onGroupClicked(item.group)
}
}
}
/**
* Filters group (unread, downloaded, ...).
*/
inner class FilterGroup : Group {
private val downloaded = Item.CheckboxGroup(R.string.action_filter_downloaded, this)
private val unread = Item.CheckboxGroup(R.string.action_filter_unread, this)
override val items = listOf(downloaded, unread)
override val header = Item.Header(R.string.action_filter)
override val footer = Item.Separator()
override fun initModels() {
downloaded.checked = preferences.filterDownloaded().getOrDefault()
unread.checked = preferences.filterUnread().getOrDefault()
}
override fun onItemClicked(item: Item) {
item as Item.CheckboxGroup
item.checked = !item.checked
when (item) {
downloaded -> preferences.filterDownloaded().set(item.checked)
unread -> preferences.filterUnread().set(item.checked)
}
adapter.notifyItemChanged(item)
}
}
/**
* Sorting group (alphabetically, by last read, ...) and ascending or descending.
*/
inner class SortGroup : Group {
private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this)
private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this)
private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this)
private val unread = Item.MultiSort(R.string.action_filter_unread, this)
override val items = listOf(alphabetically, lastRead, lastUpdated, unread)
override val header = Item.Header(R.string.action_sort)
override val footer = Item.Separator()
override fun initModels() {
val sorting = preferences.librarySortingMode().getOrDefault()
val order = if (preferences.librarySortingAscending().getOrDefault())
SORT_ASC else SORT_DESC
alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE
lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE
lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE
unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE
}
override fun onItemClicked(item: Item) {
item as Item.MultiStateGroup
val prevState = item.state
item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE }
item.state = when (prevState) {
SORT_NONE -> SORT_ASC
SORT_ASC -> SORT_DESC
SORT_DESC -> SORT_ASC
else -> throw Exception("Unknown state")
}
preferences.librarySortingMode().set(when (item) {
alphabetically -> LibrarySort.ALPHA
lastRead -> LibrarySort.LAST_READ
lastUpdated -> LibrarySort.LAST_UPDATED
unread -> LibrarySort.UNREAD
else -> throw Exception("Unknown sorting")
})
preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false)
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
/**
* Display group, to show the library as a list or a grid.
*/
inner class DisplayGroup : Group {
private val grid = Item.Radio(R.string.action_display_grid, this)
private val list = Item.Radio(R.string.action_display_list, this)
override val items = listOf(grid, list)
override val header = Item.Header(R.string.action_display)
override val footer = null
override fun initModels() {
val asList = preferences.libraryAsList().getOrDefault()
grid.checked = !asList
list.checked = asList
}
override fun onItemClicked(item: Item) {
item as Item.Radio
if (item.checked) return
item.group.items.forEach { (it as Item.Radio).checked = false }
item.checked = true
preferences.libraryAsList().set(if (item == list) true else false)
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
}

View File

@ -2,6 +2,9 @@ package eu.kanade.tachiyomi.ui.library
import android.os.Bundle import android.os.Bundle
import android.util.Pair import android.util.Pair
import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
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
@ -12,11 +15,12 @@ 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.source.SourceManager import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.combineLatest
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
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.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
@ -27,6 +31,31 @@ import java.util.*
*/ */
class LibraryPresenter : BasePresenter<LibraryFragment>() { class LibraryPresenter : BasePresenter<LibraryFragment>() {
/**
* Database.
*/
private val db: DatabaseHelper by injectLazy()
/**
* Preferences.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* Cover cache.
*/
private val coverCache: CoverCache by injectLazy()
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Download manager.
*/
private val downloadManager: DownloadManager by injectLazy()
/** /**
* Categories of the library. * Categories of the library.
*/ */
@ -40,61 +69,139 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
/** /**
* Search query of the library. * Search query of the library.
*/ */
val searchSubject: BehaviorSubject<String> = BehaviorSubject.create() val searchSubject: BehaviorRelay<String> = BehaviorRelay.create()
/** /**
* Subject to notify the library's viewpager for updates. * Subject to notify the library's viewpager for updates.
*/ */
val libraryMangaSubject: BehaviorSubject<LibraryMangaEvent> = BehaviorSubject.create() val libraryMangaSubject: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
/** /**
* Subject to notify the UI of selection updates. * Subject to notify the UI of selection updates.
*/ */
val selectionSubject: PublishSubject<LibrarySelectionEvent> = PublishSubject.create() val selectionSubject: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
/** /**
* Database. * Relay used to apply the UI filters to the last emission of the library.
*/ */
val db: DatabaseHelper by injectLazy() private val filterTriggerRelay = BehaviorRelay.create(Unit)
/** /**
* Preferences. * Relay used to apply the selected sorting method to the last emission of the library.
*/ */
val preferences: PreferencesHelper by injectLazy() private val sortTriggerRelay = BehaviorRelay.create(Unit)
/** /**
* Cover cache. * Library subscription.
*/ */
val coverCache: CoverCache by injectLazy() private var librarySubscription: Subscription? = null
/**
* Source manager.
*/
val sourceManager: SourceManager by injectLazy()
/**
* Download manager.
*/
val downloadManager: DownloadManager by injectLazy()
companion object {
/**
* Id of the restartable that listens for library updates.
*/
const val GET_LIBRARY = 1
}
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
subscribeLibrary()
}
restartableLatestCache(GET_LIBRARY, /**
{ getLibraryObservable() }, * Subscribes to library if needed.
{ view, pair -> view.onNextLibraryUpdate(pair.first, pair.second) }) */
fun subscribeLibrary() {
if (librarySubscription.isNullOrUnsubscribed()) {
librarySubscription = getLibraryObservable()
.combineLatest(filterTriggerRelay.observeOn(Schedulers.io()),
{ lib, tick -> Pair(lib.first, applyFilters(lib.second)) })
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io()),
{ lib, tick -> Pair(lib.first, applySort(lib.second)) })
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, pair ->
view.onNextLibraryUpdate(pair.first, pair.second)
})
}
}
if (savedState == null) { /**
start(GET_LIBRARY) * Applies library filters to the given map of manga.
*
* @param map the map to filter.
*/
private fun applyFilters(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
// Cached list of downloaded manga directories given a source id.
val mangaDirectories = mutableMapOf<Int, Array<UniFile>>()
// Cached list of downloaded chapter directories for a manga.
val chapterDirectories = mutableMapOf<Long, Boolean>()
val filterDownloaded = preferences.filterDownloaded().getOrDefault()
val filterUnread = preferences.filterUnread().getOrDefault()
val filterFn: (Manga) -> Boolean = f@ { manga ->
// Filter out manga without source.
val source = sourceManager.get(manga.source) ?: return@f false
// Filter when there isn't unread chapters.
if (filterUnread && manga.unread == 0) {
return@f false
}
// Filter when the download directory doesn't exist or is null.
if (filterDownloaded) {
val mangaDirs = mangaDirectories.getOrPut(source.id) {
downloadManager.findSourceDir(source)?.listFiles() ?: emptyArray()
}
val mangaDirName = downloadManager.getMangaDirName(manga)
val mangaDir = mangaDirs.find { it.name == mangaDirName } ?: return@f false
val hasDirs = chapterDirectories.getOrPut(manga.id!!) {
(mangaDir.listFiles() ?: emptyArray()).isNotEmpty()
}
if (!hasDirs) {
return@f false
}
}
true
} }
return map.mapValues { entry -> entry.value.filter(filterFn) }
}
/**
* Applies library sorting to the given map of manga.
*
* @param map the map to sort.
*/
private fun applySort(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
val sortingMode = preferences.librarySortingMode().getOrDefault()
// TODO lazy initialization in kotlin 1.1
var lastReadManga: Map<Long, Int>? = null
if (sortingMode == LibrarySort.LAST_READ) {
var counter = 0
lastReadManga = db.getLastReadManga().executeAsBlocking()
.associate { it.id!! to counter++ }
}
val sortFn: (Manga, Manga) -> Int = { manga1, manga2 ->
when (sortingMode) {
LibrarySort.ALPHA -> manga1.title.compareTo(manga2.title)
LibrarySort.LAST_READ -> {
// Get index of manga, set equal to list if size unknown.
val manga1LastRead = lastReadManga!![manga1.id!!] ?: lastReadManga!!.size
val manga2LastRead = lastReadManga!![manga2.id!!] ?: lastReadManga!!.size
manga1LastRead.compareTo(manga2LastRead)
}
LibrarySort.LAST_UPDATED -> manga2.last_update.compareTo(manga1.last_update)
LibrarySort.UNREAD -> manga1.unread.compareTo(manga2.unread)
else -> throw Exception("Unknown sorting mode")
}
}
val comparator = if (preferences.librarySortingAscending().getOrDefault())
Comparator(sortFn)
else
Collections.reverseOrder(sortFn)
return map.mapValues { entry -> entry.value.sortedWith(comparator) }
} }
/** /**
@ -102,7 +209,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
* *
* @return an observable of the categories and its manga. * @return an observable of the categories and its manga.
*/ */
fun getLibraryObservable(): Observable<Pair<List<Category>, Map<Int, List<Manga>>>> { private fun getLibraryObservable(): Observable<Pair<List<Category>, Map<Int, List<Manga>>>> {
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(), return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
{ dbCategories, libraryManga -> { dbCategories, libraryManga ->
val categories = if (libraryManga.containsKey(0)) val categories = if (libraryManga.containsKey(0))
@ -113,7 +220,6 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
this.categories = categories this.categories = categories
Pair(categories, libraryManga) Pair(categories, libraryManga)
}) })
.observeOn(AndroidSchedulers.mainThread())
} }
/** /**
@ -121,7 +227,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
* *
* @return an observable of the categories. * @return an observable of the categories.
*/ */
fun getCategoriesObservable(): Observable<List<Category>> { private fun getCategoriesObservable(): Observable<List<Category>> {
return db.getCategories().asRxObservable() return db.getCategories().asRxObservable()
} }
@ -131,81 +237,23 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
* @return an observable containing a map with the category id as key and a list of manga as the * @return an observable containing a map with the category id as key and a list of manga as the
* value. * value.
*/ */
fun getLibraryMangasObservable(): Observable<Map<Int, List<Manga>>> { private fun getLibraryMangasObservable(): Observable<Map<Int, List<Manga>>> {
return db.getLibraryMangas().asRxObservable() return db.getLibraryMangas().asRxObservable()
.flatMap { mangas -> .map { list -> list.groupBy { it.category } }
Observable.from(mangas)
// Filter library by options
.filter { filterManga(it) }
.groupBy { it.category }
.flatMap { group -> group.toList().map { Pair(group.key, it) } }
.toMap({ it.first }, { it.second })
}
} }
/** /**
* Resubscribes to library if needed. * Requests the library to be filtered.
*/ */
fun subscribeLibrary() { fun requestFilterUpdate() {
if (isUnsubscribed(GET_LIBRARY)) { filterTriggerRelay.call(Unit)
start(GET_LIBRARY)
}
} }
/** /**
* Resubscribes to library. * Requests the library to be sorted.
*/ */
fun resubscribeLibrary() { fun requestSortUpdate() {
start(GET_LIBRARY) sortTriggerRelay.call(Unit)
}
/**
* Filters an entry of the library.
*
* @param manga a favorite manga from the database.
* @return true if the entry is included, false otherwise.
*/
fun filterManga(manga: Manga): Boolean {
// Filter out manga without source
val source = sourceManager.get(manga.source) ?: return false
val prefFilterDownloaded = preferences.filterDownloaded().getOrDefault()
val prefFilterUnread = preferences.filterUnread().getOrDefault()
// Check if filter option is selected
if (prefFilterDownloaded || prefFilterUnread) {
// Does it have downloaded chapters.
var hasDownloaded = false
var hasUnread = false
if (prefFilterUnread) {
// Does it have unread chapters.
hasUnread = manga.unread > 0
}
if (prefFilterDownloaded) {
val mangaDir = downloadManager.getAbsoluteMangaDirectory(source, manga)
if (mangaDir.exists()) {
for (file in mangaDir.listFiles()) {
if (file.isDirectory && file.listFiles().isNotEmpty()) {
hasDownloaded = true
break
}
}
}
}
// Return correct filter status
if (prefFilterDownloaded && prefFilterUnread) {
return (hasDownloaded && hasUnread)
} else {
return (hasDownloaded || hasUnread)
}
} else {
return true
}
} }
/** /**
@ -213,7 +261,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
*/ */
fun onOpenManga() { fun onOpenManga() {
// Avoid further db updates for the library when it's not needed // Avoid further db updates for the library when it's not needed
stop(GET_LIBRARY) librarySubscription?.let { remove(it) }
} }
/** /**
@ -225,10 +273,10 @@ 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)) selectionSubject.call(LibrarySelectionEvent.Selected(manga))
} else { } else {
selectedMangas.remove(manga) selectedMangas.remove(manga)
selectionSubject.onNext(LibrarySelectionEvent.Unselected(manga)) selectionSubject.call(LibrarySelectionEvent.Unselected(manga))
} }
} }
@ -237,7 +285,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
*/ */
fun clearSelections() { fun clearSelections() {
selectedMangas.clear() selectedMangas.clear()
selectionSubject.onNext(LibrarySelectionEvent.Cleared()) selectionSubject.call(LibrarySelectionEvent.Cleared())
} }
/** /**
@ -301,12 +349,4 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
return false return false
} }
/**
* Changes the active display mode.
*/
fun swapDisplayMode() {
val displayAsList = preferences.libraryAsList().getOrDefault()
preferences.libraryAsList().set(!displayAsList)
}
} }

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.ui.library
object LibrarySort {
const val ALPHA = 0
const val LAST_READ = 1
const val LAST_UPDATED = 2
const val UNREAD = 3
}

View File

@ -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()

View File

@ -11,8 +11,8 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.backup.BackupFragment 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.latest_updates.LatestUpdatesFragment
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
@ -79,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)
} }
} }
@ -94,7 +94,9 @@ class MainActivity : BaseActivity() {
override fun onBackPressed() { override fun onBackPressed() {
val fragment = supportFragmentManager.findFragmentById(R.id.frame_container) val fragment = supportFragmentManager.findFragmentById(R.id.frame_container)
if (fragment != null && fragment.tag.toInt() != startScreenId) { if (fragment != null && fragment.tag.toInt() != startScreenId) {
setSelectedDrawerItem(startScreenId) if (resumed) {
setSelectedDrawerItem(startScreenId)
}
} else { } else {
super.onBackPressed() super.onBackPressed()
} }
@ -110,6 +112,8 @@ class MainActivity : BaseActivity() {
} else if (resultCode and SettingsActivity.FLAG_THEME_CHANGED != 0) { } else if (resultCode and SettingsActivity.FLAG_THEME_CHANGED != 0) {
// Delay activity recreation to avoid fragment leaks. // Delay activity recreation to avoid fragment leaks.
nav_view.post { recreate() } nav_view.post { recreate() }
} else if (resultCode and SettingsActivity.FLAG_LANG_CHANGED != 0) {
nav_view.post { recreate() }
} }
} else { } else {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)

View File

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersFragment
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoFragment import eu.kanade.tachiyomi.ui.manga.info.MangaInfoFragment
import eu.kanade.tachiyomi.ui.manga.myanimelist.MyAnimeListFragment import eu.kanade.tachiyomi.ui.manga.myanimelist.MyAnimeListFragment
import eu.kanade.tachiyomi.util.SharedData import eu.kanade.tachiyomi.util.SharedData
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.activity_manga.* import kotlinx.android.synthetic.main.activity_manga.*
import kotlinx.android.synthetic.main.toolbar.* import kotlinx.android.synthetic.main.toolbar.*
import nucleus.factory.RequiresPresenter import nucleus.factory.RequiresPresenter
@ -50,12 +51,19 @@ class MangaActivity : BaseRxActivity<MangaPresenter>() {
val fromLauncher = intent.getBooleanExtra(FROM_LAUNCHER_EXTRA, false) val fromLauncher = intent.getBooleanExtra(FROM_LAUNCHER_EXTRA, false)
//Remove any current manga if we are launching from launcher // Remove any current manga if we are launching from launcher
if(fromLauncher) SharedData.remove(MangaEvent::class.java) 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()!!) val dbManga = presenter.db.getManga(id).executeAsBlocking()
if (dbManga != null) {
MangaEvent(dbManga)
} else {
toast(R.string.manga_not_in_db)
finish()
return
}
}) })
setupToolbar(toolbar) setupToolbar(toolbar)

View File

@ -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()
}
} }

View File

@ -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() }
@ -123,11 +121,13 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
val menuFilterRead = menu.findItem(R.id.action_filter_read) val menuFilterRead = menu.findItem(R.id.action_filter_read)
val menuFilterUnread = menu.findItem(R.id.action_filter_unread) val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded) val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
// Set correct checkbox values. // Set correct checkbox values.
menuFilterRead.isChecked = presenter.onlyRead() menuFilterRead.isChecked = presenter.onlyRead()
menuFilterUnread.isChecked = presenter.onlyUnread() menuFilterUnread.isChecked = presenter.onlyUnread()
menuFilterDownloaded.isChecked = presenter.onlyDownloaded() menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
if (presenter.onlyRead()) if (presenter.onlyRead())
//Disable unread filter option if read filter is enabled. //Disable unread filter option if read filter is enabled.
@ -156,6 +156,10 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
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()
@ -178,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() {
@ -263,7 +267,7 @@ 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 } .distinctBy { it.name }
.sortedByDescending { it.source_order } .sortedByDescending { it.source_order }
@ -356,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>) {
@ -364,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)
@ -381,7 +394,8 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
} }
fun dismissDeletingDialog() { fun dismissDeletingDialog() {
(childFragmentManager.findFragmentByTag(DeletingChaptersDialog.TAG) as? DialogFragment)?.dismiss() (childFragmentManager.findFragmentByTag(DeletingChaptersDialog.TAG) as? DialogFragment)
?.dismissAllowingStateLoss()
} }
override fun onListItemClick(position: Int): Boolean { override fun onListItemClick(position: Int): Boolean {

View File

@ -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)

View File

@ -132,6 +132,9 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
chapters.map { it.toModel() } chapters.map { it.toModel() }
} }
.doOnNext { chapters -> .doOnNext { chapters ->
// Find downloaded chapters
setDownloadedChapters(chapters)
// Store the last emission // Store the last emission
this.chapters = chapters this.chapters = chapters
@ -157,16 +160,25 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
if (download != null) { if (download != null) {
// If there's an active download, assign it. // If there's an active download, assign it.
model.download = download model.download = download
} else {
// Otherwise ask the manager if the chapter is downloaded and assign it to the status.
model.status = if (downloadManager.isChapterDownloaded(source, manga, this))
Download.DOWNLOADED
else
Download.NOT_DOWNLOADED
} }
return model return model
} }
/**
* Finds and assigns the list of downloaded chapters.
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<ChapterModel>) {
val files = downloadManager.findMangaDir(source, manga)?.listFiles() ?: return
val cached = mutableMapOf<Chapter, String>()
files.mapNotNull { it.name }
.mapNotNull { name -> chapters.find {
name == cached.getOrPut(it) { downloadManager.getChapterDirName(it) }
} }
.forEach { it.status = Download.DOWNLOADED }
}
/** /**
* Requests an updated list of chapters from the source. * Requests an updated list of chapters from the source.
*/ */
@ -200,7 +212,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/** /**
* Applies the view filters to the list of chapters obtained from the database. * Applies the view filters to the list of chapters obtained from the database.
*
* @param chapters the list of chapters from the database * @param chapters the list of chapters from the database
* @return an observable of the list of chapters filtered and sorted. * @return an observable of the list of chapters filtered and sorted.
*/ */
@ -215,6 +226,9 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
if (onlyDownloaded()) { if (onlyDownloaded()) {
observable = observable.filter { it.isDownloaded } observable = observable.filter { it.isDownloaded }
} }
if (onlyBookmarked()) {
observable = observable.filter { it.bookmark }
}
val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) { val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
Manga.SORTING_SOURCE -> when (sortDescending()) { Manga.SORTING_SOURCE -> when (sortDescending()) {
true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) } true -> { c1, c2 -> c1.source_order.compareTo(c2.source_order) }
@ -231,7 +245,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/** /**
* Called when a download for the active manga changes status. * Called when a download for the active manga changes status.
*
* @param download the download whose status changed. * @param download the download whose status changed.
*/ */
fun onDownloadStatusChange(download: Download) { fun onDownloadStatusChange(download: Download) {
@ -258,7 +271,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/** /**
* Mark the selected chapter list as read/unread. * Mark the selected chapter list as read/unread.
*
* @param selectedChapters the list of selected chapters. * @param selectedChapters the list of selected chapters.
* @param read whether to mark chapters as read or unread. * @param read whether to mark chapters as read or unread.
*/ */
@ -276,23 +288,8 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
.subscribe() .subscribe()
} }
/**
* Mark the previous chapters to the selected one as read.
*
* @param chapter the selected chapter.
*/
fun markPreviousChaptersAsRead(chapter: ChapterModel) {
Observable.from(chapters)
.filter { it.isRecognizedNumber && it.chapter_number < chapter.chapter_number }
.doOnNext { it.read = true }
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribe()
}
/** /**
* Downloads the given list of chapters with the manager. * Downloads the given list of chapters with the manager.
*
* @param chapters the list of chapters to download. * @param chapters the list of chapters to download.
*/ */
fun downloadChapters(chapters: List<ChapterModel>) { fun downloadChapters(chapters: List<ChapterModel>) {
@ -300,16 +297,26 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
downloadManager.downloadChapters(manga, chapters) downloadManager.downloadChapters(manga, chapters)
} }
/**
* Bookmarks the given list of chapters.
* @param selectedChapters the list of chapters to bookmark.
*/
fun bookmarkChapters(selectedChapters: List<ChapterModel>, bookmarked: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.bookmark = bookmarked
}
.toList()
.flatMap { db.updateChaptersProgress(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.subscribe()
}
/** /**
* Deletes the given list of chapter. * Deletes the given list of chapter.
*
* @param chapters the list of chapters to delete. * @param chapters the list of chapters to delete.
*/ */
fun deleteChapters(chapters: List<ChapterModel>) { fun deleteChapters(chapters: List<ChapterModel>) {
val wasRunning = downloadManager.isRunning
if (wasRunning) {
DownloadService.stop(context)
}
Observable.from(chapters) Observable.from(chapters)
.doOnNext { deleteChapter(it) } .doOnNext { deleteChapter(it) }
.toList() .toList()
@ -318,9 +325,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, result -> .subscribeFirst({ view, result ->
view.onChaptersDeleted() view.onChaptersDeleted()
if (wasRunning) {
DownloadService.start(context)
}
}, { view, error -> }, { view, error ->
view.onChaptersDeletedError(error) view.onChaptersDeletedError(error)
}) })
@ -328,11 +332,10 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/** /**
* Deletes a chapter from disk. This method is called in a background thread. * Deletes a chapter from disk. This method is called in a background thread.
*
* @param chapter the chapter to delete. * @param chapter the chapter to delete.
*/ */
private fun deleteChapter(chapter: ChapterModel) { private fun deleteChapter(chapter: ChapterModel) {
downloadManager.queue.del(chapter) downloadManager.queue.remove(chapter)
downloadManager.deleteChapter(source, manga, chapter) downloadManager.deleteChapter(source, manga, chapter)
chapter.status = Download.NOT_DOWNLOADED chapter.status = Download.NOT_DOWNLOADED
chapter.download = null chapter.download = null
@ -349,7 +352,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/** /**
* Sets the read filter and requests an UI update. * Sets the read filter and requests an UI update.
*
* @param onlyUnread whether to display only unread chapters or all chapters. * @param onlyUnread whether to display only unread chapters or all chapters.
*/ */
fun setUnreadFilter(onlyUnread: Boolean) { fun setUnreadFilter(onlyUnread: Boolean) {
@ -360,7 +362,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/** /**
* Sets the read filter and requests an UI update. * Sets the read filter and requests an UI update.
*
* @param onlyRead whether to display only read chapters or all chapters. * @param onlyRead whether to display only read chapters or all chapters.
*/ */
fun setReadFilter(onlyRead: Boolean) { fun setReadFilter(onlyRead: Boolean) {
@ -371,7 +372,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/** /**
* Sets the download filter and requests an UI update. * Sets the download filter and requests an UI update.
*
* @param onlyDownloaded whether to display only downloaded chapters or all chapters. * @param onlyDownloaded whether to display only downloaded chapters or all chapters.
*/ */
fun setDownloadedFilter(onlyDownloaded: Boolean) { fun setDownloadedFilter(onlyDownloaded: Boolean) {
@ -380,19 +380,29 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
refreshChapters() refreshChapters()
} }
/**
* Sets the bookmark filter and requests an UI update.
* @param onlyBookmarked whether to display only bookmarked chapters or all chapters.
*/
fun setBookmarkedFilter(onlyBookmarked: Boolean) {
manga.bookmarkedFilter = if (onlyBookmarked) Manga.SHOW_BOOKMARKED else Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/** /**
* Removes all filters and requests an UI update. * Removes all filters and requests an UI update.
*/ */
fun removeFilters() { fun removeFilters() {
manga.readFilter = Manga.SHOW_ALL manga.readFilter = Manga.SHOW_ALL
manga.downloadedFilter = Manga.SHOW_ALL manga.downloadedFilter = Manga.SHOW_ALL
manga.bookmarkedFilter = Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking() db.updateFlags(manga).executeAsBlocking()
refreshChapters() refreshChapters()
} }
/** /**
* Sets the active display mode. * Sets the active display mode.
*
* @param mode the mode to set. * @param mode the mode to set.
*/ */
fun setDisplayMode(mode: Int) { fun setDisplayMode(mode: Int) {
@ -402,7 +412,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/** /**
* Sets the sorting method and requests an UI update. * Sets the sorting method and requests an UI update.
*
* @param sort the sorting mode. * @param sort the sorting mode.
*/ */
fun setSorting(sort: Int) { fun setSorting(sort: Int) {
@ -418,6 +427,13 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
return manga.downloadedFilter == Manga.SHOW_DOWNLOADED return manga.downloadedFilter == Manga.SHOW_DOWNLOADED
} }
/**
* Whether the display only downloaded filter is enabled.
*/
fun onlyBookmarked(): Boolean {
return manga.bookmarkedFilter == Manga.SHOW_BOOKMARKED
}
/** /**
* Whether the display only unread filter is enabled. * Whether the display only unread filter is enabled.
*/ */

View File

@ -184,10 +184,9 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
val url = source.mangaDetailsRequest(presenter.manga).url().toString() val url = source.mangaDetailsRequest(presenter.manga).url().toString()
val sharingIntent = Intent(Intent.ACTION_SEND).apply { val sharingIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
putExtra(android.content.Intent.EXTRA_SUBJECT, presenter.manga.title)
putExtra(android.content.Intent.EXTRA_TEXT, resources.getString(R.string.share_text, presenter.manga.title, url)) putExtra(android.content.Intent.EXTRA_TEXT, resources.getString(R.string.share_text, presenter.manga.title, url))
} }
startActivity(Intent.createChooser(sharingIntent, resources.getText(R.string.share_subject))) startActivity(Intent.createChooser(sharingIntent, resources.getText(R.string.action_share)))
} catch (e: Exception) { } catch (e: Exception) {
context.toast(e.message) context.toast(e.message)
} }

View File

@ -70,14 +70,15 @@ class ChapterLoader(
private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter) private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap { .flatMap {
// Check if the chapter is downloaded. // Check if the chapter is downloaded.
chapter.isDownloaded = downloadManager.isChapterDownloaded(source, manga, chapter) chapter.isDownloaded = downloadManager.findChapterDir(source, manga, chapter) != null
// Fetch the page list from disk. if (chapter.isDownloaded) {
if (chapter.isDownloaded) // Fetch the page list from disk.
Observable.just(downloadManager.getSavedPageList(source, manga, chapter)!!) downloadManager.buildPageList(source, manga, chapter)
// Fetch the page list from cache or fallback to network } else {
else // Fetch the page list from cache or fallback to network
source.fetchPageList(chapter) source.fetchPageList(chapter)
}
} }
.doOnNext { pages -> .doOnNext { pages ->
chapter.pages = pages chapter.pages = pages
@ -85,21 +86,11 @@ class ChapterLoader(
} }
private fun loadPages(chapter: ReaderChapter) { private fun loadPages(chapter: ReaderChapter) {
if (chapter.isDownloaded) { if (!chapter.isDownloaded) {
loadDownloadedPages(chapter)
} else {
loadOnlinePages(chapter) loadOnlinePages(chapter)
} }
} }
private fun loadDownloadedPages(chapter: ReaderChapter) {
val chapterDir = downloadManager.getAbsoluteChapterDirectory(source, manga, chapter)
subscriptions += Observable.from(chapter.pages!!)
.flatMap { downloadManager.getDownloadedImage(it, chapterDir) }
.subscribeOn(Schedulers.io())
.subscribe()
}
private fun loadOnlinePages(chapter: ReaderChapter) { private fun loadOnlinePages(chapter: ReaderChapter) {
chapter.pages?.let { pages -> chapter.pages?.let { pages ->
val startPage = chapter.requestedPage val startPage = chapter.requestedPage

View File

@ -110,7 +110,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
setMenuVisibility(menuVisible) setMenuVisibility(menuVisible)
maxBitmapSize = Math.min(2048, GLUtil.getMaxTextureSize()) maxBitmapSize = GLUtil.getMaxTextureSize()
left_chapter.setOnClickListener { left_chapter.setOnClickListener {
if (viewer != null) { if (viewer != null) {
@ -189,7 +189,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
KeyEvent.KEYCODE_VOLUME_DOWN -> { KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (volumeKeysEnabled) { if (volumeKeysEnabled) {
if (event.action == KeyEvent.ACTION_UP) { if (event.action == KeyEvent.ACTION_UP) {
viewer?.moveToNext() viewer?.moveDown()
} }
return true return true
} }
@ -197,7 +197,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
KeyEvent.KEYCODE_VOLUME_UP -> { KeyEvent.KEYCODE_VOLUME_UP -> {
if (volumeKeysEnabled) { if (volumeKeysEnabled) {
if (event.action == KeyEvent.ACTION_UP) { if (event.action == KeyEvent.ACTION_UP) {
viewer?.moveToPrevious() viewer?.moveUp()
} }
return true return true
} }
@ -210,12 +210,15 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (!isFinishing) { if (!isFinishing) {
when (keyCode) { when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT -> viewer?.moveToNext() KeyEvent.KEYCODE_DPAD_RIGHT -> viewer?.moveRight()
KeyEvent.KEYCODE_DPAD_LEFT -> viewer?.moveToPrevious() KeyEvent.KEYCODE_DPAD_LEFT -> viewer?.moveLeft()
KeyEvent.KEYCODE_DPAD_DOWN -> viewer?.moveDown()
KeyEvent.KEYCODE_DPAD_UP -> viewer?.moveUp()
KeyEvent.KEYCODE_MENU -> toggleMenu() KeyEvent.KEYCODE_MENU -> toggleMenu()
else -> return super.onKeyUp(keyCode, event)
} }
} }
return super.onKeyUp(keyCode, event) return true
} }
fun onChapterError(error: Throwable) { fun onChapterError(error: Throwable) {
@ -224,6 +227,20 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
toast(error.message) toast(error.message)
} }
fun onLongClick(page: Page) {
MaterialDialog.Builder(this)
.title(getString(R.string.options))
.items(R.array.reader_image_options)
.itemsIds(R.array.reader_image_options_values)
.itemsCallback { materialDialog, view, i, charSequence ->
when (i) {
0 -> setImageAsCover(page)
1 -> shareImage(page)
2 -> presenter.savePage(page)
}
}.show()
}
fun onChapterAppendError() { fun onChapterAppendError() {
// Ignore // Ignore
} }
@ -250,7 +267,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
val activePage = pages.getOrElse(chapter.requestedPage) { pages.first() } val activePage = pages.getOrElse(chapter.requestedPage) { pages.first() }
viewer?.onPageListReady(chapter, activePage) viewer?.onPageListReady(chapter, activePage)
setActiveChapter(chapter, activePage.pageNumber) setActiveChapter(chapter, activePage.index)
} }
fun onEnterChapter(chapter: ReaderChapter, currentPage: Int) { fun onEnterChapter(chapter: ReaderChapter, currentPage: Int) {
@ -299,14 +316,14 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
val mangaViewer = if (manga.viewer == 0) preferences.defaultViewer() else manga.viewer val mangaViewer = if (manga.viewer == 0) preferences.defaultViewer() else manga.viewer
// Try to reuse the viewer using its tag // Try to reuse the viewer using its tag
var fragment: BaseReader? = supportFragmentManager.findFragmentByTag(manga.viewer.toString()) as? BaseReader var fragment = supportFragmentManager.findFragmentByTag(manga.viewer.toString()) as? BaseReader
if (fragment == null) { if (fragment == null) {
// Create a new viewer // Create a new viewer
when (mangaViewer) { fragment = when (mangaViewer) {
RIGHT_TO_LEFT -> fragment = RightToLeftReader() RIGHT_TO_LEFT -> RightToLeftReader()
VERTICAL -> fragment = VerticalReader() VERTICAL -> VerticalReader()
WEBTOON -> fragment = WebtoonReader() WEBTOON -> WebtoonReader()
else -> fragment = LeftToRightReader() else -> LeftToRightReader()
} }
supportFragmentManager.beginTransaction().replace(R.id.reader, fragment, manga.viewer.toString()).commit() supportFragmentManager.beginTransaction().replace(R.id.reader, fragment, manga.viewer.toString()).commit()
@ -317,7 +334,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
fun onPageChanged(page: Page) { fun onPageChanged(page: Page) {
presenter.onPageChanged(page) presenter.onPageChanged(page)
val pageNumber = page.pageNumber + 1 val pageNumber = page.number
val pageCount = page.chapter.pages!!.size val pageCount = page.chapter.pages!!.size
page_number.text = "$pageNumber/$pageCount" page_number.text = "$pageNumber/$pageCount"
if (page_seekbar.rotation != 180f) { if (page_seekbar.rotation != 180f) {
@ -325,7 +342,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
} else { } else {
right_page_text.text = "$pageNumber" right_page_text.text = "$pageNumber"
} }
page_seekbar.progress = page.pageNumber page_seekbar.progress = page.index
} }
fun gotoPageInCurrentChapter(pageIndex: Int) { fun gotoPageInCurrentChapter(pageIndex: Int) {
@ -539,4 +556,39 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
} }
} }
/**
* Start a share intent that lets user share image
*
* @param page page object containing image information.
*/
private fun shareImage(page: Page) {
if (page.status != Page.READY)
return
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, page.uri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
type = "image/*"
}
startActivity(Intent.createChooser(intent, getString(R.string.action_share)))
}
/**
* Sets the given page as the cover of the manga.
*
* @param page the page containing the image to set as cover.
*/
private fun setImageAsCover(page: Page) {
if (page.status != Page.READY)
return
MaterialDialog.Builder(this)
.content(getString(R.string.confirm_set_image_as_cover))
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { dialog, which -> presenter.setImageAsCover(page) }
.show()
}
} }

View File

@ -1,7 +1,11 @@
package eu.kanade.tachiyomi.ui.reader package eu.kanade.tachiyomi.ui.reader
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.webkit.MimeTypeMap
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.History
@ -15,8 +19,11 @@ 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.data.source.online.OnlineSource
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.reader.notification.ImageNotifier
import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.RetryWithDelay import eu.kanade.tachiyomi.util.RetryWithDelay
import eu.kanade.tachiyomi.util.SharedData import eu.kanade.tachiyomi.util.SharedData
import eu.kanade.tachiyomi.util.toast
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
@ -24,13 +31,13 @@ import rx.schedulers.Schedulers
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.net.URLConnection
import java.util.* import java.util.*
/** /**
* Presenter of [ReaderActivity]. * Presenter of [ReaderActivity].
*/ */
class ReaderPresenter : BasePresenter<ReaderActivity>() { class ReaderPresenter : BasePresenter<ReaderActivity>() {
/** /**
* Preferences. * Preferences.
*/ */
@ -61,6 +68,11 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
*/ */
val chapterCache: ChapterCache by injectLazy() val chapterCache: ChapterCache by injectLazy()
/**
* Cover cache.
*/
val coverCache: CoverCache by injectLazy()
/** /**
* Manga being read. * Manga being read.
*/ */
@ -332,9 +344,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
fun retryPage(page: Page?) { fun retryPage(page: Page?) {
if (page != null && source is OnlineSource) { if (page != null && source is OnlineSource) {
page.status = Page.QUEUE page.status = Page.QUEUE
if (page.imagePath != null) { val uri = page.uri
val file = File(page.imagePath) if (uri != null && !page.chapter.isDownloaded) {
chapterCache.removeFileFromCache(file.name) chapterCache.removeFileFromCache(uri.encodedPath.substringAfterLast('/'))
} }
loader.retryPage(page) loader.retryPage(page)
} }
@ -351,27 +363,27 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
val pages = chapter.pages ?: return val pages = chapter.pages ?: return
Observable.fromCallable { Observable.fromCallable {
// Chapters with 1 page don't trigger page changes, so mark them as read.
if (pages.size == 1) {
chapter.read = true
}
// Cache current page list progress for online chapters to allow a faster reopen // Cache current page list progress for online chapters to allow a faster reopen
if (!chapter.isDownloaded) { if (!chapter.isDownloaded) {
source.let { if (it is OnlineSource) it.savePageList(chapter, pages) } source.let { if (it is OnlineSource) it.savePageList(chapter, pages) }
} }
if (chapter.read) { try {
val removeAfterReadSlots = prefs.removeAfterReadSlots() if (chapter.read) {
when (removeAfterReadSlots) { val removeAfterReadSlots = prefs.removeAfterReadSlots()
// Setting disabled when (removeAfterReadSlots) {
-1 -> { /**Empty function**/ } // Setting disabled
// Remove current read chapter -1 -> { /* Empty function */ }
0 -> deleteChapter(chapter, manga) // Remove current read chapter
// Remove previous chapter specified by user in settings. 0 -> deleteChapter(chapter, manga)
else -> getAdjacentChaptersStrategy(chapter, removeAfterReadSlots) // Remove previous chapter specified by user in settings.
.first?.let { deleteChapter(it, manga) } else -> getAdjacentChaptersStrategy(chapter, removeAfterReadSlots)
.first?.let { deleteChapter(it, manga) }
}
} }
} catch (error: Exception) {
// TODO find out why it crashes
Timber.e(error)
} }
db.updateChapterProgress(chapter).executeAsBlocking() db.updateChapterProgress(chapter).executeAsBlocking()
@ -384,8 +396,8 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
Timber.e(error) Timber.e(error)
} }
} }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
/** /**
@ -395,7 +407,7 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
*/ */
fun onPageChanged(page: Page) { fun onPageChanged(page: Page) {
val chapter = page.chapter val chapter = page.chapter
chapter.last_page_read = page.pageNumber chapter.last_page_read = page.index
if (chapter.pages!!.last() === page) { if (chapter.pages!!.last() === page) {
chapter.read = true chapter.read = true
} }
@ -508,4 +520,75 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
db.insertManga(manga).executeAsBlocking() db.insertManga(manga).executeAsBlocking()
} }
/**
* Update cover with page file.
*/
internal fun setImageAsCover(page: Page) {
try {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
val input = context.contentResolver.openInputStream(page.uri)
coverCache.copyToCache(thumbUrl, input)
context.toast(R.string.cover_updated)
} else {
context.toast(R.string.notification_first_add_to_library)
}
} catch (error: Exception) {
context.toast(R.string.notification_cover_update_failed)
Timber.e(error)
}
}
/**
* Save page to local storage.
*/
internal fun savePage(page: Page) {
if (page.status != Page.READY)
return
// Used to show image notification.
val imageNotifier = ImageNotifier(context)
// Remove the notification if it already exists (user feedback).
imageNotifier.onClear()
// Pictures directory.
val pictureDirectory = Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + context.getString(R.string.app_name)
// Copy file in background.
Observable
.fromCallable {
// Folder where the image will be saved.
val destDir = File(pictureDirectory)
destDir.mkdirs()
// Find out file mime type.
val mime = context.contentResolver.getType(page.uri)
?: context.contentResolver.openInputStream(page.uri).buffered().use {
URLConnection.guessContentTypeFromStream(it)
}
// Build destination file.
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "jpg"
val filename = DiskUtil.buildValidFilename(
"${manga.title} - ${chapter.name}") + " - ${page.number}.$ext"
val destFile = File(destDir, filename)
context.contentResolver.openInputStream(page.uri).use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
imageNotifier.onComplete(destFile)
}
.subscribeOn(Schedulers.io())
.subscribe({},
{ error ->
Timber.e(error)
imageNotifier.onError(error.message)
})
}
} }

View File

@ -0,0 +1,84 @@
package eu.kanade.tachiyomi.ui.reader.notification
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.support.v4.content.FileProvider
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.util.notificationManager
import java.io.File
import eu.kanade.tachiyomi.Constants.NOTIFICATION_DOWNLOAD_IMAGE_ID as defaultNotification
/**
* The BroadcastReceiver of [ImageNotifier]
* Intent calls should be made from this class.
*/
class ImageNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_DELETE_IMAGE -> {
deleteImage(intent.getStringExtra(EXTRA_FILE_LOCATION))
context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, defaultNotification))
}
}
}
/**
* Called to delete image
*
* @param path path of file
*/
private fun deleteImage(path: String) {
val file = File(path)
if (file.exists()) file.delete()
}
companion object {
private const val ACTION_DELETE_IMAGE = "eu.kanade.DELETE_IMAGE"
private const val EXTRA_FILE_LOCATION = "file_location"
private const val NOTIFICATION_ID = "notification_id"
/**
* Called to start share intent to share image
*
* @param context context of application
* @param path path of file
*/
internal fun shareImageIntent(context: Context, path: String): PendingIntent {
val intent = Intent(Intent.ACTION_SEND).apply {
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", File(path))
putExtra(Intent.EXTRA_STREAM, uri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
type = "image/*"
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
* Called to show image in gallery application
*
* @param context context of application
* @param path path of file
*/
internal fun showImageIntent(context: Context, path: String): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", File(path))
setDataAndType(uri, "image/*")
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
internal fun deleteImageIntent(context: Context, path: String, notificationId: Int): PendingIntent {
val intent = Intent(context, ImageNotificationReceiver::class.java).apply {
action = ACTION_DELETE_IMAGE
putExtra(EXTRA_FILE_LOCATION, path)
putExtra(NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
}
}

View File

@ -0,0 +1,102 @@
package eu.kanade.tachiyomi.ui.reader.notification
import android.content.Context
import android.graphics.Bitmap
import android.support.v4.app.NotificationCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.notificationManager
import java.io.File
/**
* Class used to show BigPictureStyle notifications
*/
class ImageNotifier(private val context: Context) {
/**
* Notification builder.
*/
private val notificationBuilder = NotificationCompat.Builder(context)
/**
* Id of the notification.
*/
private val notificationId: Int
get() = Constants.NOTIFICATION_DOWNLOAD_IMAGE_ID
/**
* Called when image download/copy is complete. This method must be called in a background
* thread.
*
* @param file image file containing downloaded page image.
*/
fun onComplete(file: File) {
val bitmap = Glide.with(context)
.load(file)
.asBitmap()
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
.into(720, 1280)
.get()
if (bitmap != null) {
showCompleteNotification(file, bitmap)
} else {
onError(null)
}
}
private fun showCompleteNotification(file: File, image: Bitmap) {
with(notificationBuilder) {
setContentTitle(context.getString(R.string.picture_saved))
setSmallIcon(R.drawable.ic_insert_photo_white_24dp)
setStyle(NotificationCompat.BigPictureStyle().bigPicture(image))
setLargeIcon(image)
setAutoCancel(true)
// Clear old actions if they exist
if (!mActions.isEmpty())
mActions.clear()
setContentIntent(ImageNotificationReceiver.showImageIntent(context, file.absolutePath))
// Share action
addAction(R.drawable.ic_share_grey_24dp,
context.getString(R.string.action_share),
ImageNotificationReceiver.shareImageIntent(context, file.absolutePath))
// Delete action
addAction(R.drawable.ic_delete_grey_24dp,
context.getString(R.string.action_delete),
ImageNotificationReceiver.deleteImageIntent(context, file.absolutePath, notificationId))
updateNotification()
}
}
/**
* Clears the notification message.
*/
fun onClear() {
context.notificationManager.cancel(notificationId)
}
private fun updateNotification() {
// Displays the progress bar on notification
context.notificationManager.notify(notificationId, notificationBuilder.build())
}
/**
* Called on error while downloading image.
* @param error string containing error information.
*/
fun onError(error: String?) {
// Create notification
with(notificationBuilder) {
setContentTitle(context.getString(R.string.download_notifier_title_error))
setContentText(error ?: context.getString(R.string.unknown_error))
setSmallIcon(android.R.drawable.ic_menu_report_image)
}
updateNotification()
}
}

View File

@ -16,14 +16,19 @@ abstract class BaseReader : BaseFragment() {
companion object { companion object {
/** /**
* Rapid decoder. * Image decoder.
*/ */
const val RAPID_DECODER = 0 const val IMAGE_DECODER = 0
/** /**
* Skia decoder. * Skia decoder.
*/ */
const val SKIA_DECODER = 1 const val SKIA_DECODER = 1
/**
* Rapid decoder.
*/
const val RAPID_DECODER = 2
} }
/** /**
@ -90,7 +95,7 @@ abstract class BaseReader : BaseFragment() {
// Active chapter has changed. // Active chapter has changed.
if (oldChapter.id != newChapter.id) { if (oldChapter.id != newChapter.id) {
readerActivity.onEnterChapter(newPage.chapter, newPage.pageNumber) readerActivity.onEnterChapter(newPage.chapter, newPage.index)
} }
// Request next chapter only when the conditions are met. // Request next chapter only when the conditions are met.
if (pages.size - position < 5 && chapters.last().id == newChapter.id if (pages.size - position < 5 && chapters.last().id == newChapter.id
@ -120,7 +125,7 @@ abstract class BaseReader : BaseFragment() {
*/ */
fun getPageIndex(search: Page): Int { fun getPageIndex(search: Page): Int {
for ((index, page) in pages.withIndex()) { for ((index, page) in pages.withIndex()) {
if (page.pageNumber == search.pageNumber && page.chapter.id == search.chapter.id) { if (page.index == search.index && page.chapter.id == search.chapter.id) {
return index return index
} }
} }
@ -184,14 +189,38 @@ abstract class BaseReader : BaseFragment() {
abstract fun onChapterAppended(chapter: ReaderChapter) abstract fun onChapterAppended(chapter: ReaderChapter)
/** /**
* Moves pages forward. Implementations decide how to move (by a page, by some distance...). * Moves pages to right. Implementations decide how to move (by a page, by some distance...).
*/ */
abstract fun moveToNext() abstract fun moveRight()
/** /**
* Moves pages backward. Implementations decide how to move (by a page, by some distance...). * Moves pages to left. Implementations decide how to move (by a page, by some distance...).
*/ */
abstract fun moveToPrevious() abstract fun moveLeft()
/**
* Moves pages down. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveDown() {
moveRight()
}
/**
* Moves pages up. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveUp() {
moveLeft()
}
/**
* Method the implementations can call to show a menu with options for the given page.
*/
fun onLongClick(page: Page?): Boolean {
if (isAdded && page != null) {
readerActivity.onLongClick(page)
}
return true
}
/** /**
* Sets the active decoder class. * Sets the active decoder class.
@ -200,16 +229,17 @@ abstract class BaseReader : BaseFragment() {
*/ */
fun setDecoderClass(value: Int) { fun setDecoderClass(value: Int) {
when (value) { when (value) {
RAPID_DECODER -> { IMAGE_DECODER -> {
// Using Skia because Rapid isn't stable. Rapid is still used for region decoding. bitmapDecoderClass = IImageDecoder::class.java
// https://github.com/inorichi/tachiyomi/issues/97 regionDecoderClass = IImageRegionDecoder::class.java
//bitmapDecoderClass = RapidImageDecoder.class;
regionDecoderClass = RapidImageRegionDecoder::class.java
bitmapDecoderClass = SkiaImageDecoder::class.java
} }
SKIA_DECODER -> { SKIA_DECODER -> {
regionDecoderClass = SkiaImageRegionDecoder::class.java
bitmapDecoderClass = SkiaImageDecoder::class.java bitmapDecoderClass = SkiaImageDecoder::class.java
regionDecoderClass = SkiaImageRegionDecoder::class.java
}
RAPID_DECODER -> {
bitmapDecoderClass = RapidImageDecoder::class.java
regionDecoderClass = RapidImageRegionDecoder::class.java
} }
} }
} }

View File

@ -1,72 +1,40 @@
package eu.kanade.tachiyomi.ui.reader.viewer.base package eu.kanade.tachiyomi.ui.reader.viewer.base
import android.content.Context
import android.content.Intent
import android.net.Uri import android.net.Uri
import android.support.v4.content.ContextCompat import android.support.v4.content.ContextCompat
import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import kotlinx.android.synthetic.main.page_decode_error.view.*
class PageDecodeErrorLayout(context: Context) : LinearLayout(context) { class PageDecodeErrorLayout(
val view: View,
/** val page: Page,
* Text color for black theme. val theme: Int,
*/ val retryListener: () -> Unit
private val whiteColor = ContextCompat.getColor(context, R.color.textColorSecondaryDark) ) {
/**
* Text color for white theme.
*/
private val blackColor = ContextCompat.getColor(context, R.color.textColorSecondaryLight)
init { init {
orientation = LinearLayout.VERTICAL val textColor = if (theme == ReaderActivity.BLACK_THEME)
setGravity(Gravity.CENTER) ContextCompat.getColor(view.context, R.color.textColorSecondaryDark)
else
ContextCompat.getColor(view.context, R.color.textColorSecondaryLight)
view.decode_error_text.setTextColor(textColor)
view.decode_retry.setOnClickListener {
retryListener()
}
view.decode_open_browser.setOnClickListener {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, Uri.parse(page.imageUrl))
view.context.startActivity(intent)
}
if (page.imageUrl == null) {
view.decode_open_browser.visibility = View.GONE
}
} }
constructor(context: Context, page: Page, theme: Int, retryListener: () -> Unit) : this(context) {
// Error message.
TextView(context).apply {
gravity = Gravity.CENTER
setText(R.string.decode_image_error)
setTextColor(if (theme == ReaderActivity.BLACK_THEME) whiteColor else blackColor)
addView(this)
}
// Retry button.
Button(context).apply {
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
setText(R.string.action_retry)
setOnClickListener {
removeAllViews()
retryListener()
}
addView(this)
}
// Open in browser button.
Button(context).apply {
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
setText(R.string.action_open_in_browser)
setOnClickListener { v ->
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(page.imageUrl))
context.startActivity(intent)
}
if (page.imageUrl == null) {
visibility = View.GONE
}
addView(this)
}
}
} }

View File

@ -8,15 +8,14 @@ import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader.Companion.ALIGN_CENTER
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader.Companion.ALIGN_LEFT
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerReader.Companion.ALIGN_RIGHT
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical.VerticalReader import eu.kanade.tachiyomi.ui.reader.viewer.pager.vertical.VerticalReader
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.chapter_image.view.* import kotlinx.android.synthetic.main.chapter_image.view.*
import kotlinx.android.synthetic.main.item_pager_reader.view.* import kotlinx.android.synthetic.main.item_pager_reader.view.*
import rx.Observable import rx.Observable
@ -24,7 +23,6 @@ import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.subjects.PublishSubject import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject import rx.subjects.SerializedSubject
import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
@ -33,8 +31,12 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
/** /**
* Page of a chapter. * Page of a chapter.
*/ */
var page: Page? = null lateinit var page: Page
private set
/**
* Subscription for status changes of the page.
*/
private var statusSubscription: Subscription? = null
/** /**
* Subscription for progress changes of the page. * Subscription for progress changes of the page.
@ -42,11 +44,11 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
private var progressSubscription: Subscription? = null private var progressSubscription: Subscription? = null
/** /**
* Subscription for status changes of the page. * Layout of decode error.
*/ */
private var statusSubscription: Subscription? = null private var decodeErrorLayout: View? = null
fun initialize(reader: PagerReader, page: Page?) { fun initialize(reader: PagerReader, page: Page) {
val activity = reader.activity as ReaderActivity val activity = reader.activity as ReaderActivity
when (activity.readerTheme) { when (activity.readerTheme) {
@ -69,21 +71,14 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
setBitmapDecoderClass(reader.bitmapDecoderClass) setBitmapDecoderClass(reader.bitmapDecoderClass)
setVerticalScrollingParent(reader is VerticalReader) setVerticalScrollingParent(reader is VerticalReader)
setOnTouchListener { v, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) } setOnTouchListener { v, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) }
setOnLongClickListener { reader.onLongClick(page) }
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() { setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onReady() { override fun onReady() {
when (reader.zoomType) { onImageDecoded(reader)
ALIGN_LEFT -> setScaleAndCenter(scale, PointF(0f, 0f))
ALIGN_RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
ALIGN_CENTER -> {
val newCenter = center
newCenter.y = 0f
setScaleAndCenter(scale, newCenter)
}
}
} }
override fun onImageLoadError(e: Exception) { override fun onImageLoadError(e: Exception) {
onImageDecodeError(activity) onImageDecodeError(reader)
} }
}) })
} }
@ -95,21 +90,15 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
true true
} }
if (page != null) { this.page = page
this.page = page observeStatus()
observeStatus()
}
} }
fun cleanup() { override fun onDetachedFromWindow() {
unsubscribeProgress() unsubscribeProgress()
unsubscribeStatus() unsubscribeStatus()
image_view.setOnTouchListener(null) image_view.setOnTouchListener(null)
image_view.setOnImageEventListener(null) image_view.setOnImageEventListener(null)
}
override fun onDetachedFromWindow() {
cleanup()
super.onDetachedFromWindow() super.onDetachedFromWindow()
} }
@ -120,7 +109,6 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
*/ */
private fun observeStatus() { private fun observeStatus() {
statusSubscription?.unsubscribe() statusSubscription?.unsubscribe()
val page = page ?: return
val statusSubject = SerializedSubject(PublishSubject.create<Int>()) val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject) page.setStatusSubject(statusSubject)
@ -135,7 +123,6 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
*/ */
private fun observeProgress() { private fun observeProgress() {
progressSubscription?.unsubscribe() progressSubscription?.unsubscribe()
val page = page ?: return
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS) progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress } .map { page.progress }
@ -154,18 +141,18 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
*/ */
private fun processStatus(status: Int) { private fun processStatus(status: Int) {
when (status) { when (status) {
Page.QUEUE -> hideError() Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> onLoading() Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> { Page.DOWNLOAD_IMAGE -> {
observeProgress() observeProgress()
onDownloading() setDownloading()
} }
Page.READY -> { Page.READY -> {
onReady() setImage()
unsubscribeProgress() unsubscribeProgress()
} }
Page.ERROR -> { Page.ERROR -> {
onError() setError()
unsubscribeProgress() unsubscribeProgress()
} }
} }
@ -175,7 +162,7 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
* Unsubscribes from the status subscription. * Unsubscribes from the status subscription.
*/ */
private fun unsubscribeStatus() { private fun unsubscribeStatus() {
page?.setStatusSubject(null) page.setStatusSubject(null)
statusSubscription?.unsubscribe() statusSubscription?.unsubscribe()
statusSubscription = null statusSubscription = null
} }
@ -188,10 +175,23 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
progressSubscription = null progressSubscription = null
} }
/**
* Called when the page is queued.
*/
private fun setQueued() {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.INVISIBLE
retry_button.visibility = View.GONE
decodeErrorLayout?.let {
removeView(it)
decodeErrorLayout = null
}
}
/** /**
* Called when the page is loading. * Called when the page is loading.
*/ */
private fun onLoading() { private fun setLoading() {
progress_container.visibility = View.VISIBLE progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE progress_text.visibility = View.VISIBLE
progress_text.setText(R.string.downloading) progress_text.setText(R.string.downloading)
@ -200,7 +200,7 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
/** /**
* Called when the page is downloading. * Called when the page is downloading.
*/ */
private fun onDownloading() { private fun setDownloading() {
progress_container.visibility = View.VISIBLE progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE progress_text.visibility = View.VISIBLE
} }
@ -208,42 +208,64 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
/** /**
* Called when the page is ready. * Called when the page is ready.
*/ */
private fun onReady() { private fun setImage() {
page?.imagePath?.let { path -> val uri = page.uri
if (File(path).exists()) { if (uri == null) {
image_view.setImage(ImageSource.uri(path)) page.status = Page.ERROR
progress_container.visibility = View.GONE return
} else {
page?.status = Page.ERROR
}
} }
val file = UniFile.fromUri(context, uri)
if (!file.exists()) {
page.status = Page.ERROR
return
}
progress_text.visibility = View.INVISIBLE
image_view.setImage(ImageSource.uri(file.uri))
} }
/** /**
* Called when the page has an error. * Called when the page has an error.
*/ */
private fun onError() { private fun setError() {
progress_container.visibility = View.GONE progress_container.visibility = View.GONE
retry_button.visibility = View.VISIBLE retry_button.visibility = View.VISIBLE
} }
/** /**
* Hides the error layout. * Called when the image is decoded and going to be displayed.
*/ */
private fun hideError() { private fun onImageDecoded(reader: PagerReader) {
retry_button.visibility = View.GONE progress_container.visibility = View.GONE
with(image_view) {
when (reader.zoomType) {
PagerReader.ALIGN_LEFT -> setScaleAndCenter(scale, PointF(0f, 0f))
PagerReader.ALIGN_RIGHT -> setScaleAndCenter(scale, PointF(sWidth.toFloat(), 0f))
PagerReader.ALIGN_CENTER -> setScaleAndCenter(scale, center.apply { y = 0f })
}
}
} }
/** /**
* Called when an image fails to decode. * Called when an image fails to decode.
*/ */
private fun onImageDecodeError(activity: ReaderActivity) { private fun onImageDecodeError(reader: PagerReader) {
page?.let { page -> progress_container.visibility = View.GONE
val errorLayout = PageDecodeErrorLayout(context, page, activity.readerTheme,
{ activity.presenter.retryPage(page) })
addView(errorLayout) if (decodeErrorLayout != null || !reader.isAdded) return
}
val activity = reader.activity as ReaderActivity
val layout = inflate(R.layout.page_decode_error)
PageDecodeErrorLayout(layout, page, activity.readerTheme, {
if (reader.isAdded) {
activity.presenter.retryPage(page)
}
})
decodeErrorLayout = layout
addView(layout)
} }
} }

View File

@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
import eu.kanade.tachiyomi.util.toast
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
/** /**
@ -65,7 +66,7 @@ abstract class PagerReader : BaseReader() {
/** /**
* Gesture detector for touch events. * Gesture detector for touch events.
*/ */
val gestureDetector by lazy { createGestureDetector() } val gestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
/** /**
* Subscriptions for reader settings. * Subscriptions for reader settings.
@ -165,27 +166,24 @@ abstract class PagerReader : BaseReader() {
} }
/** /**
* Creates the gesture detector for the pager. * Gesture detector for Subsampling Scale Image View.
*
* @return a gesture detector.
*/ */
protected fun createGestureDetector(): GestureDetector { inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
return GestureDetector(activity, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (isAdded) {
val positionX = e.x
if (positionX < pager.width * LEFT_REGION) { override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (tappingEnabled) onLeftSideTap() if (isAdded) {
} else if (positionX > pager.width * RIGHT_REGION) { val positionX = e.x
if (tappingEnabled) onRightSideTap()
} else { if (positionX < pager.width * LEFT_REGION) {
readerActivity.toggleMenu() if (tappingEnabled) moveLeft()
} } else if (positionX > pager.width * RIGHT_REGION) {
if (tappingEnabled) moveRight()
} else {
readerActivity.toggleMenu()
} }
return true
} }
}) return true
}
} }
/** /**
@ -247,23 +245,23 @@ abstract class PagerReader : BaseReader() {
} }
/** /**
* Called when the left side of the screen was clicked. * Moves a page to the right.
*/ */
protected open fun onLeftSideTap() { override fun moveRight() {
moveToPrevious() moveToNext()
} }
/** /**
* Called when the right side of the screen was clicked. * Moves a page to the left.
*/ */
protected open fun onRightSideTap() { override fun moveLeft() {
moveToNext() moveToPrevious()
} }
/** /**
* Moves to the next page or requests the next chapter if it's the last one. * Moves to the next page or requests the next chapter if it's the last one.
*/ */
override fun moveToNext() { protected fun moveToNext() {
if (pager.currentItem != pager.adapter.count - 1) { if (pager.currentItem != pager.adapter.count - 1) {
pager.setCurrentItem(pager.currentItem + 1, transitions) pager.setCurrentItem(pager.currentItem + 1, transitions)
} else { } else {
@ -274,7 +272,7 @@ abstract class PagerReader : BaseReader() {
/** /**
* Moves to the previous page or requests the previous chapter if it's the first one. * Moves to the previous page or requests the previous chapter if it's the first one.
*/ */
override fun moveToPrevious() { protected fun moveToPrevious() {
if (pager.currentItem != 0) { if (pager.currentItem != 0) {
pager.setCurrentItem(pager.currentItem - 1, transitions) pager.setCurrentItem(pager.currentItem - 1, transitions)
} else { } else {

View File

@ -15,7 +15,7 @@ class PagerReaderAdapter(private val reader: PagerReader) : ViewPagerAdapter() {
/** /**
* Pages stored in the adapter. * Pages stored in the adapter.
*/ */
var pages: List<Page>? = null var pages: List<Page> = emptyList()
set(value) { set(value) {
field = value field = value
notifyDataSetChanged() notifyDataSetChanged()
@ -23,17 +23,15 @@ class PagerReaderAdapter(private val reader: PagerReader) : ViewPagerAdapter() {
override fun createView(container: ViewGroup, position: Int): View { override fun createView(container: ViewGroup, position: Int): View {
val view = container.inflate(R.layout.item_pager_reader) as PageView val view = container.inflate(R.layout.item_pager_reader) as PageView
view.initialize(reader, pages?.getOrNull(position)) view.initialize(reader, pages[position])
return view return view
} }
/** /**
* Returns the number of pages. * Returns the number of pages.
*
* @return the number of pages or 0 if the list is null.
*/ */
override fun getCount(): Int { override fun getCount(): Int {
return pages?.size ?: 0 return pages.size
} }
} }

View File

@ -19,11 +19,31 @@ class RightToLeftReader : PagerReader() {
} }
} }
override fun onLeftSideTap() { /**
* Moves a page to the right.
*/
override fun moveRight() {
moveToPrevious()
}
/**
* Moves a page to the left.
*/
override fun moveLeft() {
moveToNext() moveToNext()
} }
override fun onRightSideTap() { /**
* Moves a page down.
*/
override fun moveDown() {
moveToNext()
}
/**
* Moves a page up.
*/
override fun moveUp() {
moveToPrevious() moveToPrevious()
} }

View File

@ -22,7 +22,7 @@ class WebtoonAdapter(val fragment: WebtoonReader) : RecyclerView.Adapter<Webtoon
/** /**
* Touch listener for images in holders. * Touch listener for images in holders.
*/ */
val touchListener = View.OnTouchListener { v, ev -> fragment.gestureDetector.onTouchEvent(ev) } val touchListener = View.OnTouchListener { v, ev -> fragment.imageGestureDetector.onTouchEvent(ev) }
/** /**
* Returns the number of pages. * Returns the number of pages.
@ -72,7 +72,7 @@ class WebtoonAdapter(val fragment: WebtoonReader) : RecyclerView.Adapter<Webtoon
* @param holder the holder to recycle. * @param holder the holder to recycle.
*/ */
override fun onViewRecycled(holder: WebtoonHolder) { override fun onViewRecycled(holder: WebtoonHolder) {
holder.unsubscribeStatus() holder.onRecycle()
} }
} }

View File

@ -6,16 +6,20 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout import eu.kanade.tachiyomi.ui.reader.viewer.base.PageDecodeErrorLayout
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.chapter_image.view.* import kotlinx.android.synthetic.main.chapter_image.view.*
import kotlinx.android.synthetic.main.item_webtoon_reader.view.* import kotlinx.android.synthetic.main.item_webtoon_reader.view.*
import rx.Observable
import rx.Subscription import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.subjects.PublishSubject import rx.subjects.PublishSubject
import rx.subjects.SerializedSubject import rx.subjects.SerializedSubject
import java.io.File import java.util.concurrent.TimeUnit
/** /**
* Holder for webtoon reader for a single page of a chapter. * Holder for webtoon reader for a single page of a chapter.
@ -38,10 +42,15 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
*/ */
private var statusSubscription: Subscription? = null private var statusSubscription: Subscription? = null
/**
* Subscription for progress changes of the page.
*/
private var progressSubscription: Subscription? = null
/** /**
* Layout of decode error. * Layout of decode error.
*/ */
private var decodeErrorLayout: PageDecodeErrorLayout? = null private var decodeErrorLayout: View? = null
init { init {
with(view.image_view) { with(view.image_view) {
@ -55,10 +64,10 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
setBitmapDecoderClass(webtoonReader.bitmapDecoderClass) setBitmapDecoderClass(webtoonReader.bitmapDecoderClass)
setVerticalScrollingParent(true) setVerticalScrollingParent(true)
setOnTouchListener(adapter.touchListener) setOnTouchListener(adapter.touchListener)
setOnLongClickListener { webtoonReader.onLongClick(page) }
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() { setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onImageLoaded() { override fun onReady() {
// When the image is loaded, reset the minimum height to avoid gaps onImageDecoded()
view.frame_container.minimumHeight = 30
} }
override fun onImageLoadError(e: Exception) { override fun onImageLoadError(e: Exception) {
@ -67,16 +76,9 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
}) })
} }
// Avoid to create a lot of view holders taking twice the screen height, view.progress_container.minimumHeight = view.resources.displayMetrics.heightPixels * 2
// saving memory and a possible OOM. When the first image is loaded in this holder,
// the minimum size will be removed.
// Doing this we get sequential holder instantiation.
view.frame_container.minimumHeight = view.resources.displayMetrics.heightPixels * 2
// Leave some space between progress bars view.setOnTouchListener(adapter.touchListener)
view.progress.minimumHeight = 300
view.frame_container.setOnTouchListener(adapter.touchListener)
view.retry_button.setOnTouchListener { v, event -> view.retry_button.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_UP) { if (event.action == MotionEvent.ACTION_UP) {
readerActivity.presenter.retryPage(page) readerActivity.presenter.retryPage(page)
@ -92,13 +94,23 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
* @param page the page to bind. * @param page the page to bind.
*/ */
fun onSetValues(page: Page) { fun onSetValues(page: Page) {
this.page = page
observeStatus()
}
/**
* Called when the view is recycled and added to the view pool.
*/
fun onRecycle() {
unsubscribeStatus()
unsubscribeProgress()
decodeErrorLayout?.let { decodeErrorLayout?.let {
(view as ViewGroup).removeView(it) (view as ViewGroup).removeView(it)
decodeErrorLayout = null decodeErrorLayout = null
} }
view.image_view.recycle()
this.page = page view.image_view.visibility = View.GONE
observeStatus() view.progress_container.visibility = View.VISIBLE
} }
/** /**
@ -107,17 +119,38 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
* @see processStatus * @see processStatus
*/ */
private fun observeStatus() { private fun observeStatus() {
page?.let { page -> unsubscribeStatus()
val statusSubject = SerializedSubject(PublishSubject.create<Int>())
page.setStatusSubject(statusSubject)
statusSubscription?.unsubscribe() val page = page ?: return
statusSubscription = statusSubject.startWith(page.status)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
webtoonReader.subscriptions.add(statusSubscription) val statusSubject = SerializedSubject(PublishSubject.create<Int>())
} page.setStatusSubject(statusSubject)
statusSubscription = statusSubject.startWith(page.status)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processStatus(it) }
addSubscription(statusSubscription)
}
/**
* Observes the progress of the page and updates view.
*/
private fun observeProgress() {
unsubscribeProgress()
val page = page ?: return
progressSubscription = Observable.interval(100, TimeUnit.MILLISECONDS)
.map { page.progress }
.distinctUntilChanged()
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { progress ->
view.progress_text.text = view.context.getString(R.string.download_progress, progress)
}
addSubscription(progressSubscription)
} }
/** /**
@ -127,104 +160,138 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
*/ */
private fun processStatus(status: Int) { private fun processStatus(status: Int) {
when (status) { when (status) {
Page.QUEUE -> onQueue() Page.QUEUE -> setQueued()
Page.LOAD_PAGE -> onLoading() Page.LOAD_PAGE -> setLoading()
Page.DOWNLOAD_IMAGE -> onLoading() Page.DOWNLOAD_IMAGE -> {
Page.READY -> onReady() observeProgress()
Page.ERROR -> onError() setDownloading()
} }
} Page.READY -> {
setImage()
/** unsubscribeProgress()
* Unsubscribes from the status subscription. }
*/ Page.ERROR -> {
fun unsubscribeStatus() { setError()
statusSubscription?.unsubscribe() unsubscribeProgress()
statusSubscription = null
}
/**
* Called when the page is loading.
*/
private fun onLoading() {
setRetryButtonVisible(false)
setImageVisible(false)
setProgressVisible(true)
}
/**
* Called when the page is ready.
*/
private fun onReady() {
setRetryButtonVisible(false)
setProgressVisible(false)
setImageVisible(true)
page?.imagePath?.let { path ->
if (File(path).exists()) {
view.image_view.setImage(ImageSource.uri(path))
view.progress.visibility = View.GONE
} else {
page?.status = Page.ERROR
} }
} }
} }
/** /**
* Called when the page has an error. * Adds a subscription to a list of subscriptions that will automatically unsubscribe when the
* activity or the reader is destroyed.
*/ */
private fun onError() { private fun addSubscription(subscription: Subscription?) {
setImageVisible(false) webtoonReader.subscriptions.add(subscription)
setProgressVisible(false) }
setRetryButtonVisible(true)
/**
* Removes a subscription from the list of subscriptions.
*/
private fun removeSubscription(subscription: Subscription?) {
subscription?.let { webtoonReader.subscriptions.remove(it) }
}
/**
* Unsubscribes from the status subscription.
*/
private fun unsubscribeStatus() {
page?.setStatusSubject(null)
removeSubscription(statusSubscription)
statusSubscription = null
}
/**
* Unsubscribes from the progress subscription.
*/
private fun unsubscribeProgress() {
removeSubscription(progressSubscription)
progressSubscription = null
} }
/** /**
* Called when the page is queued. * Called when the page is queued.
*/ */
private fun onQueue() { private fun setQueued() = with(view) {
setImageVisible(false) progress_container.visibility = View.VISIBLE
setRetryButtonVisible(false) progress_text.visibility = View.INVISIBLE
setProgressVisible(false) retry_container.visibility = View.GONE
decodeErrorLayout?.let {
(view as ViewGroup).removeView(it)
decodeErrorLayout = null
}
}
/**
* Called when the page is loading.
*/
private fun setLoading() = with(view) {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE
progress_text.setText(R.string.downloading)
}
/**
* Called when the page is downloading
*/
private fun setDownloading() = with(view) {
progress_container.visibility = View.VISIBLE
progress_text.visibility = View.VISIBLE
}
/**
* Called when the page is ready.
*/
private fun setImage() = with(view) {
val uri = page?.uri
if (uri == null) {
page?.status = Page.ERROR
return
}
val file = UniFile.fromUri(context, uri)
if (!file.exists()) {
page?.status = Page.ERROR
return
}
progress_text.visibility = View.INVISIBLE
image_view.visibility = View.VISIBLE
image_view.setImage(ImageSource.uri(file.uri))
}
/**
* Called when the page has an error.
*/
private fun setError() = with(view) {
progress_container.visibility = View.GONE
retry_container.visibility = View.VISIBLE
}
/**
* Called when the image is decoded and going to be displayed.
*/
private fun onImageDecoded() {
view.progress_container.visibility = View.GONE
} }
/** /**
* Called when the image fails to decode. * Called when the image fails to decode.
*/ */
private fun onImageDecodeError() { private fun onImageDecodeError() {
page?.let { page -> view.progress_container.visibility = View.GONE
decodeErrorLayout = PageDecodeErrorLayout(view.context, page, readerActivity.readerTheme,
{ readerActivity.presenter.retryPage(page) })
(view as ViewGroup).addView(decodeErrorLayout) val page = page ?: return
} if (decodeErrorLayout != null || !webtoonReader.isAdded) return
}
/** val layout = (view as ViewGroup).inflate(R.layout.page_decode_error)
* Sets the visibility of the progress bar. PageDecodeErrorLayout(layout, page, readerActivity.readerTheme, {
* if (webtoonReader.isAdded) {
* @param visible whether to show it or not. readerActivity.presenter.retryPage(page)
*/ }
private fun setProgressVisible(visible: Boolean) { })
view.progress.visibility = if (visible) View.VISIBLE else View.GONE decodeErrorLayout = layout
} view.addView(layout)
/**
* Sets the visibility of the image view.
*
* @param visible whether to show it or not.
*/
private fun setImageVisible(visible: Boolean) {
view.image_view.visibility = if (visible) View.VISIBLE else View.GONE
}
/**
* Sets the visibility of the retry button.
*
* @param visible whether to show it or not.
*/
private fun setRetryButtonVisible(visible: Boolean) {
view.retry_button.visibility = if (visible) View.VISIBLE else View.GONE
} }
/** /**

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.os.Bundle import android.os.Bundle
import android.support.v7.widget.RecyclerView import android.support.v7.widget.RecyclerView
import android.view.* import android.view.*
import android.view.GestureDetector.SimpleOnGestureListener
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
@ -53,9 +52,9 @@ class WebtoonReader : BaseReader() {
private set private set
/** /**
* Gesture detector for touch events. * Gesture detector for image touch events.
*/ */
val gestureDetector by lazy { createGestureDetector() } val imageGestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
/** /**
* Subscriptions used while the view exists. * Subscriptions used while the view exists.
@ -114,43 +113,39 @@ class WebtoonReader : BaseReader() {
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
val savedPosition = pages.getOrNull(layoutManager.findFirstVisibleItemPosition())?.pageNumber ?: 0 val savedPosition = pages.getOrNull(layoutManager.findFirstVisibleItemPosition())?.index ?: 0
outState.putInt(SAVED_POSITION, savedPosition) outState.putInt(SAVED_POSITION, savedPosition)
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
/** /**
* Creates the gesture detector for the reader. * Gesture detector for Subsampling Scale Image View.
*
* @return a gesture detector.
*/ */
protected fun createGestureDetector(): GestureDetector { inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
return GestureDetector(context, object : SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (isAdded) {
val positionX = e.x
if (positionX < recycler.width * LEFT_REGION) { override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (tappingEnabled) moveToPrevious() if (isAdded) {
} else if (positionX > recycler.width * RIGHT_REGION) { val positionX = e.x
if (tappingEnabled) moveToNext()
} else { if (positionX < recycler.width * LEFT_REGION) {
readerActivity.toggleMenu() if (tappingEnabled) moveLeft()
} } else if (positionX > recycler.width * RIGHT_REGION) {
if (tappingEnabled) moveRight()
} else {
readerActivity.toggleMenu()
} }
return true
} }
}) return true
}
} }
/** /**
* Called when a new chapter is set in [BaseReader]. * Called when a new chapter is set in [BaseReader].
*
* @param chapter the chapter set. * @param chapter the chapter set.
* @param currentPage the initial page to display. * @param currentPage the initial page to display.
*/ */
override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) { override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
this.currentPage = currentPage.pageNumber this.currentPage = currentPage.index
// Make sure the view is already initialized. // Make sure the view is already initialized.
if (view != null) { if (view != null) {
@ -160,7 +155,6 @@ class WebtoonReader : BaseReader() {
/** /**
* Called when a chapter is appended in [BaseReader]. * Called when a chapter is appended in [BaseReader].
*
* @param chapter the chapter appended. * @param chapter the chapter appended.
*/ */
override fun onChapterAppended(chapter: ReaderChapter) { override fun onChapterAppended(chapter: ReaderChapter) {
@ -184,7 +178,6 @@ class WebtoonReader : BaseReader() {
/** /**
* Sets the active page. * Sets the active page.
*
* @param pageNumber the index of the page from [pages]. * @param pageNumber the index of the page from [pages].
*/ */
override fun setActivePage(pageNumber: Int) { override fun setActivePage(pageNumber: Int) {
@ -194,14 +187,14 @@ class WebtoonReader : BaseReader() {
/** /**
* Moves to the next page or requests the next chapter if it's the last one. * Moves to the next page or requests the next chapter if it's the last one.
*/ */
override fun moveToNext() { override fun moveRight() {
recycler.smoothScrollBy(0, scrollDistance) recycler.smoothScrollBy(0, scrollDistance)
} }
/** /**
* Moves to the previous page or requests the previous chapter if it's the first one. * Moves to the previous page or requests the previous chapter if it's the first one.
*/ */
override fun moveToPrevious() { override fun moveLeft() {
recycler.smoothScrollBy(0, -scrollDistance) recycler.smoothScrollBy(0, -scrollDistance)
} }

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.recent_updates
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
@ -13,9 +14,7 @@ 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.reader.ReaderActivity import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.getResourceDrawable
import eu.kanade.tachiyomi.widget.DeletingChaptersDialog import eu.kanade.tachiyomi.widget.DeletingChaptersDialog
import eu.kanade.tachiyomi.widget.DividerItemDecoration
import kotlinx.android.synthetic.main.fragment_recent_chapters.* import kotlinx.android.synthetic.main.fragment_recent_chapters.*
import nucleus.factory.RequiresPresenter import nucleus.factory.RequiresPresenter
import timber.log.Timber import timber.log.Timber
@ -69,7 +68,7 @@ class RecentChaptersFragment
override fun onViewCreated(view: View, savedState: Bundle?) { override fun onViewCreated(view: View, savedState: Bundle?) {
// Init RecyclerView and adapter // Init RecyclerView and adapter
recycler.layoutManager = LinearLayoutManager(activity) recycler.layoutManager = LinearLayoutManager(activity)
recycler.addItemDecoration(DividerItemDecoration(context.theme.getResourceDrawable(R.attr.divider_drawable))) recycler.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true) recycler.setHasFixedSize(true)
adapter = RecentChaptersAdapter(this) adapter = RecentChaptersAdapter(this)
recycler.adapter = adapter recycler.adapter = adapter

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.recent_updates package eu.kanade.tachiyomi.ui.recent_updates
import android.os.Bundle import android.os.Bundle
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.MangaChapter import eu.kanade.tachiyomi.data.database.models.MangaChapter
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
@ -42,43 +43,17 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
*/ */
private var chapters: List<RecentChapter>? = null private var chapters: List<RecentChapter>? = null
/**
* The id of the restartable.
*/
val GET_RECENT_CHAPTERS = 1
/**
* The id of the restartable.
*/
val CHAPTER_STATUS_CHANGES = 2
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
// Used to get recent chapters getRecentChaptersObservable()
restartableLatestCache(GET_RECENT_CHAPTERS, .observeOn(AndroidSchedulers.mainThread())
{ getRecentChaptersObservable() }, .subscribeLatestCache(RecentChaptersFragment::onNextRecentChapters)
{ view, chapters ->
// Update adapter to show recent manga's
view.onNextRecentChapters(chapters)
}
)
// Used to update download status getChapterStatusObservable()
restartableLatestCache(CHAPTER_STATUS_CHANGES, .subscribeLatestCache(RecentChaptersFragment::onChapterStatusChange,
{ getChapterStatusObservable() }, { view, error -> Timber.e(error) })
{ view, download ->
// Set chapter status
view.onChapterStatusChange(download)
},
{ view, error -> Timber.e(error) }
)
if (savedState == null) {
// Start fetching recent chapters
start(GET_RECENT_CHAPTERS)
start(CHAPTER_STATUS_CHANGES)
}
} }
/** /**
@ -97,7 +72,10 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
.map { mangaChapters -> .map { mangaChapters ->
mangaChapters.map { it.toModel() } mangaChapters.map { it.toModel() }
} }
.doOnNext { chapters = it } .doOnNext {
setDownloadedChapters(it)
chapters = it
}
// Group chapters by the date they were fetched on a ordered map. // Group chapters by the date they were fetched on a ordered map.
.flatMap { recentItems -> .flatMap { recentItems ->
Observable.from(recentItems) Observable.from(recentItems)
@ -115,7 +93,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
} }
} }
} }
.observeOn(AndroidSchedulers.mainThread())
} }
/** /**
@ -142,18 +119,44 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
// downloaded and assign it to the status. // downloaded and assign it to the status.
if (download != null) { if (download != null) {
model.download = download model.download = download
} else {
// Get source of chapter.
val source = sourceManager.get(manga.source)!!
model.status = if (downloadManager.isChapterDownloaded(source, manga, chapter))
Download.DOWNLOADED
else
Download.NOT_DOWNLOADED
} }
return model return model
} }
/**
* Finds and assigns the list of downloaded chapters.
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<RecentChapter>) {
// Cached list of downloaded manga directories.
val mangaDirectories = mutableMapOf<Int, Array<UniFile>>()
// Cached list of downloaded chapter directories for a manga.
val chapterDirectories = mutableMapOf<Long, Array<UniFile>>()
for (chapter in chapters) {
val manga = chapter.manga
val source = sourceManager.get(manga.source) ?: continue
val mangaDirs = mangaDirectories.getOrPut(source.id) {
downloadManager.findSourceDir(source)?.listFiles() ?: emptyArray()
}
val mangaDirName = downloadManager.getMangaDirName(manga)
val mangaDir = mangaDirs.find { it.name == mangaDirName } ?: continue
val chapterDirs = chapterDirectories.getOrPut(manga.id!!) {
mangaDir.listFiles() ?: emptyArray()
}
val chapterDirName = downloadManager.getChapterDirName(chapter)
if (chapterDirs.any { it.name == chapterDirName }) {
chapter.status = Download.DOWNLOADED
}
}
}
/** /**
* Update status of chapters. * Update status of chapters.
* @param download download object containing progress. * @param download download object containing progress.
@ -207,10 +210,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
* @param chapters list of chapters * @param chapters list of chapters
*/ */
fun deleteChapters(chapters: List<RecentChapter>) { fun deleteChapters(chapters: List<RecentChapter>) {
val wasRunning = downloadManager.isRunning
if (wasRunning) {
DownloadService.stop(context)
}
Observable.from(chapters) Observable.from(chapters)
.doOnNext { deleteChapter(it) } .doOnNext { deleteChapter(it) }
.toList() .toList()
@ -218,9 +217,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, result -> .subscribeFirst({ view, result ->
view.onChaptersDeleted() view.onChaptersDeleted()
if (wasRunning) {
DownloadService.start(context)
}
}, { view, error -> }, { view, error ->
view.onChaptersDeletedError(error) view.onChaptersDeletedError(error)
}) })
@ -253,7 +249,7 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
*/ */
private fun deleteChapter(chapter: RecentChapter) { private fun deleteChapter(chapter: RecentChapter) {
val source = sourceManager.get(chapter.manga.source) ?: return val source = sourceManager.get(chapter.manga.source) ?: return
downloadManager.queue.del(chapter) downloadManager.queue.remove(chapter)
downloadManager.deleteChapter(source, chapter.manga, chapter) downloadManager.deleteChapter(source, chapter.manga, chapter)
chapter.status = Download.NOT_DOWNLOADED chapter.status = Download.NOT_DOWNLOADED
chapter.download = null chapter.download = null

View File

@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.updater.GithubUpdateChecker import eu.kanade.tachiyomi.data.updater.GithubUpdateChecker
import eu.kanade.tachiyomi.data.updater.GithubUpdateResult import eu.kanade.tachiyomi.data.updater.GithubUpdateResult
import eu.kanade.tachiyomi.data.updater.UpdateCheckerService import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import eu.kanade.tachiyomi.data.updater.UpdateDownloaderService import eu.kanade.tachiyomi.data.updater.UpdateDownloaderService
import eu.kanade.tachiyomi.util.toast import eu.kanade.tachiyomi.util.toast
import net.xpece.android.support.preference.SwitchPreference import net.xpece.android.support.preference.SwitchPreference
@ -64,9 +64,9 @@ class SettingsAboutFragment : SettingsFragment() {
automaticUpdates.setOnPreferenceChangeListener { preference, any -> automaticUpdates.setOnPreferenceChangeListener { preference, any ->
val checked = any as Boolean val checked = any as Boolean
if (checked) { if (checked) {
UpdateCheckerService.setupTask(context) UpdateCheckerJob.setupTask()
} else { } else {
UpdateCheckerService.cancelTask(context) UpdateCheckerJob.cancelTask()
} }
true true
} }

View File

@ -78,6 +78,7 @@ class SettingsActivity : BaseActivity(),
companion object { companion object {
const val FLAG_THEME_CHANGED = 0x1 const val FLAG_THEME_CHANGED = 0x1
const val FLAG_DATABASE_CLEARED = 0x2 const val FLAG_DATABASE_CLEARED = 0x2
const val FLAG_LANG_CHANGED = 0x4
} }
} }

View File

@ -71,7 +71,7 @@ class SettingsAdvancedFragment : SettingsFragment() {
private fun clearChapterCache() { private fun clearChapterCache() {
val deletedFiles = AtomicInteger() val deletedFiles = AtomicInteger()
val files = chapterCache.cacheDir.listFiles() val files = chapterCache.cacheDir.listFiles() ?: return
val dialog = MaterialDialog.Builder(activity) val dialog = MaterialDialog.Builder(activity)
.title(R.string.deleting) .title(R.string.deleting)

View File

@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.setting
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.support.v4.content.ContextCompat import android.support.v4.content.ContextCompat
@ -11,6 +13,7 @@ import android.support.v7.widget.RecyclerView
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.hippo.unifile.UniFile
import com.nononsenseapps.filepicker.AbstractFilePickerFragment import com.nononsenseapps.filepicker.AbstractFilePickerFragment
import com.nononsenseapps.filepicker.FilePickerActivity import com.nononsenseapps.filepicker.FilePickerActivity
import com.nononsenseapps.filepicker.FilePickerFragment import com.nononsenseapps.filepicker.FilePickerFragment
@ -26,7 +29,8 @@ import java.io.File
class SettingsDownloadsFragment : SettingsFragment() { class SettingsDownloadsFragment : SettingsFragment() {
companion object { companion object {
val DOWNLOAD_DIR_CODE = 103 const val DOWNLOAD_DIR_PRE_L = 103
const val DOWNLOAD_DIR_L = 104
fun newInstance(rootKey: String): SettingsDownloadsFragment { fun newInstance(rootKey: String): SettingsDownloadsFragment {
val args = Bundle() val args = Bundle()
@ -45,24 +49,30 @@ class SettingsDownloadsFragment : SettingsFragment() {
downloadDirPref.setOnPreferenceClickListener { downloadDirPref.setOnPreferenceClickListener {
val currentDir = preferences.downloadsDirectory().getOrDefault() val currentDir = preferences.downloadsDirectory().getOrDefault()
val externalDirs = getExternalFilesDirs() + getString(R.string.custom_dir) val externalDirs = getExternalFilesDirs() + File(getString(R.string.custom_dir))
val selectedIndex = externalDirs.indexOf(File(currentDir)) val selectedIndex = externalDirs.map(File::toString).indexOfFirst { it in currentDir }
MaterialDialog.Builder(activity) MaterialDialog.Builder(activity)
.items(externalDirs) .items(externalDirs)
.itemsCallbackSingleChoice(selectedIndex, { dialog, view, which, text -> .itemsCallbackSingleChoice(selectedIndex, { dialog, view, which, text ->
if (which == externalDirs.lastIndex) { if (which == externalDirs.lastIndex) {
// Custom dir selected, open directory selector if (Build.VERSION.SDK_INT < 21) {
val i = Intent(activity, CustomLayoutPickerActivity::class.java) // Custom dir selected, open directory selector
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false) val i = Intent(activity, CustomLayoutPickerActivity::class.java)
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR) i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir) i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
startActivityForResult(i, DOWNLOAD_DIR_CODE) startActivityForResult(i, DOWNLOAD_DIR_PRE_L)
} else {
val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(i, DOWNLOAD_DIR_L)
}
} else { } else {
// One of the predefined folders was selected // One of the predefined folders was selected
preferences.downloadsDirectory().set(text.toString()) val path = Uri.fromFile(File(text.toString()))
preferences.downloadsDirectory().set(path.toString())
} }
true true
}) })
@ -72,7 +82,16 @@ class SettingsDownloadsFragment : SettingsFragment() {
} }
subscriptions += preferences.downloadsDirectory().asObservable() subscriptions += preferences.downloadsDirectory().asObservable()
.subscribe { downloadDirPref.summary = it } .subscribe { path ->
val dir = UniFile.fromUri(context, Uri.parse(path))
downloadDirPref.summary = dir.filePath ?: path
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file.
if (dir != null && dir.exists()) {
dir.createFile(".nomedia")
}
}
} }
fun getExternalFilesDirs(): List<File> { fun getExternalFilesDirs(): List<File> {
@ -85,8 +104,22 @@ class SettingsDownloadsFragment : SettingsFragment() {
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (data != null && requestCode == DOWNLOAD_DIR_CODE && resultCode == Activity.RESULT_OK) { when (requestCode) {
preferences.downloadsDirectory().set(data.data.path) DOWNLOAD_DIR_PRE_L -> if (data != null && resultCode == Activity.RESULT_OK) {
val uri = Uri.fromFile(File(data.data.path))
preferences.downloadsDirectory().set(uri.toString())
}
DOWNLOAD_DIR_L -> if (data != null && resultCode == Activity.RESULT_OK) {
val uri = data.data
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
@Suppress("NewApi")
context.contentResolver.takePersistableUriPermission(uri, flags)
val file = UniFile.fromUri(context, uri)
preferences.downloadsDirectory().set(file.uri.toString())
}
} }
} }

View File

@ -2,14 +2,10 @@ package eu.kanade.tachiyomi.ui.setting
import android.os.Bundle import android.os.Bundle
import android.support.annotation.CallSuper import android.support.annotation.CallSuper
import android.support.graphics.drawable.VectorDrawableCompat
import android.support.v4.content.ContextCompat
import android.support.v7.preference.Preference import android.support.v7.preference.Preference
import android.support.v7.preference.XpPreferenceFragment import android.support.v7.preference.XpPreferenceFragment
import android.view.View import android.view.View
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.getResourceId
import net.xpece.android.support.preference.PreferenceIconHelper
import net.xpece.android.support.preference.PreferenceScreenNavigationStrategy import net.xpece.android.support.preference.PreferenceScreenNavigationStrategy
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
@ -25,10 +21,6 @@ open class SettingsFragment : XpPreferenceFragment() {
lateinit var subscriptions: CompositeSubscription lateinit var subscriptions: CompositeSubscription
private val iconTint by lazy { ContextCompat.getColorStateList(context,
context.theme.getResourceId(R.attr.colorAccent, 0))
}
override final fun onCreatePreferences2(savedState: Bundle?, rootKey: String?) { override final fun onCreatePreferences2(savedState: Bundle?, rootKey: String?) {
subscriptions = CompositeSubscription() subscriptions = CompositeSubscription()
@ -40,18 +32,6 @@ open class SettingsFragment : XpPreferenceFragment() {
addPreferencesFromResource(R.xml.pref_advanced) addPreferencesFromResource(R.xml.pref_advanced)
addPreferencesFromResource(R.xml.pref_about) addPreferencesFromResource(R.xml.pref_about)
// Add an icon to each subscreen
for ((screen, drawable) in getSubscreenIcons()) {
val icon = VectorDrawableCompat.create(resources, drawable, context.theme) ?: continue
PreferenceIconHelper(findPreference(screen)).apply {
isIconPaddingEnabled = true
setIcon(icon)
tintList = iconTint
isIconTintEnabled = true
}
}
// Setup root preference title. // Setup root preference title.
preferenceScreen.title = activity.title preferenceScreen.title = activity.title
@ -74,16 +54,6 @@ open class SettingsFragment : XpPreferenceFragment() {
super.onDestroyView() super.onDestroyView()
} }
private fun getSubscreenIcons() = listOf(
"general_screen" to R.drawable.ic_tune_black_24dp,
"reader_screen" to R.drawable.ic_chrome_reader_mode_black_24dp,
"downloads_screen" to R.drawable.ic_file_download_black_24dp,
"sources_screen" to R.drawable.ic_language_black_24dp,
"sync_screen" to R.drawable.ic_sync_black_24dp,
"advanced_screen" to R.drawable.ic_code_black_24dp,
"about_screen" to R.drawable.ic_help_black_24dp
)
protected inline fun <reified T : Preference> bindPref(resId: Int): Lazy<T> { protected inline fun <reified T : Preference> bindPref(resId: Int): Lazy<T> {
return lazy { findPreference(getString(resId)) as T } return lazy { findPreference(getString(resId)) as T }
} }

View File

@ -7,8 +7,9 @@ import android.support.v7.preference.XpPreferenceFragment
import android.view.View import android.view.View
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.library.LibraryUpdateTrigger 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.util.LocaleHelper
import eu.kanade.tachiyomi.util.plusAssign import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.widget.preference.IntListPreference import eu.kanade.tachiyomi.widget.preference.IntListPreference
import eu.kanade.tachiyomi.widget.preference.LibraryColumnsDialog import eu.kanade.tachiyomi.widget.preference.LibraryColumnsDialog
@ -17,6 +18,7 @@ import net.xpece.android.support.preference.MultiSelectListPreference
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.util.*
class SettingsGeneralFragment : SettingsFragment(), class SettingsGeneralFragment : SettingsFragment(),
PreferenceFragmentCompat.OnPreferenceDisplayDialogCallback { PreferenceFragmentCompat.OnPreferenceDisplayDialogCallback {
@ -44,6 +46,8 @@ class SettingsGeneralFragment : SettingsFragment(),
val categoryUpdate: MultiSelectListPreference by bindPref(R.string.pref_library_update_categories_key) val categoryUpdate: MultiSelectListPreference by bindPref(R.string.pref_library_update_categories_key)
val langPreference: IntListPreference by bindPref(R.string.pref_language_key)
override fun onViewCreated(view: View, savedState: Bundle?) { override fun onViewCreated(view: View, savedState: Bundle?) {
super.onViewCreated(view, savedState) super.onViewCreated(view, savedState)
@ -57,19 +61,20 @@ class SettingsGeneralFragment : SettingsFragment(),
.subscribe { updateColumnsSummary(it.first, it.second) } .subscribe { updateColumnsSummary(it.first, it.second) }
updateInterval.setOnPreferenceChangeListener { preference, newValue -> updateInterval.setOnPreferenceChangeListener { preference, newValue ->
val interval = (newValue as String).toInt() // Always cancel the previous task, it seems that sometimes they are not updated.
if (interval > 0) LibraryUpdateJob.cancelTask()
LibraryUpdateTrigger.setupTask(context, interval)
else
LibraryUpdateTrigger.cancelTask(context)
val interval = (newValue as String).toInt()
if (interval > 0) {
LibraryUpdateJob.setupTask(interval)
}
true true
} }
updateRestriction.setOnPreferenceChangeListener { preference, newValue -> updateRestriction.setOnPreferenceChangeListener { preference, newValue ->
// Post to event looper to allow the preference to be updated. // Post to event looper to allow the preference to be updated.
subscriptions += Observable.fromCallable { subscriptions += Observable.fromCallable {
LibraryUpdateTrigger.setupTask(context) LibraryUpdateJob.setupTask()
}.subscribeOn(AndroidSchedulers.mainThread()).subscribe() }.subscribeOn(AndroidSchedulers.mainThread()).subscribe()
true true
@ -100,6 +105,15 @@ class SettingsGeneralFragment : SettingsFragment(),
activity.recreate() activity.recreate()
true true
} }
langPreference.setOnPreferenceChangeListener { preference, newValue ->
(activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_LANG_CHANGED
LocaleHelper.setLocale(Locale(LocaleHelper.intToLangCode(newValue.toString().toInt())))
LocaleHelper.updateCfg(activity.application, activity.baseContext.resources.configuration)
activity.recreate()
true
}
} }
override fun onPreferenceDisplayDialog(p0: PreferenceFragmentCompat?, p: Preference): Boolean { override fun onPreferenceDisplayDialog(p0: PreferenceFragmentCompat?, p: Preference): Boolean {

View File

@ -29,7 +29,7 @@ object ChapterRecognition {
* Regex used to remove unwanted tags * Regex used to remove unwanted tags
* Example Prison School 12 v.1 vol004 version1243 volume64 -R> Prison School 12 * Example Prison School 12 v.1 vol004 version1243 volume64 -R> Prison School 12
*/ */
private val unwanted = Regex("""(?:(v|ver|vol|version|volume|season).?[0-9]+)""") private val unwanted = Regex("""(?:(v|ver|vol|version|volume|season|s).?[0-9]+)""")
/** /**
* Regex used to remove unwanted whitespace * Regex used to remove unwanted whitespace

View File

@ -19,7 +19,7 @@ import java.util.*
fun syncChaptersWithSource(db: DatabaseHelper, fun syncChaptersWithSource(db: DatabaseHelper,
sourceChapters: List<Chapter>, sourceChapters: List<Chapter>,
manga: Manga, manga: Manga,
source: Source) : Pair<Int, Int> { source: Source) : Pair<List<Chapter>, List<Chapter>> {
// Chapters from db. // Chapters from db.
val dbChapters = db.getChapters(manga).executeAsBlocking() val dbChapters = db.getChapters(manga).executeAsBlocking()
@ -36,7 +36,7 @@ fun syncChaptersWithSource(db: DatabaseHelper,
// Recognize number for new chapters. // Recognize number for new chapters.
toAdd.forEach { toAdd.forEach {
if (source is OnlineSource) { if (source is OnlineSource) {
source.parseChapterNumber(it) source.prepareNewChapter(it, manga)
} }
ChapterRecognition.parseChapterNumber(it, manga) ChapterRecognition.parseChapterNumber(it, manga)
} }
@ -44,22 +44,19 @@ fun syncChaptersWithSource(db: DatabaseHelper,
// Chapters from the db not in the source. // Chapters from the db not in the source.
val toDelete = dbChapters.filterNot { it in sourceChapters } val toDelete = dbChapters.filterNot { it in sourceChapters }
// Amount of chapters added and deleted. val readded = mutableListOf<Chapter>()
var added = 0
var deleted = 0
// Amount of chapters readded (different url but the same chapter number).
var readded = 0
db.inTransaction { db.inTransaction {
val deletedChapterNumbers = TreeSet<Float>()
val deletedReadChapterNumbers = TreeSet<Float>() val deletedReadChapterNumbers = TreeSet<Float>()
if (!toDelete.isEmpty()) { if (!toDelete.isEmpty()) {
for (c in toDelete) { for (c in toDelete) {
if (c.read) { if (c.read) {
deletedReadChapterNumbers.add(c.chapter_number) deletedReadChapterNumbers.add(c.chapter_number)
} }
deletedChapterNumbers.add(c.chapter_number)
} }
deleted = db.deleteChapters(toDelete).executeAsBlocking().results().size db.deleteChapters(toDelete).executeAsBlocking()
} }
if (!toAdd.isEmpty()) { if (!toAdd.isEmpty()) {
@ -73,14 +70,16 @@ fun syncChaptersWithSource(db: DatabaseHelper,
// Try to mark already read chapters as read when the source deletes them // Try to mark already read chapters as read when the source deletes them
if (c.isRecognizedNumber && c.chapter_number in deletedReadChapterNumbers) { if (c.isRecognizedNumber && c.chapter_number in deletedReadChapterNumbers) {
c.read = true c.read = true
readded++ }
if (c.isRecognizedNumber && c.chapter_number in deletedChapterNumbers) {
readded.add(c)
} }
} }
added = db.insertChapters(toAdd).executeAsBlocking().numberOfInserts() db.insertChapters(toAdd).executeAsBlocking()
} }
// Fix order in source. // Fix order in source.
db.fixChaptersSourceOrder(sourceChapters).executeAsBlocking() db.fixChaptersSourceOrder(sourceChapters).executeAsBlocking()
} }
return Pair(added - readded, deleted - readded) return Pair(toAdd.subtract(readded).toList(), toDelete.subtract(readded).toList())
} }

View File

@ -1,10 +1,11 @@
package eu.kanade.tachiyomi.util package eu.kanade.tachiyomi.util
import android.app.AlarmManager
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.os.PowerManager
import android.support.annotation.StringRes import android.support.annotation.StringRes
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
import android.support.v4.content.ContextCompat import android.support.v4.content.ContextCompat
@ -54,8 +55,13 @@ val Context.notificationManager: NotificationManager
get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
/** /**
* Property to get the alarm manager from the context. * Property to get the connectivity manager from the context.
* @return the alarm manager.
*/ */
val Context.alarmManager: AlarmManager val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
/**
* Property to get the power manager from the context.
*/
val Context.powerManager: PowerManager
get() = getSystemService(Context.POWER_SERVICE) as PowerManager

View File

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.util
import java.io.File
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
object DiskUtil {
fun hashKeyForDisk(key: String): String {
return try {
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
val sb = StringBuilder()
bytes.forEach { byte ->
sb.append(Integer.toHexString(byte.toInt() and 0xFF or 0x100).substring(1, 3))
}
sb.toString()
} catch (e: NoSuchAlgorithmException) {
key.hashCode().toString()
}
}
fun getDirectorySize(f: File): Long {
var size: Long = 0
if (f.isDirectory) {
for (file in f.listFiles()) {
size += getDirectorySize(file)
}
} else {
size = f.length()
}
return size
}
/**
* Mutate the given filename to make it valid for a FAT filesystem,
* replacing any invalid characters with "_". This method doesn't allow private files (starting
* with a dot), but you can manually add it later.
*/
fun buildValidFilename(origName: String): String {
val name = origName.trim('.', ' ')
if (name.isNullOrEmpty()) {
return "(invalid)"
}
val sb = StringBuilder(name.length)
name.forEach { c ->
if (isValidFatFilenameChar(c)) {
sb.append(c)
} else {
sb.append('_')
}
}
// Even though vfat allows 255 UCS-2 chars, we might eventually write to
// ext4 through a FUSE layer, so use that limit minus 15 reserved characters.
return sb.toString().take(240)
}
/**
* Returns true if the given character is a valid filename character, false otherwise.
*/
private fun isValidFatFilenameChar(c: Char): Boolean {
if (0x00.toChar() <= c && c <= 0x1f.toChar()) {
return false
}
return when (c) {
'"', '*', '/', ':', '<', '>', '?', '\\', '|', 0x7f.toChar() -> false
else -> true
}
}
}

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