Compare commits

...

63 Commits

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

* italian language: fix aapt error

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

* Rename imagePath to uri

* Handle android < 21

* Minor changes

* Separate downloader from the manager. Optimize folder lookups

* Persist downloads across restarts

* Fix for #511

* Updated ReactiveNetwork. Add some documentation

* More documentation and minor fixes

* Handle persistent notifications. Other minor changes

* Improve downloader and add documentation

* Rename pageNumber to index in Page class

* Remove unused methods

* Use chop method

* Make sure dest dir is created

* Reset downloads dir preference

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

* Fix empty download queue after application restart

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

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

* Removed network call now copies from page image

* Format fix + notification feedback

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

* Now uses glide for notification

* Fixed webtoon page

* Fixes + API 16 support

* fixes

* Fixed API 24 FileProvider error

* Added page.ready check

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

* Fixes
2016-11-06 13:33:00 +01:00
len
b418169c20 Exclude backup empty fields 2016-11-06 13:31:01 +01:00
len
f4d12ba622 Update travis 2016-11-05 20:16:54 +01:00
len
c64d8c8b6b Fix tests 2016-11-05 19:41:52 +01:00
len
10a1ba95d6 Support API 25 again. Bump dependencies 2016-11-05 19:28:47 +01:00
27d3daf918 Add support for latest updates to Readmangatoday (#512) 2016-11-03 16:17:37 +01:00
7d3d0999f3 Fixed API 24 FileProvider error 2016-10-25 17:34:49 +02:00
93f90b5a62 fixes 2016-10-25 16:08:33 +02:00
c2b113ac0a Fixes + API 16 support 2016-10-25 15:49:27 +02:00
8ff8ab4f27 Fixed webtoon page 2016-10-25 15:49:22 +02:00
414b8c9f21 Now uses glide for notification 2016-10-25 15:49:21 +02:00
4975787afa Added code to prevent OutOfMemory error. Made notification optional. Can now save image on long press. Bug fixes 2016-10-25 15:49:20 +02:00
1210691fdd Format fix + notification feedback 2016-10-25 15:49:19 +02:00
2a4527a8d6 Removed network call now copies from page image 2016-10-25 15:49:18 +02:00
2991906a85 Added option to download page or set page as cover 2016-10-25 15:49:17 +02:00
125 changed files with 2921 additions and 1589 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 13 versionCode 16
versionName "0.3.2" versionName "0.4.0"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\"" buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\"" buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\"" buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
buildConfigField "boolean", "INCLUDE_UPDATER", "${includeUpdater()}"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@ -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:96d2c7f' 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.8.0'
// 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

@ -64,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.
@ -73,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

@ -3,6 +3,9 @@ package eu.kanade.tachiyomi
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
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 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 +30,7 @@ open class App : Application() {
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
setupAcra() setupAcra()
setupJobManager()
} }
override fun attachBaseContext(base: Context) { override fun attachBaseContext(base: Context) {
@ -40,4 +44,14 @@ open class App : Application() {
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,5 @@ object Constants {
const val NOTIFICATION_UPDATER_ID = 2 const val NOTIFICATION_UPDATER_ID = 2
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3 const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4 const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 5
} }

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.saveTo 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,35 +143,34 @@ class ChapterCache(private val context: Context) {
} }
/** /**
* Check if image is in cache. * Returns true if image is in cache.
*
* @param imageUrl url of image. * @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.
@ -191,7 +182,7 @@ class ChapterCache(private val context: Context) {
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.

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

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

@ -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,154 @@
package eu.kanade.tachiyomi.data.download package eu.kanade.tachiyomi.data.download
import android.content.Context import android.content.Context
import android.net.Uri import com.hippo.unifile.UniFile
import com.google.gson.Gson import com.jakewharton.rxrelay.BehaviorRelay
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.Source import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.util.*
import rx.Observable import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.BehaviorSubject
import rx.subjects.PublishSubject
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.FileReader
import java.util.*
class DownloadManager(
private val context: Context,
private val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
) {
private val gson = Gson()
private val downloadsQueueSubject = PublishSubject.create<List<Download>>()
val runningSubject = BehaviorSubject.create<Boolean>()
private var downloadsSubscription: Subscription? = null
val downloadNotifier by lazy { DownloadNotifier(context) }
private val threadsSubject = BehaviorSubject.create<Int>()
private var threadsSubscription: Subscription? = null
val queue = DownloadQueue()
val imageFilenameRegex = "[^\\sa-zA-Z0-9.-]".toRegex()
val PAGE_LIST_FILE = "index.json"
@Volatile var isRunning: Boolean = false
private set
private fun initializeSubscriptions() {
downloadsSubscription?.unsubscribe()
threadsSubscription = preferences.downloadThreads().asObservable()
.subscribe {
threadsSubject.onNext(it)
downloadNotifier.multipleDownloadThreads = it > 1
}
downloadsSubscription = downloadsQueueSubject.flatMap { Observable.from(it) }
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
// Delete successful downloads from queue
if (it.status == Download.DOWNLOADED) {
// remove downloaded chapter from queue
queue.del(it)
downloadNotifier.onProgressChange(queue)
}
if (areAllDownloadsFinished()) {
DownloadService.stop(context)
}
}, { error ->
DownloadService.stop(context)
Timber.e(error)
downloadNotifier.onError(error.message)
})
if (!isRunning) {
isRunning = true
runningSubject.onNext(true)
}
}
fun destroySubscriptions() {
if (isRunning) {
isRunning = false
runningSubject.onNext(false)
}
if (downloadsSubscription != null) {
downloadsSubscription?.unsubscribe()
downloadsSubscription = null
}
if (threadsSubscription != null) {
threadsSubscription?.unsubscribe()
}
}
// Create a download object for every chapter and add them to the downloads queue
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
// Add chapters to queue from the start
val sortedChapters = chapters.sortedByDescending { it.source_order }
// Used to avoid downloading chapters with the same name
val addedChapters = ArrayList<String>()
val pending = ArrayList<Download>()
for (chapter in sortedChapters) {
if (addedChapters.contains(chapter.name))
continue
addedChapters.add(chapter.name)
val download = Download(source, manga, chapter)
if (!prepareDownload(download)) {
queue.add(download)
pending.add(download)
}
}
// Initialize queue size
downloadNotifier.initialQueueSize = queue.size
// Show notification
downloadNotifier.onProgressChange(queue)
if (isRunning) downloadsQueueSubject.onNext(pending)
}
// Public method to check if a chapter is downloaded
fun isChapterDownloaded(source: Source, manga: Manga, chapter: Chapter): Boolean {
val directory = getAbsoluteChapterDirectory(source, manga, chapter)
if (!directory.exists())
return false
val pages = getSavedPageList(source, manga, chapter)
return isChapterDownloaded(directory, pages)
}
// Prepare the download. Returns true if the chapter is already downloaded
private fun prepareDownload(download: Download): Boolean {
// If the chapter is already queued, don't add it again
for (queuedDownload in queue) {
if (download.chapter.id == queuedDownload.chapter.id)
return true
}
// Add the directory to the download object for future access
download.directory = getAbsoluteChapterDirectory(download)
// If the directory doesn't exist, the chapter isn't downloaded.
if (!download.directory.exists()) {
return false
}
// If the page list doesn't exist, the chapter isn't downloaded
val savedPages = getSavedPageList(download) ?: return false
// Add the page list to the download object for future access
download.pages = savedPages
// If the number of files matches the number of pages, the chapter is downloaded.
// We have the index file, so we check one file more
return isChapterDownloaded(download.directory, download.pages)
}
// Check that all the images are downloaded
private fun isChapterDownloaded(directory: File, pages: List<Page>?): Boolean {
return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size
}
// Download the entire chapter
private fun downloadChapter(download: Download): Observable<Download> {
DiskUtils.createDirectory(download.directory)
val pageListObservable: Observable<List<Page>> = if (download.pages == null)
// Pull page list from network and add them to download object
download.source.fetchPageListFromNetwork(download.chapter)
.doOnNext { pages ->
download.pages = pages
savePageList(download)
}
else
// Or if the page list already exists, start from the file
Observable.just(download.pages)
return Observable.defer {
pageListObservable
.doOnNext { pages ->
download.downloadedImages = 0
download.status = Download.DOWNLOADING
}
// Get all the URLs to the source images, fetch pages if necessary
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
// Start downloading images, consider we can have downloaded images already
.concatMap { page -> getOrDownloadImage(page, download) }
// Do when page is downloaded.
.doOnNext {
downloadNotifier.onProgressChange(download, queue)
}
// Do after download completes
.doOnCompleted { onDownloadCompleted(download) }
.toList()
.map { pages -> download }
// If the page list threw, it will resume here
.onErrorResumeNext { error ->
download.status = Download.ERROR
downloadNotifier.onError(error.message, download.chapter.name)
Observable.just(download)
}
}.subscribeOn(Schedulers.io())
}
// Get the image from the filesystem if it exists or download from network
private fun getOrDownloadImage(page: Page, download: Download): Observable<Page> {
// If the image URL is empty, do nothing
if (page.imageUrl == null)
return Observable.just(page)
val filename = getImageFilename(page)
val imagePath = File(download.directory, filename)
// If the image is already downloaded, do nothing. Otherwise download from network
val pageObservable = if (isImageDownloaded(imagePath))
Observable.just(page)
else
downloadImage(page, download.source, download.directory, filename)
return pageObservable
// When the image is ready, set image path, progress (just in case) and status
.doOnNext {
page.imagePath = imagePath.absolutePath
page.progress = 100
download.downloadedImages++
page.status = Page.READY
}
// Mark this page as error and allow to download the remaining
.onErrorResumeNext {
page.progress = 0
page.status = Page.ERROR
Observable.just(page)
}
}
// Save image on disk
private fun downloadImage(page: Page, source: OnlineSource, directory: File, filename: String): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE
return source.imageResponse(page)
.map {
val file = File(directory, filename)
try {
file.parentFile.mkdirs()
it.body().source().saveTo(file.outputStream())
} catch (e: Exception) {
it.close()
file.delete()
throw e
}
page
}
// Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
.retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
}
// Public method to get the image from the filesystem. It does NOT provide any way to download the image
fun getDownloadedImage(page: Page, chapterDir: File): Observable<Page> {
if (page.imageUrl == null) {
page.status = Page.ERROR
return Observable.just(page)
}
val imagePath = File(chapterDir, getImageFilename(page))
// When the image is ready, set image path, progress (just in case) and status
if (isImageDownloaded(imagePath)) {
page.imagePath = imagePath.absolutePath
page.progress = 100
page.status = Page.READY
} else {
page.status = Page.ERROR
}
return Observable.just(page)
}
// Get the filename for an image given the page
private fun getImageFilename(page: Page): String {
val url = page.imageUrl
val number = String.format("%03d", page.pageNumber + 1)
// Try to preserve file extension
return when {
UrlUtil.isJpg(url) -> "$number.jpg"
UrlUtil.isPng(url) -> "$number.png"
UrlUtil.isGif(url) -> "$number.gif"
else -> Uri.parse(url).lastPathSegment.replace(imageFilenameRegex, "_")
}
}
private fun isImageDownloaded(imagePath: File): Boolean {
return imagePath.exists()
}
// Called when a download finishes. This doesn't mean the download was successful, so we check it
private fun onDownloadCompleted(download: Download) {
checkDownloadIsSuccessful(download)
savePageList(download)
}
private fun checkDownloadIsSuccessful(download: Download) {
var actualProgress = 0
var status = Download.DOWNLOADED
// If any page has an error, the download result will be error
for (page in download.pages!!) {
actualProgress += page.progress
if (page.status != Page.READY) {
status = Download.ERROR
downloadNotifier.onError(context.getString(R.string.download_notifier_page_ready_error), download.chapter.name)
}
}
// Ensure that the chapter folder has all the images
if (!isChapterDownloaded(download.directory, download.pages)) {
status = Download.ERROR
downloadNotifier.onError(context.getString(R.string.download_notifier_page_error), download.chapter.name)
}
download.totalProgress = actualProgress
download.status = status
}
// Return the page list from the chapter's directory if it exists, null otherwise
fun getSavedPageList(source: Source, manga: Manga, chapter: Chapter): List<Page>? {
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
return try {
JsonReader(FileReader(pagesFile)).use {
val collectionType = object : TypeToken<List<Page>>() {}.type
gson.fromJson(it, collectionType)
}
} catch (e: Exception) {
null
}
}
// Shortcut for the method above
private fun getSavedPageList(download: Download): List<Page>? {
return getSavedPageList(download.source, download.manga, download.chapter)
}
// Save the page list to the chapter's directory
fun savePageList(source: Source, manga: Manga, chapter: Chapter, pages: List<Page>) {
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
pagesFile.outputStream().use {
try {
it.write(gson.toJson(pages).toByteArray())
it.flush()
} catch (error: Exception) {
Timber.e(error)
}
}
}
// Shortcut for the method above
private fun savePageList(download: Download) {
savePageList(download.source, download.manga, download.chapter, download.pages!!)
}
fun getAbsoluteMangaDirectory(source: Source, manga: Manga): File {
val mangaRelativePath = source.toString() +
File.separator +
manga.title.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
return File(preferences.downloadsDirectory().getOrDefault(), mangaRelativePath)
}
// Get the absolute path to the chapter directory
fun getAbsoluteChapterDirectory(source: Source, manga: Manga, chapter: Chapter): File {
val chapterRelativePath = chapter.name.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
return File(getAbsoluteMangaDirectory(source, manga), chapterRelativePath)
}
// Shortcut for the method above
private fun getAbsoluteChapterDirectory(download: Download): File {
return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter)
}
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
val path = getAbsoluteChapterDirectory(source, manga, chapter)
DiskUtils.deleteFiles(path)
}
fun areAllDownloadsFinished(): Boolean {
for (download in queue) {
if (download.status <= Download.DOWNLOADING)
return false
}
return true
}
/**
* This class is used to manage chapter downloads in the application. It must be instantiated once
* and retrieved through dependency injection. You can use this class to queue new chapters or query
* downloaded chapters.
*
* @param context the application context.
*/
class DownloadManager(context: Context) {
/**
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
*/
private val provider = DownloadProvider(context)
/**
* Downloader whose only task is to download chapters.
*/
private val downloader = Downloader(context, provider)
/**
* Downloads queue, where the pending chapters are stored.
*/
val queue: DownloadQueue
get() = downloader.queue
/**
* Subject for subscribing to downloader status.
*/
val runningRelay: BehaviorRelay<Boolean>
get() = downloader.runningRelay
/**
* Tells the downloader to begin downloads.
*
* @return true if it's started, false otherwise (empty queue).
*/
fun startDownloads(): Boolean { fun startDownloads(): Boolean {
if (queue.isEmpty()) return downloader.start()
return false
if (downloadsSubscription == null || downloadsSubscription!!.isUnsubscribed)
initializeSubscriptions()
val pending = ArrayList<Download>()
for (download in queue) {
if (download.status != Download.DOWNLOADED) {
if (download.status != Download.QUEUE) download.status = Download.QUEUE
pending.add(download)
}
}
downloadsQueueSubject.onNext(pending)
return !pending.isEmpty()
} }
fun stopDownloads(errorMessage: String? = null) { /**
destroySubscriptions() * Tells the downloader to stop downloads.
for (download in queue) { *
if (download.status == Download.DOWNLOADING) { * @param reason an optional reason for being stopped, used to notify the user.
download.status = Download.ERROR */
} fun stopDownloads(reason: String? = null) {
} downloader.stop(reason)
errorMessage?.let { downloadNotifier.onError(it) }
} }
/**
* Empties the download queue.
*/
fun clearQueue() { fun clearQueue() {
queue.clear() downloader.clearQueue()
downloadNotifier.onClear() }
/**
* Tells the downloader to enqueue the given list of chapters.
*
* @param manga the manga of the chapters.
* @param chapters the list of chapters to enqueue.
*/
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
downloader.queueChapters(manga, chapters)
}
/**
* Builds the page list of a downloaded chapter.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the downloaded chapter.
* @return an observable containing the list of pages from the chapter.
*/
fun buildPageList(source: Source, manga: Manga, chapter: Chapter): Observable<List<Page>> {
return buildPageList(provider.findChapterDir(source, manga, chapter))
}
/**
* Builds the page list of a downloaded chapter.
*
* @param chapterDir the file where the chapter is downloaded.
* @return an observable containing the list of pages from the chapter.
*/
private fun buildPageList(chapterDir: UniFile?): Observable<List<Page>> {
return Observable.fromCallable {
val files = chapterDir?.listFiles().orEmpty()
.filter { "image" in it.type.orEmpty() }
if (files.isEmpty()) {
throw Exception("Page list is empty")
}
files.sortedBy { it.name }
.mapIndexed { i, file ->
Page(i, uri = file.uri).apply { status = Page.READY }
}
}
}
/**
* Returns the directory name for the given chapter.
*
* @param chapter the chapter to query.
*/
fun getChapterDirName(chapter: Chapter): String {
return provider.getChapterDirName(chapter)
}
/**
* Returns the directory for the given manga, if it exists.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
fun findMangaDir(source: Source, manga: Manga): UniFile? {
return provider.findMangaDir(source, manga)
}
/**
* Returns the directory for the given chapter, if it exists.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to query.
*/
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
return provider.findChapterDir(source, manga, chapter)
}
/**
* Deletes the directory of a downloaded chapter.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to delete.
*/
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
provider.findChapterDir(source, manga, chapter)?.delete()
} }
} }

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,98 @@
package eu.kanade.tachiyomi.data.download
import android.content.Context
import android.net.Uri
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.util.DiskUtil
import uy.kohesive.injekt.injectLazy
/**
* This class is used to provide the directories where the downloads should be saved.
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
*
* @param context the application context.
*/
class DownloadProvider(private val context: Context) {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* The root directory for downloads.
*/
private lateinit var downloadsDir: UniFile
init {
preferences.downloadsDirectory().asObservable()
.subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) }
}
/**
* Returns the download directory for a manga. For internal use only.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
internal fun getMangaDir(source: Source, manga: Manga): UniFile {
return downloadsDir
.createDirectory(getSourceDirName(source))
.createDirectory(getMangaDirName(manga))
}
/**
* Returns the download directory for a manga if it exists.
*
* @param source the source of the manga.
* @param manga the manga to query.
*/
fun findMangaDir(source: Source, manga: Manga): UniFile? {
val sourceDir = downloadsDir.findFile(getSourceDirName(source))
return sourceDir?.findFile(getMangaDirName(manga))
}
/**
* Returns the download directory for a chapter if it exists.
*
* @param source the source of the chapter.
* @param manga the manga of the chapter.
* @param chapter the chapter to query.
*/
fun findChapterDir(source: Source, manga: Manga, chapter: Chapter): UniFile? {
val mangaDir = findMangaDir(source, manga)
return mangaDir?.findFile(getChapterDirName(chapter))
}
/**
* Returns the download directory name for a source.
*
* @param source the source to query.
*/
fun getSourceDirName(source: Source): String {
return source.toString()
}
/**
* Returns the download directory name for a manga.
*
* @param manga the manga to query.
*/
fun getMangaDirName(manga: Manga): String {
return DiskUtil.buildValidFilename(manga.title)
}
/**
* Returns the chapter directory name for a chapter.
*
* @param chapter the chapter to query.
*/
fun getChapterDirName(chapter: Chapter): String {
return DiskUtil.buildValidFilename(chapter.name)
}
}

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
@ -21,10 +20,7 @@ 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
@ -73,7 +69,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 +139,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 +167,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.
@ -267,7 +259,6 @@ class LibraryUpdateService : Service() {
} else { } else {
showResultNotification(newUpdates, failedUpdates) showResultNotification(newUpdates, failedUpdates)
} }
LibraryUpdateTrigger.setupTask(this)
} }
} }
@ -328,7 +319,7 @@ class LibraryUpdateService : Service() {
* @return the body of the notification to display. * @return the body of the notification to display.
*/ */
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String { private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
return with(StringBuilder()) { return buildString {
if (updates.isEmpty()) { if (updates.isEmpty()) {
append(getString(R.string.notification_no_new_chapters)) append(getString(R.string.notification_no_new_chapters))
append("\n") append("\n")
@ -336,7 +327,7 @@ class LibraryUpdateService : Service() {
append(getString(R.string.notification_new_chapters)) append(getString(R.string.notification_new_chapters))
for (manga in updates) { for (manga in updates) {
append("\n") append("\n")
append(manga.title) append(manga.title.chop(45))
} }
} }
if (!failedUpdates.isEmpty()) { if (!failedUpdates.isEmpty()) {
@ -344,10 +335,9 @@ class LibraryUpdateService : Service() {
append(getString(R.string.notification_manga_update_failed)) append(getString(R.string.notification_manga_update_failed))
for (manga in failedUpdates) { for (manga in failedUpdates) {
append("\n") append("\n")
append(manga.title) append(manga.title.chop(45))
} }
} }
toString()
} }
} }
@ -376,7 +366,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 +382,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 +403,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 {

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)
@ -94,6 +95,6 @@ class PreferenceKeys(context: Context) {
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId" fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
val libraryAsList = context.getString(R.string.pref_display_library_as_list) val libraryAsList = context.getString(R.string.pref_display_library_as_list)
} }

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,7 +10,6 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
import eu.kanade.tachiyomi.data.source.Source import eu.kanade.tachiyomi.data.source.Source
import java.io.File import java.io.File
import java.io.IOException
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!! fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
@ -20,17 +20,9 @@ class PreferencesHelper(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val rxPrefs = RxSharedPreferences.create(prefs) private val rxPrefs = RxSharedPreferences.create(prefs)
private val defaultDownloadsDir = File(Environment.getExternalStorageDirectory().absolutePath + private val defaultDownloadsDir = Uri.fromFile(
File.separator + context.getString(R.string.app_name), "downloads") File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
context.getString(R.string.app_name), "downloads"))
init {
// Don't display downloaded chapters in gallery apps creating a ".nomedia" file
try {
File(downloadsDirectory().getOrDefault(), ".nomedia").createNewFile()
} catch (e: IOException) {
/* Ignore */
}
}
fun startScreen() = prefs.getInt(keys.startScreen, 1) fun startScreen() = prefs.getInt(keys.startScreen, 1)
@ -112,7 +104,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)

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,5 +1,6 @@
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
@ -416,7 +417,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 }
@ -461,8 +462,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

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

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

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

@ -185,15 +185,10 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
} }
if (prefFilterDownloaded) { if (prefFilterDownloaded) {
val mangaDir = downloadManager.getAbsoluteMangaDirectory(source, manga) val mangaDir = downloadManager.findMangaDir(source, manga)
if (mangaDir.exists()) { if (mangaDir != null) {
for (file in mangaDir.listFiles()) { hasDownloaded = mangaDir.listFiles()?.any { it.isDirectory } ?: false
if (file.isDirectory && file.listFiles().isNotEmpty()) {
hasDownloaded = true
break
}
}
} }
} }

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

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)

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

@ -224,6 +224,20 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
toast(error.message) toast(error.message)
} }
fun onLongPress(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 -> presenter.setCover(page)
1 -> shareImage(page)
2 -> presenter.savePage(page)
}
}.show()
}
fun onChapterAppendError() { fun onChapterAppendError() {
// Ignore // Ignore
} }
@ -250,7 +264,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) {
@ -317,7 +331,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 +339,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) {
@ -455,6 +469,23 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
} }
} }
/**
* Start a share intent that lets user share image
*
* @param page page object containing image information.
*/
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 brightness of the screen. Range is [-75, 100]. * Sets the brightness of the screen. Range is [-75, 100].
* From -75 to -1 a semi-transparent black view is shown at the top with the minimum brightness. * From -75 to -1 a semi-transparent black view is shown at the top with the minimum brightness.

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
val path = page.imagePath val uri = page.uri
if (!path.isNullOrEmpty() && !page.chapter.isDownloaded) { if (uri != null && !page.chapter.isDownloaded) {
chapterCache.removeFileFromCache(File(path).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,81 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
db.insertManga(manga).executeAsBlocking() db.insertManga(manga).executeAsBlocking()
} }
/**
* Update cover with page file.
*/
internal fun setCover(page: Page) {
if (page.status != Page.READY)
return
try {
if (manga.favorite) {
if (manga.thumbnail_url != null) {
val input = context.contentResolver.openInputStream(page.uri)
coverCache.copyToCache(manga.thumbnail_url!!, input)
context.toast(R.string.cover_updated)
} else {
throw Exception("Image url not found")
}
} 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,108 @@
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.net.Uri
import android.support.v4.content.FileProvider
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.notificationManager
import java.io.File
/**
* 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_SHARE_IMAGE -> {
shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION))
context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, 5))
}
ACTION_SHOW_IMAGE ->
showImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION))
ACTION_DELETE_IMAGE -> {
deleteImage(intent.getStringExtra(EXTRA_FILE_LOCATION))
context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, 5))
}
}
}
/**
* Called to delete image
* @param path path of file
*/
private fun deleteImage(path: String) {
val file = File(path)
if (file.exists()) file.delete()
}
/**
* Called to start share intent to share image
* @param context context of application
* @param path path of file
*/
private fun shareImage(context: Context, path: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
putExtra(Intent.EXTRA_STREAM, Uri.parse(path))
type = "image/*"
}
context.startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
}
/**
* Called to show image in gallery application
* @param context context of application
* @param path path of file
*/
private fun showImage(context: Context, path: String) {
val intent = Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", File(path))
setDataAndType(uri, "image/*")
}
context.startActivity(intent)
}
companion object {
private const val ACTION_SHARE_IMAGE = "eu.kanade.SHARE_IMAGE"
private const val ACTION_SHOW_IMAGE = "eu.kanade.SHOW_IMAGE"
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"
internal fun shareImageIntent(context: Context, path: String, notificationId: Int): PendingIntent {
val intent = Intent(context, ImageNotificationReceiver::class.java).apply {
action = ACTION_SHARE_IMAGE
putExtra(EXTRA_FILE_LOCATION, path)
putExtra(NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
internal fun showImageIntent(context: Context, path: String): PendingIntent {
val intent = Intent(context, ImageNotificationReceiver::class.java).apply {
action = ACTION_SHOW_IMAGE
putExtra(EXTRA_FILE_LOCATION, path)
}
return PendingIntent.getBroadcast(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, notificationId))
// 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,9 +16,9 @@ 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.
@ -26,9 +26,9 @@ abstract class BaseReader : BaseFragment() {
const val SKIA_DECODER = 1 const val SKIA_DECODER = 1
/** /**
* Image decoder. * Rapid decoder.
*/ */
const val IMAGE_DECODER = 2 const val RAPID_DECODER = 2
} }
/** /**
@ -95,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
@ -125,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
} }
} }
@ -205,17 +205,17 @@ abstract class BaseReader : BaseFragment() {
*/ */
fun setDecoderClass(value: Int) { fun setDecoderClass(value: Int) {
when (value) { when (value) {
RAPID_DECODER -> { IMAGE_DECODER -> {
bitmapDecoderClass = RapidImageDecoder::class.java bitmapDecoderClass = IImageDecoder::class.java
regionDecoderClass = RapidImageRegionDecoder::class.java regionDecoderClass = IImageRegionDecoder::class.java
} }
SKIA_DECODER -> { SKIA_DECODER -> {
bitmapDecoderClass = SkiaImageDecoder::class.java bitmapDecoderClass = SkiaImageDecoder::class.java
regionDecoderClass = SkiaImageRegionDecoder::class.java regionDecoderClass = SkiaImageRegionDecoder::class.java
} }
IMAGE_DECODER -> { RAPID_DECODER -> {
bitmapDecoderClass = IImageDecoder::class.java bitmapDecoderClass = RapidImageDecoder::class.java
regionDecoderClass = IImageRegionDecoder::class.java regionDecoderClass = RapidImageRegionDecoder::class.java
} }
} }
} }

View File

@ -8,6 +8,7 @@ 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
@ -22,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)
@ -208,13 +208,20 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
* Called when the page is ready. * Called when the page is ready.
*/ */
private fun setImage() { private fun setImage() {
val path = page.imagePath val uri = page.uri
if (path != null && File(path).exists()) { if (uri == null) {
progress_text.visibility = View.INVISIBLE
image_view.setImage(ImageSource.uri(path))
} else {
page.status = Page.ERROR 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.setImage(ImageSource.uri(file.uri))
} }
/** /**

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
/** /**
@ -185,6 +186,16 @@ abstract class PagerReader : BaseReader() {
} }
return true return true
} }
override fun onLongPress(e: MotionEvent?) {
if (isAdded) {
val page = adapter.pages.getOrNull(pager.currentItem)
if (page != null)
readerActivity.onLongPress(page)
else
context.toast(getString(R.string.unknown_error))
}
}
}) })
} }

View File

@ -6,6 +6,7 @@ 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.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
@ -18,7 +19,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
/** /**
@ -242,14 +242,21 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
* Called when the page is ready. * Called when the page is ready.
*/ */
private fun setImage() = with(view) { private fun setImage() = with(view) {
val path = page?.imagePath val uri = page?.uri
if (path != null && File(path).exists()) { if (uri == null) {
progress_text.visibility = View.INVISIBLE
image_view.visibility = View.VISIBLE
image_view.setImage(ImageSource.uri(path))
} else {
page?.status = Page.ERROR 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))
} }
/** /**

View File

@ -6,9 +6,11 @@ import android.view.*
import android.view.GestureDetector.SimpleOnGestureListener 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.R
import eu.kanade.tachiyomi.data.source.model.Page import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderChapter 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.util.toast
import eu.kanade.tachiyomi.widget.PreCachingLayoutManager import eu.kanade.tachiyomi.widget.PreCachingLayoutManager
import rx.subscriptions.CompositeSubscription import rx.subscriptions.CompositeSubscription
@ -114,7 +116,7 @@ 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)
} }
@ -140,17 +142,28 @@ class WebtoonReader : BaseReader() {
} }
return true return true
} }
override fun onLongPress(e: MotionEvent) {
if (isAdded) {
val child = recycler.findChildViewUnder(e.rawX, e.rawY)
val position = recycler.getChildAdapterPosition(child)
val page = adapter.pages?.getOrNull(position)
if (page != null)
readerActivity.onLongPress(page)
else
context.toast(getString(R.string.unknown_error))
}
}
}) })
} }
/** /**
* 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 +173,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 +196,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) {

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
@ -97,7 +98,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)
@ -142,18 +146,29 @@ 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>) {
val cachedDirs = mutableMapOf<Long, UniFile?>()
chapters.forEach { chapter ->
val manga = chapter.manga
val mangaDir = cachedDirs.getOrPut(manga.id!!)
{ downloadManager.findMangaDir(sourceManager.get(manga.source)!!, manga) }
if (mangaDir?.findFile(downloadManager.getChapterDirName(chapter)) != null) {
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 +222,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 +229,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 +261,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

@ -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,7 +7,7 @@ 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.plusAssign import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.widget.preference.IntListPreference import eu.kanade.tachiyomi.widget.preference.IntListPreference
@ -57,19 +57,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

View File

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

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

View File

@ -1,69 +0,0 @@
package eu.kanade.tachiyomi.util;
import java.io.File;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public final class DiskUtils {
private DiskUtils() {
throw new AssertionError();
}
public static String hashKeyForDisk(String key) {
String cacheKey;
try {
final MessageDigest mDigest = MessageDigest.getInstance("MD5");
mDigest.update(key.getBytes());
cacheKey = bytesToHexString(mDigest.digest());
} catch (NoSuchAlgorithmException e) {
cacheKey = String.valueOf(key.hashCode());
}
return cacheKey;
}
private static String bytesToHexString(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < bytes.length; i++) {
String hex = Integer.toHexString(0xFF & bytes[i]);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString();
}
public static void deleteFiles(File inputFile) {
if (inputFile.isDirectory()) {
for (File childFile : inputFile.listFiles()) {
deleteFiles(childFile);
}
}
//noinspection ResultOfMethodCallIgnored
inputFile.delete();
}
public static synchronized void createDirectory(File directory) throws IOException {
if (!directory.exists() && !directory.mkdirs()) {
throw new IOException("Failed creating directory");
}
}
public static long getDirectorySize(File f) {
long size = 0;
if (f.isDirectory()) {
for (File file : f.listFiles()) {
size += getDirectorySize(file);
}
} else {
size=f.length();
}
return size;
}
}

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.util
/**
* Replaces the given string to have at most [count] characters using [replacement] at its end.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.chop(count: Int, replacement: String = "..."): String {
return if (length > count)
take(count - replacement.length) + replacement
else
this
}

View File

@ -5,10 +5,6 @@ import java.net.URISyntaxException;
public final class UrlUtil { public final class UrlUtil {
private static final String JPG = ".jpg";
private static final String PNG = ".png";
private static final String GIF = ".gif";
private UrlUtil() throws InstantiationException { private UrlUtil() throws InstantiationException {
throw new InstantiationException("This class is not for instantiation"); throw new InstantiationException("This class is not for instantiation");
} }
@ -27,36 +23,4 @@ public final class UrlUtil {
} }
} }
public static boolean isJpg(String url) {
return containsIgnoreCase(url, JPG);
}
public static boolean isPng(String url) {
return containsIgnoreCase(url, PNG);
}
public static boolean isGif(String url) {
return containsIgnoreCase(url, GIF);
}
public static boolean containsIgnoreCase(String src, String what) {
final int length = what.length();
if (length == 0)
return true; // Empty string is contained
final char firstLo = Character.toLowerCase(what.charAt(0));
final char firstUp = Character.toUpperCase(what.charAt(0));
for (int i = src.length() - length; i >= 0; i--) {
// Quick check before calling the more expensive regionMatches() method:
final char ch = src.charAt(i);
if (ch != firstLo && ch != firstUp)
continue;
if (src.regionMatches(true, i, what, 0, length))
return true;
}
return false;
}
} }

View File

@ -1,83 +0,0 @@
package eu.kanade.tachiyomi.widget
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.util.AttributeSet
import android.view.View
class DividerItemDecoration : RecyclerView.ItemDecoration {
private val divider: Drawable?
constructor(context: Context, attrs: AttributeSet) {
val a = context.obtainStyledAttributes(attrs, intArrayOf(android.R.attr.listDivider))
divider = a.getDrawable(0)
a.recycle()
}
constructor(divider: Drawable) {
this.divider = divider
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State?) {
super.getItemOffsets(outRect, view, parent, state)
if (divider == null) return
if (parent.getChildPosition(view) < 1) return
if (getOrientation(parent) == LinearLayoutManager.VERTICAL)
outRect.top = divider.intrinsicHeight
else
outRect.left = divider.intrinsicWidth
}
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State?) {
if (divider == null) {
super.onDrawOver(c, parent, state)
return
}
if (getOrientation(parent) == LinearLayoutManager.VERTICAL) {
val left = parent.paddingLeft
val right = parent.width - parent.paddingRight
val childCount = parent.childCount
val dividerHeight = divider.intrinsicHeight
for (i in 1..childCount - 1) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val ty = (child.translationY + 0.5f).toInt()
val top = child.top - params.topMargin + ty
val bottom = top + dividerHeight
divider.setBounds(left, top, right, bottom)
divider.draw(c)
}
} else { //horizontal
val top = parent.paddingTop
val bottom = parent.height - parent.paddingBottom
val childCount = parent.childCount
for (i in 1..childCount - 1) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val size = divider.intrinsicWidth
val left = child.left - params.leftMargin
val right = left + size
divider.setBounds(left, top, right, bottom)
divider.draw(c)
}
}
}
private fun getOrientation(parent: RecyclerView): Int {
if (parent.layoutManager is LinearLayoutManager) {
val layoutManager = parent.layoutManager as LinearLayoutManager
return layoutManager.orientation
} else
throw IllegalStateException("DividerItemDecoration can only be used with a LinearLayoutManager.")
}
}

View File

@ -6,6 +6,10 @@ import android.support.v7.widget.RecyclerView
class PreCachingLayoutManager(context: Context) : LinearLayoutManager(context) { class PreCachingLayoutManager(context: Context) : LinearLayoutManager(context) {
init {
isItemPrefetchEnabled = false
}
companion object { companion object {
const val DEFAULT_EXTRA_LAYOUT_SPACE = 600 const val DEFAULT_EXTRA_LAYOUT_SPACE = 600
} }

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.widget.preference
import android.annotation.TargetApi import android.annotation.TargetApi
import android.content.Context import android.content.Context
import android.content.res.TypedArray import android.content.res.TypedArray
import android.os.Build
import android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH import android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH
import android.support.v7.preference.PreferenceViewHolder import android.support.v7.preference.PreferenceViewHolder
import android.support.v7.widget.SwitchCompat import android.support.v7.widget.SwitchCompat
@ -11,7 +10,6 @@ import android.util.AttributeSet
import android.view.View import android.view.View
import android.widget.Checkable import android.widget.Checkable
import android.widget.CompoundButton import android.widget.CompoundButton
import android.widget.Switch
import eu.kanade.tachiyomi.util.getResourceColor import eu.kanade.tachiyomi.util.getResourceColor
import net.xpece.android.support.preference.PreferenceCategory import net.xpece.android.support.preference.PreferenceCategory
import net.xpece.android.support.preference.R import net.xpece.android.support.preference.R
@ -52,16 +50,12 @@ CompoundButton.OnCheckedChangeListener {
if (view is SwitchCompat) { if (view is SwitchCompat) {
view.setOnCheckedChangeListener(null) view.setOnCheckedChangeListener(null)
} else if (NATIVE_SWITCH_CAPABLE && view is Switch) {
view.setOnCheckedChangeListener(null)
} }
view.toggle() view.toggle()
if (view is SwitchCompat) { if (view is SwitchCompat) {
view.setOnCheckedChangeListener(this) view.setOnCheckedChangeListener(this)
} else if (NATIVE_SWITCH_CAPABLE && view is Switch) {
view.setOnCheckedChangeListener(this)
} }
} }
} }
@ -130,8 +124,4 @@ CompoundButton.OnCheckedChangeListener {
defaultValue as Boolean) defaultValue as Boolean)
} }
companion object {
private val NATIVE_SWITCH_CAPABLE = Build.VERSION.SDK_INT >= ICE_CREAM_SANDWICH
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

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