Compare commits

...

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

View File

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

View File

@ -28,27 +28,22 @@ ext {
}
}
def includeUpdater() {
return hasProperty("include_updater")
}
android {
compileSdkVersion 24
buildToolsVersion "24.0.2"
compileSdkVersion 25
buildToolsVersion "25.0.1"
publishNonDefault true
defaultConfig {
applicationId "eu.kanade.tachiyomi"
minSdkVersion 16
targetSdkVersion 24
targetSdkVersion 25
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 12
versionName "0.3.1"
versionCode 16
versionName "0.4.0"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
buildConfigField "boolean", "INCLUDE_UPDATER", "${includeUpdater()}"
vectorDrawables.useSupportLibrary = true
@ -71,6 +66,16 @@ android {
}
}
productFlavors {
standard {
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
}
fdroid {
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
}
}
packagingOptions {
exclude 'META-INF/DEPENDENCIES'
exclude 'LICENSE.txt'
@ -93,11 +98,10 @@ android {
dependencies {
// Modified dependencies
compile 'com.github.inorichi:subsampling-scale-image-view:2d9c854'
compile 'com.github.inorichi:ReactiveNetwork:69092ed'
compile 'com.github.inorichi:subsampling-scale-image-view:f687b74'
// 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:appcompat-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.google.android.gms:play-services-gcm:9.6.1'
// ReactiveX
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.github.pwittchen:reactivenetwork:0.6.0'
// 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
final retrofit_version = '2.1.0'
@ -124,24 +129,26 @@ dependencies {
compile "com.squareup.retrofit2:converter-gson:$retrofit_version"
compile "com.squareup.retrofit2:adapter-rxjava:$retrofit_version"
// IO
compile 'com.squareup.okio:okio:1.10.0'
// 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'
// YAML
compile 'com.github.bmoliveira:snake-yaml:v1.18-android'
// 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.github.seven332:unifile:1.0.0'
// Parse HTML
compile 'org.jsoup:jsoup:1.9.2'
// HTML parser
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
compile 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
@ -176,8 +183,8 @@ dependencies {
compile 'eu.davidea:flexible-adapter:4.2.0'
compile 'com.nononsenseapps:filepicker:2.5.2'
compile 'com.github.amulyakhare:TextDrawable:558677e'
compile 'com.afollestad.material-dialogs:core:0.9.0.2'
compile 'net.xpece.android:support-preference:1.0.3'
compile 'com.afollestad.material-dialogs:core:0.9.1.0'
compile 'net.xpece.android:support-preference:1.2.0'
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
compile 'de.hdodenhof:circleimageview:2.1.0'
@ -185,15 +192,17 @@ dependencies {
testCompile 'junit:junit:4.12'
testCompile 'org.assertj:assertj-core:1.7.1'
testCompile 'org.mockito:mockito-core:1.10.19'
testCompile 'org.robolectric:robolectric:3.1.2'
testCompile 'org.robolectric:shadows-multidex:3.1.2'
testCompile 'org.robolectric:shadows-play-services:3.1.2'
final robolectric_version = '3.1.4'
testCompile "org.robolectric:robolectric:$robolectric_version"
testCompile "org.robolectric:shadows-multidex:$robolectric_version"
testCompile "org.robolectric:shadows-play-services:$robolectric_version"
compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}
buildscript {
ext.kotlin_version = '1.0.4'
ext.kotlin_version = '1.0.5-2'
repositories {
mavenCentral()
}

View File

@ -2,6 +2,9 @@
-keep class eu.kanade.tachiyomi.**
-keep class com.hippo.image.** { *; }
-keep interface com.hippo.image.** { *; }
# OkHttp
-keepattributes Signature
-keepattributes *Annotation*
@ -61,7 +64,10 @@
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
# removes such information by default, so configure it to keep all of it.
@ -70,55 +76,18 @@
# For using GSON @Expose annotation
-keepattributes *Annotation*
-keepattributes EnclosingMethod
# Gson specific classes
-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
-renamesourcefileattribute SourceFile
-keepattributes SourceFile,LineNumberTable
# ACRA needs "annotations" so add this...
-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.** { *; }
# Prevent proguard from stripping interface information from TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# SnakeYaml
-keep class org.yaml.snakeyaml.** { public protected private *; }

View File

@ -54,6 +54,16 @@
android:theme="@style/FilePickerTheme">
</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"
android:exported="false"/>
@ -63,32 +73,14 @@
<service android:name=".data.mangasync.UpdateMangaSyncService"
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"
android:exported="false"/>
<receiver android:name=".data.updater.UpdateNotificationReceiver"/>
<receiver
android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver">
</receiver>
<receiver android:name=".data.library.LibraryUpdateService$CancelUpdateReceiver" />
<receiver android:name=".ui.reader.notification.ImageNotificationReceiver" />
<meta-data
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"

View File

@ -3,6 +3,9 @@ package eu.kanade.tachiyomi
import android.app.Application
import android.content.Context
import android.support.multidex.MultiDex
import com.evernote.android.job.JobManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import org.acra.ACRA
import org.acra.annotation.ReportsCrashes
import timber.log.Timber
@ -27,6 +30,7 @@ open class App : Application() {
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
setupAcra()
setupJobManager()
}
override fun attachBaseContext(base: Context) {
@ -40,4 +44,14 @@ open class App : Application() {
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_DOWNLOAD_CHAPTER_ID = 3
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.google.gson.*
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.IntegerSerializer
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.*
import java.io.*
@ -42,7 +44,9 @@ class BackupManager(private val db: DatabaseHelper) {
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
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())
.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.FieldAttributes
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl
class IdExclusion : ExclusionStrategy {
@ -15,10 +15,10 @@ class IdExclusion : ExclusionStrategy {
private val syncExclusions = listOf("id", "manga_id", "update")
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
Manga::class.java -> mangaExclusions.contains(f.name)
Chapter::class.java -> chapterExclusions.contains(f.name)
MangaSync::class.java -> syncExclusions.contains(f.name)
Category::class.java -> categoryExclusions.contains(f.name)
MangaImpl::class.java -> mangaExclusions.contains(f.name)
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
MangaSyncImpl::class.java -> syncExclusions.contains(f.name)
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
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.text.format.Formatter
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.jakewharton.disklrucache.DiskLruCache
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.DiskUtils
import eu.kanade.tachiyomi.util.saveImageTo
import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.saveTo
import okhttp3.Response
import okio.Okio
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.IOException
import java.lang.reflect.Type
/**
* Class used to create chapter cache
@ -26,15 +26,6 @@ import java.lang.reflect.Type
*/
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 {
/** Name of cache directory. */
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
}
init {
// Open cache in default cache directory.
diskCache = DiskLruCache.open(
File(context.cacheDir, PARAMETER_CACHE_DIRECTORY),
PARAMETER_APP_VERSION,
PARAMETER_VALUE_COUNT,
PARAMETER_CACHE_SIZE)
}
/** Google Json class used for parsing JSON files. */
private val gson: Gson by injectLazy()
/** Cache class used for cache management. */
private val diskCache = DiskLruCache.open(
File(context.externalCacheDir, PARAMETER_CACHE_DIRECTORY),
PARAMETER_APP_VERSION,
PARAMETER_VALUE_COUNT,
PARAMETER_CACHE_SIZE)
/**
* Returns directory of cache.
* @return directory of cache.
*/
val cacheDir: File
get() = diskCache.directory
/**
* Returns real size of directory.
* @return real size of directory.
*/
private val realSize: Long
get() = DiskUtils.getDirectorySize(cacheDir)
get() = DiskUtil.getDirectorySize(cacheDir)
/**
* Returns real size of directory in human readable format.
* @return real size of directory.
*/
val readableSize: String
get() = Formatter.formatFileSize(context, realSize)
/**
* Remove file from cache.
*
* @param file name of file "md5.0".
* @return status of deletion for the file.
*/
@ -101,23 +91,25 @@ class ChapterCache(private val context: Context) {
/**
* Get page list from cache.
*
* @param chapterUrl the url of the chapter.
* @return an observable of the list of pages.
*/
fun getPageListFromCache(chapterUrl: String): Observable<List<Page>> {
return Observable.fromCallable<List<Page>> {
// 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
diskCache.get(key).use {
gson.fromJson(it.getString(0), pageListCollection)
gson.fromJson<List<Page>>(it.getString(0))
}
}
}
/**
* Add page list to disk cache.
*
* @param chapterUrl the url of the chapter.
* @param pages list of pages.
*/
@ -130,7 +122,7 @@ class ChapterCache(private val context: Context) {
try {
// Get editor from md5 key.
val key = DiskUtils.hashKeyForDisk(chapterUrl)
val key = DiskUtil.hashKeyForDisk(chapterUrl)
editor = diskCache.edit(key) ?: return
// Write chapter urls to cache.
@ -151,51 +143,50 @@ class ChapterCache(private val context: Context) {
}
/**
* Check if image is in cache.
* Returns true if image is in cache.
*
* @param imageUrl url of image.
* @return true if in cache otherwise false.
*/
fun isImageInCache(imageUrl: String): Boolean {
try {
return diskCache.get(DiskUtils.hashKeyForDisk(imageUrl)) != null
return diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null
} catch (e: IOException) {
return false
}
}
/**
* Get image path from url.
* Get image file from url.
*
* @param imageUrl url of image.
* @return path of image.
*/
fun getImagePath(imageUrl: String): String? {
try {
// Get file from md5 key.
val imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0"
return File(diskCache.directory, imageName).canonicalPath
} catch (e: IOException) {
return null
}
fun getImageFile(imageUrl: String): File {
// Get file from md5 key.
val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0"
return File(diskCache.directory, imageName)
}
/**
* Add image to cache.
*
* @param imageUrl url of image.
* @param response http response from page.
* @throws IOException image error.
*/
@Throws(IOException::class)
fun putImageToCache(imageUrl: String, response: Response, reencode: Boolean) {
fun putImageToCache(imageUrl: String, response: Response) {
// Initialize editor (edits the values for an entry).
var editor: DiskLruCache.Editor? = null
try {
// 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")
// Get OutputStream and write image with Okio.
response.body().source().saveImageTo(editor.newOutputStream(0), reencode)
response.body().source().saveTo(editor.newOutputStream(0))
diskCache.flush()
editor.commit()

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.data.cache
import android.content.Context
import eu.kanade.tachiyomi.util.DiskUtils
import eu.kanade.tachiyomi.util.DiskUtil
import java.io.File
import java.io.IOException
import java.io.InputStream
@ -29,7 +29,7 @@ class CoverCache(private val context: Context) {
* @return cover image.
*/
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.
*/
const val DATABASE_VERSION = 3
const val DATABASE_VERSION = 4
}
override fun onCreate(db: SQLiteDatabase) = with(db) {
@ -48,6 +48,9 @@ class DbOpenHelper(context: Context)
db.execSQL(HistoryTable.createTableQuery)
db.execSQL(HistoryTable.createChapterIdIndexQuery)
}
if (oldVersion < 4) {
db.execSQL(ChapterTable.bookmarkUpdateQuery)
}
}
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 eu.kanade.tachiyomi.data.database.models.Chapter
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_DATE_FETCH
import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_DATE_UPLOAD
@ -41,12 +42,13 @@ class ChapterPutResolver : DefaultPutResolver<Chapter>() {
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: Chapter) = ContentValues(10).apply {
override fun mapToContentValues(obj: Chapter) = ContentValues(11).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_URL, obj.url)
put(COL_NAME, obj.name)
put(COL_READ, obj.read)
put(COL_BOOKMARK, obj.bookmark)
put(COL_DATE_FETCH, obj.date_fetch)
put(COL_DATE_UPLOAD, obj.date_upload)
put(COL_LAST_PAGE_READ, obj.last_page_read)
@ -63,6 +65,7 @@ class ChapterGetResolver : DefaultGetResolver<Chapter>() {
url = cursor.getString(cursor.getColumnIndex(COL_URL))
name = cursor.getString(cursor.getColumnIndex(COL_NAME))
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_upload = cursor.getLong(cursor.getColumnIndex(COL_DATE_UPLOAD))
last_page_read = cursor.getInt(cursor.getColumnIndex(COL_LAST_PAGE_READ))

View File

@ -14,6 +14,8 @@ interface Chapter : Serializable {
var read: Boolean
var bookmark: Boolean
var last_page_read: Int
var date_fetch: Long

View File

@ -12,6 +12,8 @@ class ChapterImpl : Chapter {
override var read: Boolean = false
override var bookmark: Boolean = false
override var last_page_read: Int = 0
override var date_fetch: Long = 0

View File

@ -84,6 +84,10 @@ interface Manga : Serializable {
get() = chapter_flags and 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
get() = chapter_flags and SORTING_MASK
set(sort) = setFlags(sort, SORTING_MASK)
@ -110,6 +114,10 @@ interface Manga : Serializable {
const val SHOW_NOT_DOWNLOADED = 0x00000010
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_NUMBER = 0x00000100
const val SORTING_MASK = 0x00000100

View File

@ -33,6 +33,15 @@ interface ChapterQueries : DbProvider {
.withGetResolver(MangaChapterGetResolver.INSTANCE)
.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 insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()

View File

@ -25,8 +25,9 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() {
.whereArgs(chapter.id)
.build()
fun mapToContentValues(chapter: Chapter) = ContentValues(2).apply {
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
put(ChapterTable.COL_READ, chapter.read)
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
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_BOOKMARK = "bookmark"
const val COL_DATE_FETCH = "date_fetch"
const val COL_DATE_UPLOAD = "date_upload"
@ -31,6 +33,7 @@ object ChapterTable {
$COL_URL TEXT NOT NULL,
$COL_NAME TEXT NOT NULL,
$COL_READ BOOLEAN NOT NULL,
$COL_BOOKMARK BOOLEAN NOT NULL,
$COL_LAST_PAGE_READ INT NOT NULL,
$COL_CHAPTER_NUMBER FLOAT NOT NULL,
$COL_SOURCE_ORDER INTEGER NOT NULL,
@ -46,4 +49,7 @@ object ChapterTable {
val sourceOrderUpdateQuery: String
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
import android.content.Context
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.R
import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.BehaviorRelay
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.preference.getOrDefault
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.online.OnlineSource
import eu.kanade.tachiyomi.util.*
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.BehaviorSubject
import rx.subjects.PublishSubject
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.FileReader
import java.util.*
class DownloadManager(
private val context: Context,
private val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
) {
private val gson = Gson()
private val downloadsQueueSubject = PublishSubject.create<List<Download>>()
val runningSubject = BehaviorSubject.create<Boolean>()
private var downloadsSubscription: Subscription? = null
val downloadNotifier by lazy { DownloadNotifier(context) }
private val threadsSubject = BehaviorSubject.create<Int>()
private var threadsSubscription: Subscription? = null
val queue = DownloadQueue()
val imageFilenameRegex = "[^\\sa-zA-Z0-9.-]".toRegex()
val PAGE_LIST_FILE = "index.json"
@Volatile var isRunning: Boolean = false
private set
private fun initializeSubscriptions() {
downloadsSubscription?.unsubscribe()
threadsSubscription = preferences.downloadThreads().asObservable()
.subscribe {
threadsSubject.onNext(it)
downloadNotifier.multipleDownloadThreads = it > 1
}
downloadsSubscription = downloadsQueueSubject.flatMap { Observable.from(it) }
.lift(DynamicConcurrentMergeOperator<Download, Download>({ downloadChapter(it) }, threadsSubject))
.onBackpressureBuffer()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
// Delete successful downloads from queue
if (it.status == Download.DOWNLOADED) {
// remove downloaded chapter from queue
queue.del(it)
downloadNotifier.onProgressChange(queue)
}
if (areAllDownloadsFinished()) {
DownloadService.stop(context)
}
}, { error ->
DownloadService.stop(context)
Timber.e(error)
downloadNotifier.onError(error.message)
})
if (!isRunning) {
isRunning = true
runningSubject.onNext(true)
}
}
fun destroySubscriptions() {
if (isRunning) {
isRunning = false
runningSubject.onNext(false)
}
if (downloadsSubscription != null) {
downloadsSubscription?.unsubscribe()
downloadsSubscription = null
}
if (threadsSubscription != null) {
threadsSubscription?.unsubscribe()
}
}
// Create a download object for every chapter and add them to the downloads queue
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return
// Add chapters to queue from the start
val sortedChapters = chapters.sortedByDescending { it.source_order }
// Used to avoid downloading chapters with the same name
val addedChapters = ArrayList<String>()
val pending = ArrayList<Download>()
for (chapter in sortedChapters) {
if (addedChapters.contains(chapter.name))
continue
addedChapters.add(chapter.name)
val download = Download(source, manga, chapter)
if (!prepareDownload(download)) {
queue.add(download)
pending.add(download)
}
}
// Initialize queue size
downloadNotifier.initialQueueSize = queue.size
// Show notification
downloadNotifier.onProgressChange(queue)
if (isRunning) downloadsQueueSubject.onNext(pending)
}
// Public method to check if a chapter is downloaded
fun isChapterDownloaded(source: Source, manga: Manga, chapter: Chapter): Boolean {
val directory = getAbsoluteChapterDirectory(source, manga, chapter)
if (!directory.exists())
return false
val pages = getSavedPageList(source, manga, chapter)
return isChapterDownloaded(directory, pages)
}
// Prepare the download. Returns true if the chapter is already downloaded
private fun prepareDownload(download: Download): Boolean {
// If the chapter is already queued, don't add it again
for (queuedDownload in queue) {
if (download.chapter.id == queuedDownload.chapter.id)
return true
}
// Add the directory to the download object for future access
download.directory = getAbsoluteChapterDirectory(download)
// If the directory doesn't exist, the chapter isn't downloaded.
if (!download.directory.exists()) {
return false
}
// If the page list doesn't exist, the chapter isn't downloaded
val savedPages = getSavedPageList(download) ?: return false
// Add the page list to the download object for future access
download.pages = savedPages
// If the number of files matches the number of pages, the chapter is downloaded.
// We have the index file, so we check one file more
return isChapterDownloaded(download.directory, download.pages)
}
// Check that all the images are downloaded
private fun isChapterDownloaded(directory: File, pages: List<Page>?): Boolean {
return pages != null && !pages.isEmpty() && pages.size + 1 == directory.listFiles().size
}
// Download the entire chapter
private fun downloadChapter(download: Download): Observable<Download> {
DiskUtils.createDirectory(download.directory)
val pageListObservable: Observable<List<Page>> = if (download.pages == null)
// Pull page list from network and add them to download object
download.source.fetchPageListFromNetwork(download.chapter)
.doOnNext { pages ->
download.pages = pages
savePageList(download)
}
else
// Or if the page list already exists, start from the file
Observable.just(download.pages)
return Observable.defer {
pageListObservable
.doOnNext { pages ->
download.downloadedImages = 0
download.status = Download.DOWNLOADING
}
// Get all the URLs to the source images, fetch pages if necessary
.flatMap { download.source.fetchAllImageUrlsFromPageList(it) }
// Start downloading images, consider we can have downloaded images already
.concatMap { page -> getOrDownloadImage(page, download) }
// Do when page is downloaded.
.doOnNext {
downloadNotifier.onProgressChange(download, queue)
}
// Do after download completes
.doOnCompleted { onDownloadCompleted(download) }
.toList()
.map { pages -> download }
// If the page list threw, it will resume here
.onErrorResumeNext { error ->
download.status = Download.ERROR
downloadNotifier.onError(error.message, download.chapter.name)
Observable.just(download)
}
}.subscribeOn(Schedulers.io())
}
// Get the image from the filesystem if it exists or download from network
private fun getOrDownloadImage(page: Page, download: Download): Observable<Page> {
// If the image URL is empty, do nothing
if (page.imageUrl == null)
return Observable.just(page)
val filename = getImageFilename(page)
val imagePath = File(download.directory, filename)
// If the image is already downloaded, do nothing. Otherwise download from network
val pageObservable = if (isImageDownloaded(imagePath))
Observable.just(page)
else
downloadImage(page, download.source, download.directory, filename)
return pageObservable
// When the image is ready, set image path, progress (just in case) and status
.doOnNext {
page.imagePath = imagePath.absolutePath
page.progress = 100
download.downloadedImages++
page.status = Page.READY
}
// Mark this page as error and allow to download the remaining
.onErrorResumeNext {
page.progress = 0
page.status = Page.ERROR
Observable.just(page)
}
}
// Save image on disk
private fun downloadImage(page: Page, source: OnlineSource, directory: File, filename: String): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE
return source.imageResponse(page)
.map {
val file = File(directory, filename)
try {
file.parentFile.mkdirs()
it.body().source().saveImageTo(file.outputStream(), preferences.reencodeImage())
} catch (e: Exception) {
it.close()
file.delete()
throw e
}
page
}
// Retry 3 times, waiting 2, 4 and 8 seconds between attempts.
.retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline()))
}
// Public method to get the image from the filesystem. It does NOT provide any way to download the image
fun getDownloadedImage(page: Page, chapterDir: File): Observable<Page> {
if (page.imageUrl == null) {
page.status = Page.ERROR
return Observable.just(page)
}
val imagePath = File(chapterDir, getImageFilename(page))
// When the image is ready, set image path, progress (just in case) and status
if (isImageDownloaded(imagePath)) {
page.imagePath = imagePath.absolutePath
page.progress = 100
page.status = Page.READY
} else {
page.status = Page.ERROR
}
return Observable.just(page)
}
// Get the filename for an image given the page
private fun getImageFilename(page: Page): String {
val url = page.imageUrl
val number = String.format("%03d", page.pageNumber + 1)
// Try to preserve file extension
return when {
UrlUtil.isJpg(url) -> "$number.jpg"
UrlUtil.isPng(url) -> "$number.png"
UrlUtil.isGif(url) -> "$number.gif"
else -> Uri.parse(url).lastPathSegment.replace(imageFilenameRegex, "_")
}
}
private fun isImageDownloaded(imagePath: File): Boolean {
return imagePath.exists()
}
// Called when a download finishes. This doesn't mean the download was successful, so we check it
private fun onDownloadCompleted(download: Download) {
checkDownloadIsSuccessful(download)
savePageList(download)
}
private fun checkDownloadIsSuccessful(download: Download) {
var actualProgress = 0
var status = Download.DOWNLOADED
// If any page has an error, the download result will be error
for (page in download.pages!!) {
actualProgress += page.progress
if (page.status != Page.READY) {
status = Download.ERROR
downloadNotifier.onError(context.getString(R.string.download_notifier_page_ready_error), download.chapter.name)
}
}
// Ensure that the chapter folder has all the images
if (!isChapterDownloaded(download.directory, download.pages)) {
status = Download.ERROR
downloadNotifier.onError(context.getString(R.string.download_notifier_page_error), download.chapter.name)
}
download.totalProgress = actualProgress
download.status = status
}
// Return the page list from the chapter's directory if it exists, null otherwise
fun getSavedPageList(source: Source, manga: Manga, chapter: Chapter): List<Page>? {
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
return try {
JsonReader(FileReader(pagesFile)).use {
val collectionType = object : TypeToken<List<Page>>() {}.type
gson.fromJson(it, collectionType)
}
} catch (e: Exception) {
null
}
}
// Shortcut for the method above
private fun getSavedPageList(download: Download): List<Page>? {
return getSavedPageList(download.source, download.manga, download.chapter)
}
// Save the page list to the chapter's directory
fun savePageList(source: Source, manga: Manga, chapter: Chapter, pages: List<Page>) {
val chapterDir = getAbsoluteChapterDirectory(source, manga, chapter)
val pagesFile = File(chapterDir, PAGE_LIST_FILE)
pagesFile.outputStream().use {
try {
it.write(gson.toJson(pages).toByteArray())
it.flush()
} catch (error: Exception) {
Timber.e(error)
}
}
}
// Shortcut for the method above
private fun savePageList(download: Download) {
savePageList(download.source, download.manga, download.chapter, download.pages!!)
}
fun getAbsoluteMangaDirectory(source: Source, manga: Manga): File {
val mangaRelativePath = source.toString() +
File.separator +
manga.title.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
return File(preferences.downloadsDirectory().getOrDefault(), mangaRelativePath)
}
// Get the absolute path to the chapter directory
fun getAbsoluteChapterDirectory(source: Source, manga: Manga, chapter: Chapter): File {
val chapterRelativePath = chapter.name.replace("[^\\sa-zA-Z0-9.-]".toRegex(), "_")
return File(getAbsoluteMangaDirectory(source, manga), chapterRelativePath)
}
// Shortcut for the method above
private fun getAbsoluteChapterDirectory(download: Download): File {
return getAbsoluteChapterDirectory(download.source, download.manga, download.chapter)
}
fun deleteChapter(source: Source, manga: Manga, chapter: Chapter) {
val path = getAbsoluteChapterDirectory(source, manga, chapter)
DiskUtils.deleteFiles(path)
}
fun areAllDownloadsFinished(): Boolean {
for (download in queue) {
if (download.status <= Download.DOWNLOADING)
return false
}
return true
}
/**
* This class is used to manage chapter downloads in the application. It must be instantiated once
* and retrieved through dependency injection. You can use this class to queue new chapters or query
* downloaded chapters.
*
* @param context the application context.
*/
class DownloadManager(context: Context) {
/**
* Downloads provider, used to retrieve the folders where the chapters are or should be stored.
*/
private val provider = DownloadProvider(context)
/**
* Downloader whose only task is to download chapters.
*/
private val downloader = Downloader(context, provider)
/**
* Downloads queue, where the pending chapters are stored.
*/
val queue: DownloadQueue
get() = downloader.queue
/**
* Subject for subscribing to downloader status.
*/
val runningRelay: BehaviorRelay<Boolean>
get() = downloader.runningRelay
/**
* Tells the downloader to begin downloads.
*
* @return true if it's started, false otherwise (empty queue).
*/
fun startDownloads(): Boolean {
if (queue.isEmpty())
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()
return downloader.start()
}
fun stopDownloads(errorMessage: String? = null) {
destroySubscriptions()
for (download in queue) {
if (download.status == Download.DOWNLOADING) {
download.status = Download.ERROR
}
}
errorMessage?.let { downloadNotifier.onError(it) }
/**
* Tells the downloader to stop downloads.
*
* @param reason an optional reason for being stopped, used to notify the user.
*/
fun stopDownloads(reason: String? = null) {
downloader.stop(reason)
}
/**
* Empties the download queue.
*/
fun clearQueue() {
queue.clear()
downloadNotifier.onClear()
downloader.clearQueue()
}
/**
* 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
import android.content.Context
import android.graphics.BitmapFactory
import android.support.v4.app.NotificationCompat
import eu.kanade.tachiyomi.Constants
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.download.model.DownloadQueue
import eu.kanade.tachiyomi.util.chop
import eu.kanade.tachiyomi.util.notificationManager
/**
@ -13,17 +15,14 @@ import eu.kanade.tachiyomi.util.notificationManager
*
* @param context context of application
*/
class DownloadNotifier(private val context: Context) {
internal class DownloadNotifier(private val context: Context) {
/**
* Notification builder.
*/
private val notificationBuilder = NotificationCompat.Builder(context)
/**
* Id of the notification.
*/
private val notificationId: Int
get() = Constants.NOTIFICATION_DOWNLOAD_CHAPTER_ID
private val notification by lazy {
NotificationCompat.Builder(context)
.setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher))
}
/**
* 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.
*/
internal var initialQueueSize = 0
var initialQueueSize = 0
/**
* 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.
@ -46,47 +62,47 @@ class DownloadNotifier(private val context: Context) {
*
* @param queue the queue containing downloads.
*/
internal fun onProgressChange(queue: DownloadQueue) {
fun onProgressChange(queue: DownloadQueue) {
if (multipleDownloadThreads) {
doOnProgressChange(null, queue)
}
}
/**
* Called when download progress changes
* Note: Only accepted when single download active
* Called when download progress changes.
* Note: Only accepted when single download active.
*
* @param download download object containing download information
* @param queue the queue containing downloads
* @param download download object containing download information.
* @param queue the queue containing downloads.
*/
internal fun onProgressChange(download: Download, queue: DownloadQueue) {
fun onProgressChange(download: Download, queue: DownloadQueue) {
if (!multipleDownloadThreads) {
doOnProgressChange(download, queue)
}
}
/**
* Show notification progress of chapter
* Show notification progress of chapter.
*
* @param download download object containing download information
* @param queue the queue containing downloads
* @param download download object containing download information.
* @param queue the queue containing downloads.
*/
private fun doOnProgressChange(download: Download?, queue: DownloadQueue) {
// Check if download is completed
if (multipleDownloadThreads) {
if (queue.isEmpty()) {
onComplete(null)
onChapterCompleted(null)
return
}
} else {
if (download != null && download.pages!!.size == download.downloadedImages) {
onComplete(download)
onChapterCompleted(download)
return
}
}
// Create notification
with (notificationBuilder) {
with(notification) {
// Check if icon needs refresh
if (!isDownloading) {
setSmallIcon(android.R.drawable.stat_sys_download)
@ -105,11 +121,7 @@ class DownloadNotifier(private val context: Context) {
setProgress(initialQueueSize, initialQueueSize - queue.size, false)
} else {
download?.let {
if (it.chapter.name.length >= 33)
setContentTitle(it.chapter.name.slice(IntRange(0, 30)).plus("..."))
else
setContentTitle(it.chapter.name)
setContentTitle(it.chapter.name.chop(30))
setContentText(context.getString(R.string.chapter_downloading_progress)
.format(it.downloadedImages, it.pages!!.size))
setProgress(it.pages!!.size, it.downloadedImages, false)
@ -118,17 +130,17 @@ class DownloadNotifier(private val context: Context) {
}
}
// 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.
with(notificationBuilder) {
with(notification) {
setContentTitle(download?.chapter?.name ?: context.getString(R.string.app_name))
setContentText(context.getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)
@ -136,7 +148,7 @@ class DownloadNotifier(private val context: Context) {
}
// Show notification.
context.notificationManager.notify(notificationId, notificationBuilder.build())
notification.show()
// Reset initial values
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() {
context.notificationManager.cancel(notificationId)
fun onWarning(reason: String) {
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 chapter string containing chapter title
* @param error string containing error information.
* @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
with(notificationBuilder) {
setContentTitle(chapter ?: context.getString(R.string.download_notifier_title_error))
with(notification) {
setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title))
setContentText(error ?: context.getString(R.string.download_notifier_unkown_error))
setSmallIcon(android.R.drawable.stat_sys_warning)
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
onClear()
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.content.Context
import android.content.Intent
import android.net.NetworkInfo.State.CONNECTED
import android.net.NetworkInfo.State.DISCONNECTED
import android.os.IBinder
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.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.R
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 rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
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() {
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) {
context.startService(Intent(context, DownloadService::class.java))
}
/**
* Stops this service.
*
* @param context the application context.
*/
fun stop(context: Context) {
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
private var queueRunningSubscription: Subscription? = null
private var isRunning: Boolean = false
/**
* Preferences helper.
*/
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() {
super.onCreate()
createWakeLock()
listenQueueRunningChanges()
runningRelay.call(true)
subscriptions = CompositeSubscription()
listenDownloaderState()
listenNetworkChanges()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return Service.START_STICKY
}
/**
* Called when the service is destroyed.
*/
override fun onDestroy() {
queueRunningSubscription?.unsubscribe()
networkChangeSubscription?.unsubscribe()
downloadManager.destroySubscriptions()
destroyWakeLock()
runningRelay.call(false)
subscriptions.unsubscribe()
downloadManager.stopDownloads()
wakeLock.releaseIfNeeded()
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? {
return null
}
/**
* Listens to network changes.
*
* @see onNetworkStateChanged
*/
private fun listenNetworkChanges() {
networkChangeSubscription = ReactiveNetwork().enableInternetCheck()
.observeConnectivity(applicationContext)
subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ 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))
}
}
}
.subscribe({ state -> onNetworkStateChanged(state)
}, { error ->
toast(R.string.download_queue_error)
stopSelf()
})
}
private fun listenQueueRunningChanges() {
queueRunningSubscription = downloadManager.runningSubject.subscribe { running ->
isRunning = running
/**
* Called when the network state changes.
*
* @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)
acquireWakeLock()
wakeLock.acquireIfNeeded()
else
releaseWakeLock()
wakeLock.releaseIfNeeded()
}
}
private fun createWakeLock() {
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "DownloadService:WakeLock")
/**
* Releases the wake lock if it's held.
*/
fun PowerManager.WakeLock.releaseIfNeeded() {
if (isHeld) release()
}
private fun destroyWakeLock() {
if (wakeLock != null && wakeLock!!.isHeld) {
wakeLock!!.release()
wakeLock = null
}
}
fun acquireWakeLock() {
if (wakeLock != null && !wakeLock!!.isHeld) {
wakeLock!!.acquire()
}
}
fun releaseWakeLock() {
if (wakeLock != null && wakeLock!!.isHeld) {
wakeLock!!.release()
}
/**
* Acquires the wake lock if it's not held.
*/
fun PowerManager.WakeLock.acquireIfNeeded() {
if (!isHeld) acquire()
}
}

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.online.OnlineSource
import rx.subjects.PublishSubject
import java.io.File
class Download(val source: OnlineSource, val manga: Manga, val chapter: Chapter) {
lateinit var directory: File
var pages: List<Page>? = null
@Volatile @Transient var totalProgress: Int = 0

View File

@ -1,38 +1,52 @@
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.download.DownloadStore
import eu.kanade.tachiyomi.data.source.model.Page
import rx.Observable
import rx.subjects.PublishSubject
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 {
private val statusSubject = PublishSubject.create<Download>()
private val removeSubject = PublishSubject.create<Download>()
private val updatedRelay = PublishRelay.create<Unit>()
fun add(download: Download): Boolean {
download.setStatusSubject(statusSubject)
download.status = Download.QUEUE
return queue.add(download)
fun addAll(downloads: List<Download>) {
downloads.forEach { download ->
download.setStatusSubject(statusSubject)
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)
store.remove(download)
download.setStatusSubject(null)
if (removed) {
removeSubject.onNext(download)
updatedRelay.call(Unit)
}
}
fun del(chapter: Chapter) {
find { it.chapter.id == chapter.id }?.let { del(it) }
fun remove(chapter: Chapter) {
find { it.chapter.id == chapter.id }?.let { remove(it) }
}
fun clear() {
queue.forEach { del(it) }
queue.forEach { download ->
download.setStatusSubject(null)
}
queue.clear()
store.clear()
updatedRelay.call(Unit)
}
fun getActiveDownloads(): Observable<Download> =
@ -40,7 +54,9 @@ class DownloadQueue(private val queue: MutableList<Download> = CopyOnWriteArrayL
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> {
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.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.IBinder
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.online.OnlineSource
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.AndroidComponentUtil
import eu.kanade.tachiyomi.util.notification
import eu.kanade.tachiyomi.util.notificationManager
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import eu.kanade.tachiyomi.util.*
import rx.Observable
import rx.Subscription
import rx.schedulers.Schedulers
@ -73,7 +69,9 @@ class LibraryUpdateService : Service() {
private val notificationId: Int
get() = Constants.NOTIFICATION_LIBRARY_ID
private var notificationBitmap: Bitmap? = null
private val notificationBitmap by lazy {
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
companion object {
@ -141,8 +139,6 @@ class LibraryUpdateService : Service() {
*/
override fun onDestroy() {
subscription?.unsubscribe()
notificationBitmap?.recycle()
notificationBitmap = null
destroyWakeLock()
super.onDestroy()
}
@ -171,10 +167,6 @@ class LibraryUpdateService : Service() {
// Update favorite manga. Destroy service when completed or in case of an error.
subscription = Observable
.defer {
if (notificationBitmap == null) {
notificationBitmap = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
val mangaList = getMangaToUpdate(intent)
// Update either chapter list or manga details.
@ -267,7 +259,6 @@ class LibraryUpdateService : Service() {
} else {
showResultNotification(newUpdates, failedUpdates)
}
LibraryUpdateTrigger.setupTask(this)
}
}
@ -328,7 +319,7 @@ class LibraryUpdateService : Service() {
* @return the body of the notification to display.
*/
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
return with(StringBuilder()) {
return buildString {
if (updates.isEmpty()) {
append(getString(R.string.notification_no_new_chapters))
append("\n")
@ -336,7 +327,7 @@ class LibraryUpdateService : Service() {
append(getString(R.string.notification_new_chapters))
for (manga in updates) {
append("\n")
append(manga.title)
append(manga.title.chop(45))
}
}
if (!failedUpdates.isEmpty()) {
@ -344,10 +335,9 @@ class LibraryUpdateService : Service() {
append(getString(R.string.notification_manga_update_failed))
for (manga in failedUpdates) {
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.
*/
private fun showNotification(title: String, body: String) {
notificationManager.notify(notificationId, notification() {
notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
setContentTitle(title)
@ -392,7 +382,7 @@ class LibraryUpdateService : Service() {
* @param total the total progress.
*/
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)
setLargeIcon(notificationBitmap)
setContentTitle(manga.title)
@ -413,7 +403,7 @@ class LibraryUpdateService : Service() {
val title = getString(R.string.notification_update_completed)
val body = getUpdatedMangasBody(updates, failed)
notificationManager.notify(notificationId, notification() {
notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
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> {
return Observable.create { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber.
val call = if (!isExecuted) this else {
// 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())
}
val call = clone()
// Wrap the call in a helper which handles both unsubscription and backpressure.
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
* referenced here.
*/
@Suppress("HasPlatformType")
class PreferenceKeys(context: Context) {
val theme = context.getString(R.string.pref_theme_key)
@ -44,8 +45,6 @@ class PreferenceKeys(context: Context) {
val readWithVolumeKeys = context.getString(R.string.pref_read_with_volume_keys_key)
val reencodeImage = context.getString(R.string.pref_reencode_key)
val portraitColumns = context.getString(R.string.pref_library_columns_portrait_key)
val landscapeColumns = context.getString(R.string.pref_library_columns_landscape_key)
@ -96,6 +95,6 @@ class PreferenceKeys(context: Context) {
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
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.preference.PreferenceManager
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.source.Source
import java.io.File
import java.io.IOException
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
@ -20,17 +20,9 @@ class PreferencesHelper(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private val rxPrefs = RxSharedPreferences.create(prefs)
private val defaultDownloadsDir = 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 */
}
}
private val defaultDownloadsDir = Uri.fromFile(
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
context.getString(R.string.app_name), "downloads"))
fun startScreen() = prefs.getInt(keys.startScreen, 1)
@ -70,8 +62,6 @@ class PreferencesHelper(context: Context) {
fun readWithVolumeKeys() = rxPrefs.getBoolean(keys.readWithVolumeKeys, false)
fun reencodeImage() = prefs.getBoolean(keys.reencodeImage, false)
fun portraitColumns() = rxPrefs.getInteger(keys.portraitColumns, 0)
fun landscapeColumns() = rxPrefs.getInteger(keys.landscapeColumns, 0)
@ -114,7 +104,7 @@ class PreferencesHelper(context: Context) {
.apply()
}
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.absolutePath)
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)

View File

@ -1,16 +1,20 @@
package eu.kanade.tachiyomi.data.source.model
import android.net.Uri
import eu.kanade.tachiyomi.data.network.ProgressListener
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import rx.subjects.Subject
class Page(
val pageNumber: Int,
val url: String,
val index: Int,
val url: String = "",
var imageUrl: String? = null,
@Transient var imagePath: String? = null
@Transient var uri: Uri? = null
) : ProgressListener {
val number: Int
get() = index + 1
@Transient lateinit var chapter: ReaderChapter
@Transient @Volatile var status: Int = 0

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.source.online
import android.net.Uri
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
@ -416,7 +417,7 @@ abstract class OnlineSource() : Source {
}
}
.doOnNext {
page.imagePath = chapterCache.getImagePath(imageUrl)
page.uri = Uri.fromFile(chapterCache.getImageFile(imageUrl))
page.status = Page.READY
}
.doOnError { page.status = Page.ERROR }
@ -431,7 +432,7 @@ abstract class OnlineSource() : Source {
private fun cacheImage(page: Page): Observable<Page> {
page.status = Page.DOWNLOAD_IMAGE
return imageResponse(page)
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it, preferences.reencodeImage()) }
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
.map { page }
}
@ -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 {
val dateAsString = dateElement.text()
val date: Date
var date: Date
try {
date = SimpleDateFormat("dd MMMMM yyyy - hh:mm a", Locale.ENGLISH).parse(dateAsString)
} 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 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.name = urlElement.text()
chapter.name = urlElement.text() + volume + title
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
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
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.Language
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.util.*
import java.text.SimpleDateFormat
import java.util.regex.Pattern
class Mangasee(override val id: Int) : ParsedOnlineSource() {
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 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 {
put("second", Calendar.SECOND)
put("minute", Calendar.MINUTE)
put("hour", Calendar.HOUR)
put("day", Calendar.DATE)
put("week", Calendar.WEEK_OF_YEAR)
put("month", Calendar.MONTH)
put("year", Calendar.YEAR)
private val indexPattern = Pattern.compile("-index-(.*?)-")
override fun popularMangaInitialUrl() = "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending"
override fun popularMangaSelector() = "div.requested > div.row"
override fun popularMangaRequest(page: MangasPage): Request {
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 {
put("from now", 1)
put("ago", -1)
override fun popularMangaParse(response: Response, page: MangasPage) {
val document = response.asJsoup()
for (element in document.select(popularMangaSelector())) {
Manga.create(id).apply {
popularMangaFromElement(element, this)
page.mangas.add(this)
}
}
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) {
element.select("td > h2 > a").first().let {
manga.setUrlWithoutDomain("/${it.attr("href")}")
element.select("a.resultLink").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
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>) =
"$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) {
element.select("a").first().let {
manga.setUrlWithoutDomain("/${it.attr("href")}")
element.select("a.resultLink").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
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) {
val detailElement = document.select("div.well > div.row").first()
manga.author = detailElement.select("a[href^=../search_result.php?author_name=]").first()?.text()
manga.genre = detailElement.select("div > div.row > div:has(b:contains(Genre:)) > a").map { it.text() }.joinToString()
manga.author = detailElement.select("a[href^=/search/?author=]").first()?.text()
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.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")
}
private fun parseStatus(status: String) = when {
status.contains("Ongoing") -> Manga.ONGOING
status.contains("Completed") -> Manga.COMPLETED
status.contains("Ongoing (Scan)") -> Manga.ONGOING
status.contains("Complete (Scan)") -> Manga.COMPLETED
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) {
val urlElement = element.select("a").first()
chapter.setUrlWithoutDomain("/${urlElement.attr("href")}")
chapter.name = urlElement.text()
chapter.date_upload = element.select("span").first()?.text()?.let { parseChapterDate(it) } ?: 0
chapter.setUrlWithoutDomain(urlElement.attr("href"))
chapter.name = element.select("span.chapterLabel").first().text()?.let { it } ?: ""
chapter.date_upload = element.select("time").first()?.attr("datetime")?.let { parseChapterDate(it) } ?: 0
}
private fun parseChapterDate(dateAsString: String): Long {
val m = datePattern.matcher(dateAsString)
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
}
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(dateAsString).time
}
override fun pageListParse(response: Response, pages: MutableList<Page>) {
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 chapter = document.select("input[name=chapter]").first().attr("value")
val index = document.select("input[name=index]").first().attr("value")
val series = document.select("input.IndexName").first().attr("value")
val chapter = document.select("span.CurChapter").first().text()
var index = ""
document.select("select[name=page] > option").forEach {
pages.add(Page(pages.size, "$url/?series=$series&chapter=$chapter&index=$index&page=${pages.size + 1}"))
val m = indexPattern.matcher(fullUrl)
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)
}
@ -128,7 +166,7 @@ class Mangasee(override val id: Int) : ParsedOnlineSource() {
override fun pageListParse(document: Document, pages: MutableList<Page>) {
}
override fun imageUrlParse(document: Document) = document.select("div > a > img").attr("src")
override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src")
// [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
// http://mangasee.co/advanced-search/
@ -171,20 +209,45 @@ class Mangasee(override val id: Int) : ParsedOnlineSource() {
Filter("Yuri", "Yuri")
)
override fun latestUpdatesInitialUrl(): String {
throw UnsupportedOperationException("not implemented")
override fun latestUpdatesInitialUrl(): String = "http://mangaseeonline.net/home/latest.request.php"
// Not used, overrides parent.
override fun latestUpdatesNextPageSelector(): String = ""
override fun latestUpdatesSelector(): String = "a.latestSeries"
override fun latestUpdatesRequest(page: MangasPage): Request {
if (page.page == 1) {
page.url = latestUpdatesInitialUrl()
}
val (body, requestUrl) = convertQueryToPost(page)
return POST(requestUrl, headers, body.build())
}
override fun latestUpdatesNextPageSelector(): String {
throw UnsupportedOperationException("not implemented")
override fun latestUpdatesParse(response: Response, page: MangasPage) {
val document = response.asJsoup()
for (element in document.select(latestUpdatesSelector())) {
Manga.create(id).apply {
latestUpdatesFromElement(element, this)
page.mangas.add(this)
}
}
page.nextPageUrl = page.url
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
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 supportsLatest = false
override val supportsLatest = true
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 latestUpdatesInitialUrl() = "$baseUrl/latest-releases/"
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) {
element.select("div.title > h2 > a").first().let {
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 latestUpdatesNextPageSelector(): String = "div.hot-manga > ul.pagination > li > a:contains(»)"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/service/advanced_search"
@ -183,21 +193,4 @@ class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
Filter("36", "Yaoi"),
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
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.Language
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.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
@ -25,13 +26,25 @@ class Mangachan(override val id: Int) : ParsedOnlineSource() {
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 latestUpdatesSelector() = "div.content_row"
override fun latestUpdatesSelector() = "ul.area_rightNews li"
override fun searchMangaSelector() = popularMangaSelector()
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("h2 > a").first().let {
@ -41,20 +54,48 @@ class Mangachan(override val id: Int) : ParsedOnlineSource() {
}
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) {
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) {
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 pageUrls = trimmedHtml.split(',')
for ((i, url) in pageUrls.withIndex()) {
pages.add(Page(i, "", url))
}
pageUrls.mapIndexedTo(pages) { i, url -> Page(i, "", url) }
}
override fun pageListParse(document: Document, pages: MutableList<Page>) { }
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
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.Language
@ -90,7 +89,7 @@ class Mintmanga(override val id: Int) : ParsedOnlineSource() {
} ?: 0
}
override fun parseChapterNumber(chapter: Chapter) {
override fun prepareNewChapter(chapter: Chapter, manga: Manga) {
chapter.chapter_number = -2f
}
@ -115,9 +114,9 @@ class Mintmanga(override val id: Int) : ParsedOnlineSource() {
override fun imageUrlParse(document: Document) = ""
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => {
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
* on http://mintmanga.com/search
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
* on http://mintmanga.com/search
*/
override fun getFilterList(): List<Filter> = listOf(
Filter("el_2220", "арт"),
@ -163,5 +162,4 @@ class Mintmanga(override val id: Int) : ParsedOnlineSource() {
Filter("el_1315", "юри"),
Filter("el_1336", "яой")
)
}

View File

@ -1,6 +1,5 @@
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.Manga
import eu.kanade.tachiyomi.data.source.Language
@ -53,10 +52,7 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
override fun searchMangaSelector() = popularMangaSelector()
override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("h3 > a").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.attr("title")
}
popularMangaFromElement(element, manga)
}
// max 200 results
@ -93,7 +89,7 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
} ?: 0
}
override fun parseChapterNumber(chapter: Chapter) {
override fun prepareNewChapter(chapter: Chapter, manga: Manga) {
chapter.chapter_number = -2f
}
@ -118,9 +114,9 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
override fun imageUrlParse(document: Document) = ""
/* [...document.querySelectorAll("tr.advanced_option:nth-child(1) > td:nth-child(3) span.js-link")].map((el,i) => {
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
* on http://readmanga.me/search
* const onClick=el.getAttribute('onclick');const id=onClick.substr(31,onClick.length-33);
* return `Filter("${id}", "${el.textContent.trim()}")` }).join(',\n')
* on http://readmanga.me/search
*/
override fun getFilterList(): List<Filter> = listOf(
Filter("el_5685", "арт"),
@ -136,6 +132,7 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
Filter("el_2118", "драма"),
Filter("el_2154", "игра"),
Filter("el_2119", "история"),
Filter("el_8032", "киберпанк"),
Filter("el_2137", "кодомо"),
Filter("el_2136", "комедия"),
Filter("el_2147", "махо-сёдзё"),
@ -164,5 +161,4 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
Filter("el_2149", "этти"),
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")
}
val installIntent = UpdateNotificationReceiver.installApkIntent(ctx, apkFile.absolutePath)
// Prompt the user to install the new update.
NotificationCompat.Builder(this).update {
setContentTitle(getString(R.string.app_name))
setContentText(getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)
// Install action
setContentIntent(installIntent)
addAction(R.drawable.ic_system_update_grey_24dp_img,
getString(R.string.action_install),
UpdateNotificationReceiver.installApkIntent(ctx, apkFile.absolutePath))
installIntent)
// Cancel action
addAction(R.drawable.ic_clear_grey_24dp_img,
getString(R.string.action_cancel),

View File

@ -3,10 +3,7 @@ package eu.kanade.tachiyomi.ui.catalogue
import android.content.res.Configuration
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v7.widget.GridLayoutManager
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.SearchView
import android.support.v7.widget.Toolbar
import android.support.v7.widget.*
import android.view.*
import android.view.animation.AnimationUtils
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.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaActivity
import eu.kanade.tachiyomi.util.getResourceDrawable
import eu.kanade.tachiyomi.util.snack
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DividerItemDecoration
import eu.kanade.tachiyomi.widget.EndlessScrollListener
import eu.kanade.tachiyomi.widget.IgnoreFirstSpinnerListener
import kotlinx.android.synthetic.main.fragment_catalogue.*
@ -149,9 +144,7 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleVie
catalogue_list.adapter = adapter
catalogue_list.layoutManager = llm
catalogue_list.addOnScrollListener(listScrollListener)
catalogue_list.addItemDecoration(
DividerItemDecoration(context.theme.getResourceDrawable(R.attr.divider_drawable)))
catalogue_list.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
if (presenter.isListMode) {
switcher.showNext()
}

View File

@ -1,9 +1,11 @@
package eu.kanade.tachiyomi.ui.category
import android.os.Bundle
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.toast
import rx.android.schedulers.AndroidSchedulers
import uy.kohesive.injekt.injectLazy
@ -15,57 +17,41 @@ import uy.kohesive.injekt.injectLazy
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
companion object {
/**
* The id of the restartable.
*/
final private val GET_CATEGORIES = 1
}
private var categories: List<Category> = emptyList()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
// Get categories as list
restartableLatestCache(GET_CATEGORIES,
{
db.getCategories().asRxObservable()
.doOnNext { categories -> this.categories = categories }
.observeOn(AndroidSchedulers.mainThread())
}, CategoryActivity::setCategories)
// Start get categories as list task
start(GET_CATEGORIES)
db.getCategories().asRxObservable()
.doOnNext { categories = it }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(CategoryActivity::setCategories)
}
/**
* Create category and add it to database
*
* @param name name of category
*/
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.
val cat = Category.create(name)
// Set the new item in the last position.
var max = 0
if (categories != null) {
for (cat2 in categories!!) {
if (cat2.order > max) {
max = cat2.order + 1
}
}
}
cat.order = max
cat.order = categories.map { it.order + 1 }.max() ?: 0
// Insert into database.
db.insertCategory(cat).asRxObservable().subscribe()
@ -86,8 +72,8 @@ class CategoryPresenter : BasePresenter<CategoryActivity>() {
* @param categories list of categories
*/
fun reorderCategories(categories: List<Category>) {
for (i in categories.indices) {
categories[i].order = i
categories.forEachIndexed { i, category ->
category.order = i
}
db.insertCategories(categories).asRxObservable().subscribe()
@ -100,6 +86,12 @@ class CategoryPresenter : BasePresenter<CategoryActivity>() {
* @param name new name of category
*/
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
db.insertCategory(category).asRxObservable().subscribe()
}

View File

@ -6,6 +6,7 @@ import android.view.*
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.DownloadService
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.main.MainActivity
import eu.kanade.tachiyomi.util.plusAssign
@ -30,21 +31,6 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
*/
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].
*/
@ -95,15 +81,15 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
recycler.setHasFixedSize(true)
// Suscribe to changes
subscriptions += presenter.downloadManager.runningSubject
subscriptions += DownloadService.runningRelay
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onQueueStatusChange(it) }
subscriptions += presenter.getStatusObservable()
subscriptions += presenter.getDownloadStatusObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onStatusChange(it) }
subscriptions += presenter.getProgressObservable()
subscriptions += presenter.getDownloadProgressObservable()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onUpdateDownloadedPages(it) }
}
@ -119,23 +105,17 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.download_queue, menu)
}
override fun onPrepareOptionsMenu(menu: Menu) {
// Set start button visibility.
startButton = menu.findItem(R.id.start_queue).apply {
isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
}
menu.findItem(R.id.start_queue).isVisible = !isRunning && !presenter.downloadQueue.isEmpty()
// Set pause button visibility.
pauseButton = menu.findItem(R.id.pause_queue).apply {
isVisible = isRunning
}
menu.findItem(R.id.pause_queue).isVisible = isRunning
// Set clear button visibility.
clearButton = menu.findItem(R.id.clear_queue).apply {
if (!presenter.downloadQueue.isEmpty()) {
isVisible = true
}
}
menu.findItem(R.id.clear_queue).isVisible = !presenter.downloadQueue.isEmpty()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -182,7 +162,7 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
// Get the sum of percentages for all the pages.
.flatMap {
Observable.from(download.pages)
.map { it.progress }
.map(Page::progress)
.reduce { x, y -> x + y }
}
// Keep only the latest emission to avoid backpressure.
@ -218,9 +198,7 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
*/
private fun onQueueStatusChange(running: Boolean) {
isRunning = running
startButton?.isVisible = !running && !presenter.downloadQueue.isEmpty()
pauseButton?.isVisible = running
clearButton?.isVisible = !presenter.downloadQueue.isEmpty()
activity.supportInvalidateOptionsMenu()
// Check if download queue is empty and update information accordingly.
setInformationView()
@ -232,13 +210,11 @@ class DownloadFragment : BaseRxFragment<DownloadPresenter>() {
* @param downloads the downloads from the queue.
*/
fun onNextDownloads(downloads: List<Download>) {
activity.supportInvalidateOptionsMenu()
setInformationView()
adapter.setItems(downloads)
}
fun onDownloadRemoved(position: Int) {
adapter.notifyItemRemoved(position)
}
/**
* Called when the progress of a download changes.
*

View File

@ -29,36 +29,21 @@ class DownloadPresenter : BasePresenter<DownloadFragment>() {
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
Observable.just(ArrayList(downloadQueue))
.doOnNext { syncQueue(it) }
.subscribeLatestCache({ view, downloads ->
view.onNextDownloads(downloads)
}, { view, error ->
downloadQueue.getUpdatedObservable()
.observeOn(AndroidSchedulers.mainThread())
.map { ArrayList(it) }
.subscribeLatestCache(DownloadFragment::onNextDownloads, { view, error ->
Timber.e(error)
})
}
private fun syncQueue(queue: MutableList<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> {
fun getDownloadStatusObservable(): Observable<Download> {
return downloadQueue.getStatusObservable()
.startWith(downloadQueue.getActiveDownloads())
}
fun getProgressObservable(): Observable<Download> {
fun getDownloadProgressObservable(): Observable<Download> {
return downloadQueue.getProgressObservable()
.onBackpressureBuffer()
}

View File

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

View File

@ -9,17 +9,36 @@ import android.util.AttributeSet
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.BuildConfig
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.getOrDefault
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import it.gmariotti.changelibs.library.view.ChangeLogRecyclerView
import java.io.File
class ChangelogDialogFragment : DialogFragment() {
companion object {
fun show(preferences: PreferencesHelper, fragmentManager: FragmentManager) {
if (preferences.lastVersionCode().getOrDefault() < BuildConfig.VERSION_CODE) {
fun show(context: Context, preferences: PreferencesHelper, fm: FragmentManager) {
val oldVersion = preferences.lastVersionCode().getOrDefault()
if (oldVersion < 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 {
val view = WhatsNewRecyclerView(context)
return MaterialDialog.Builder(activity)
.title("Changelog")
.title(if (BuildConfig.DEBUG) "Notices" else "Changelog")
.customView(view, false)
.positiveText(android.R.string.yes)
.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.base.activity.BaseActivity
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.latest_updates.LatestUpdatesFragment
import eu.kanade.tachiyomi.ui.library.LibraryFragment
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersFragment
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadFragment
@ -79,7 +79,7 @@ class MainActivity : BaseActivity() {
setSelectedDrawerItem(startScreenId)
// 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)
}
var items: List<ChapterModel>
get() = mItems
set(value) {
mItems = value
notifyDataSetChanged()
}
override fun updateDataSet(param: String) {
}
@ -32,8 +39,4 @@ class ChaptersAdapter(val fragment: ChaptersFragment) : FlexibleAdapter<Chapters
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.support.v4.app.DialogFragment
import android.support.v7.view.ActionMode
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager
import android.view.*
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.reader.ReaderActivity
import eu.kanade.tachiyomi.util.getCoordinates
import eu.kanade.tachiyomi.util.getResourceDrawable
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.DeletingChaptersDialog
import eu.kanade.tachiyomi.widget.DividerItemDecoration
import kotlinx.android.synthetic.main.fragment_manga_chapters.*
import nucleus.factory.RequiresPresenter
import timber.log.Timber
@ -67,8 +66,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
recycler.adapter = adapter
recycler.layoutManager = LinearLayoutManager(activity)
recycler.addItemDecoration(DividerItemDecoration(
context.theme.getResourceDrawable(R.attr.divider_drawable)))
recycler.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
swipe_refresh.setOnRefreshListener { fetchChapters() }
@ -123,11 +121,13 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
val menuFilterRead = menu.findItem(R.id.action_filter_read)
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
// Set correct checkbox values.
menuFilterRead.isChecked = presenter.onlyRead()
menuFilterUnread.isChecked = presenter.onlyUnread()
menuFilterDownloaded.isChecked = presenter.onlyDownloaded()
menuFilterBookmarked.isChecked = presenter.onlyBookmarked()
if (presenter.onlyRead())
//Disable unread filter option if read filter is enabled.
@ -156,6 +156,10 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
item.isChecked = !item.isChecked
presenter.setDownloadedFilter(item.isChecked)
}
R.id.action_filter_bookmarked -> {
item.isChecked = !item.isChecked
presenter.setBookmarkedFilter(item.isChecked)
}
R.id.action_filter_empty -> {
presenter.removeFilters()
activity.supportInvalidateOptionsMenu()
@ -178,7 +182,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
initialFetchChapters()
destroyActionModeIfNeeded()
adapter.setItems(chapters)
adapter.items = chapters
}
private fun initialFetchChapters() {
@ -263,7 +267,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
.itemsCallback { dialog, view, i, charSequence ->
fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && !it.isDownloaded }
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
.distinctBy { it.name }
.sortedByDescending { it.source_order }
@ -356,7 +360,11 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
}
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>) {
@ -364,6 +372,11 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
presenter.downloadChapters(chapters)
}
fun bookmarkChapters(chapters: List<ChapterModel>, bookmarked: Boolean) {
destroyActionModeIfNeeded()
presenter.bookmarkChapters(chapters, bookmarked)
}
fun deleteChapters(chapters: List<ChapterModel>) {
destroyActionModeIfNeeded()
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 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 df = DateFormat.getDateInstance(DateFormat.SHORT)
@ -43,7 +44,10 @@ class ChaptersHolder(
}
else -> chapter.name
}
// Set correct text color
chapter_title.setTextColor(if (chapter.read) readColor else unreadColor)
if (chapter.bookmark) chapter_title.setTextColor(bookmarkedColor)
if (chapter.date_upload > 0) {
chapter_date.text = df.format(Date(chapter.date_upload))
@ -84,6 +88,10 @@ class ChaptersHolder(
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
if (!chapter.read && chapter.last_page_read == 0) {
popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false
@ -101,6 +109,8 @@ class ChaptersHolder(
with(adapter.fragment) {
when (menuItem.itemId) {
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_mark_as_read -> markAsRead(chapterList)
R.id.action_mark_as_unread -> markAsUnread(chapterList)

View File

@ -132,6 +132,9 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
chapters.map { it.toModel() }
}
.doOnNext { chapters ->
// Find downloaded chapters
setDownloadedChapters(chapters)
// Store the last emission
this.chapters = chapters
@ -157,16 +160,25 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
if (download != null) {
// If there's an active download, assign it.
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
}
/**
* 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.
*/
@ -200,7 +212,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* Applies the view filters to the list of chapters obtained from the database.
*
* @param chapters the list of chapters from the database
* @return an observable of the list of chapters filtered and sorted.
*/
@ -215,6 +226,9 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
if (onlyDownloaded()) {
observable = observable.filter { it.isDownloaded }
}
if (onlyBookmarked()) {
observable = observable.filter { it.bookmark }
}
val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
Manga.SORTING_SOURCE -> when (sortDescending()) {
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.
*
* @param download the download whose status changed.
*/
fun onDownloadStatusChange(download: Download) {
@ -258,7 +271,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* Mark the selected chapter list as read/unread.
*
* @param selectedChapters the list of selected chapters.
* @param read whether to mark chapters as read or unread.
*/
@ -276,23 +288,8 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
.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.
*
* @param chapters the list of chapters to download.
*/
fun downloadChapters(chapters: List<ChapterModel>) {
@ -300,16 +297,26 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
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.
*
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<ChapterModel>) {
val wasRunning = downloadManager.isRunning
if (wasRunning) {
DownloadService.stop(context)
}
Observable.from(chapters)
.doOnNext { deleteChapter(it) }
.toList()
@ -318,9 +325,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, result ->
view.onChaptersDeleted()
if (wasRunning) {
DownloadService.start(context)
}
}, { view, 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.
*
* @param chapter the chapter to delete.
*/
private fun deleteChapter(chapter: ChapterModel) {
downloadManager.queue.del(chapter)
downloadManager.queue.remove(chapter)
downloadManager.deleteChapter(source, manga, chapter)
chapter.status = Download.NOT_DOWNLOADED
chapter.download = null
@ -349,7 +352,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* Sets the read filter and requests an UI update.
*
* @param onlyUnread whether to display only unread chapters or all chapters.
*/
fun setUnreadFilter(onlyUnread: Boolean) {
@ -360,7 +362,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* Sets the read filter and requests an UI update.
*
* @param onlyRead whether to display only read chapters or all chapters.
*/
fun setReadFilter(onlyRead: Boolean) {
@ -371,7 +372,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* Sets the download filter and requests an UI update.
*
* @param onlyDownloaded whether to display only downloaded chapters or all chapters.
*/
fun setDownloadedFilter(onlyDownloaded: Boolean) {
@ -380,19 +380,29 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
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.
*/
fun removeFilters() {
manga.readFilter = Manga.SHOW_ALL
manga.downloadedFilter = Manga.SHOW_ALL
manga.bookmarkedFilter = Manga.SHOW_ALL
db.updateFlags(manga).executeAsBlocking()
refreshChapters()
}
/**
* Sets the active display mode.
*
* @param mode the mode to set.
*/
fun setDisplayMode(mode: Int) {
@ -402,7 +412,6 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* Sets the sorting method and requests an UI update.
*
* @param sort the sorting mode.
*/
fun setSorting(sort: Int) {
@ -418,6 +427,13 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
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.
*/

View File

@ -184,10 +184,9 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
val url = source.mangaDetailsRequest(presenter.manga).url().toString()
val sharingIntent = Intent(Intent.ACTION_SEND).apply {
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))
}
startActivity(Intent.createChooser(sharingIntent, resources.getText(R.string.share_subject)))
startActivity(Intent.createChooser(sharingIntent, resources.getText(R.string.action_share)))
} catch (e: Exception) {
context.toast(e.message)
}

View File

@ -70,14 +70,15 @@ class ChapterLoader(
private fun retrievePageList(chapter: ReaderChapter) = Observable.just(chapter)
.flatMap {
// 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)
Observable.just(downloadManager.getSavedPageList(source, manga, chapter)!!)
// Fetch the page list from cache or fallback to network
else
if (chapter.isDownloaded) {
// Fetch the page list from disk.
downloadManager.buildPageList(source, manga, chapter)
} else {
// Fetch the page list from cache or fallback to network
source.fetchPageList(chapter)
}
}
.doOnNext { pages ->
chapter.pages = pages
@ -85,21 +86,11 @@ class ChapterLoader(
}
private fun loadPages(chapter: ReaderChapter) {
if (chapter.isDownloaded) {
loadDownloadedPages(chapter)
} else {
if (!chapter.isDownloaded) {
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) {
chapter.pages?.let { pages ->
val startPage = chapter.requestedPage

View File

@ -110,7 +110,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
setMenuVisibility(menuVisible)
maxBitmapSize = Math.min(2048, GLUtil.getMaxTextureSize())
maxBitmapSize = GLUtil.getMaxTextureSize()
left_chapter.setOnClickListener {
if (viewer != null) {
@ -224,6 +224,20 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
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() {
// Ignore
}
@ -250,7 +264,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
val activePage = pages.getOrElse(chapter.requestedPage) { pages.first() }
viewer?.onPageListReady(chapter, activePage)
setActiveChapter(chapter, activePage.pageNumber)
setActiveChapter(chapter, activePage.index)
}
fun onEnterChapter(chapter: ReaderChapter, currentPage: Int) {
@ -317,7 +331,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
fun onPageChanged(page: Page) {
presenter.onPageChanged(page)
val pageNumber = page.pageNumber + 1
val pageNumber = page.number
val pageCount = page.chapter.pages!!.size
page_number.text = "$pageNumber/$pageCount"
if (page_seekbar.rotation != 180f) {
@ -325,7 +339,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
} else {
right_page_text.text = "$pageNumber"
}
page_seekbar.progress = page.pageNumber
page_seekbar.progress = page.index
}
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].
* 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
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.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
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.online.OnlineSource
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.SharedData
import eu.kanade.tachiyomi.util.toast
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
@ -24,13 +31,13 @@ import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.net.URLConnection
import java.util.*
/**
* Presenter of [ReaderActivity].
*/
class ReaderPresenter : BasePresenter<ReaderActivity>() {
/**
* Preferences.
*/
@ -61,6 +68,11 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
*/
val chapterCache: ChapterCache by injectLazy()
/**
* Cover cache.
*/
val coverCache: CoverCache by injectLazy()
/**
* Manga being read.
*/
@ -332,9 +344,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
fun retryPage(page: Page?) {
if (page != null && source is OnlineSource) {
page.status = Page.QUEUE
if (page.imagePath != null) {
val file = File(page.imagePath)
chapterCache.removeFileFromCache(file.name)
val uri = page.uri
if (uri != null && !page.chapter.isDownloaded) {
chapterCache.removeFileFromCache(uri.encodedPath.substringAfterLast('/'))
}
loader.retryPage(page)
}
@ -351,27 +363,27 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
val pages = chapter.pages ?: return
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
if (!chapter.isDownloaded) {
source.let { if (it is OnlineSource) it.savePageList(chapter, pages) }
}
if (chapter.read) {
val removeAfterReadSlots = prefs.removeAfterReadSlots()
when (removeAfterReadSlots) {
// Setting disabled
-1 -> { /**Empty function**/ }
// Remove current read chapter
0 -> deleteChapter(chapter, manga)
// Remove previous chapter specified by user in settings.
else -> getAdjacentChaptersStrategy(chapter, removeAfterReadSlots)
.first?.let { deleteChapter(it, manga) }
try {
if (chapter.read) {
val removeAfterReadSlots = prefs.removeAfterReadSlots()
when (removeAfterReadSlots) {
// Setting disabled
-1 -> { /* Empty function */ }
// Remove current read chapter
0 -> deleteChapter(chapter, manga)
// Remove previous chapter specified by user in settings.
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()
@ -384,8 +396,8 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
Timber.e(error)
}
}
.subscribeOn(Schedulers.io())
.subscribe()
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
@ -395,7 +407,7 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
*/
fun onPageChanged(page: Page) {
val chapter = page.chapter
chapter.last_page_read = page.pageNumber
chapter.last_page_read = page.index
if (chapter.pages!!.last() === page) {
chapter.read = true
}
@ -508,4 +520,81 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
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,14 +16,19 @@ abstract class BaseReader : BaseFragment() {
companion object {
/**
* Rapid decoder.
* Image decoder.
*/
const val RAPID_DECODER = 0
const val IMAGE_DECODER = 0
/**
* Skia decoder.
*/
const val SKIA_DECODER = 1
/**
* Rapid decoder.
*/
const val RAPID_DECODER = 2
}
/**
@ -90,7 +95,7 @@ abstract class BaseReader : BaseFragment() {
// Active chapter has changed.
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.
if (pages.size - position < 5 && chapters.last().id == newChapter.id
@ -120,7 +125,7 @@ abstract class BaseReader : BaseFragment() {
*/
fun getPageIndex(search: Page): Int {
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
}
}
@ -200,16 +205,17 @@ abstract class BaseReader : BaseFragment() {
*/
fun setDecoderClass(value: Int) {
when (value) {
RAPID_DECODER -> {
// Using Skia because Rapid isn't stable. Rapid is still used for region decoding.
// https://github.com/inorichi/tachiyomi/issues/97
//bitmapDecoderClass = RapidImageDecoder.class;
regionDecoderClass = RapidImageRegionDecoder::class.java
bitmapDecoderClass = SkiaImageDecoder::class.java
IMAGE_DECODER -> {
bitmapDecoderClass = IImageDecoder::class.java
regionDecoderClass = IImageRegionDecoder::class.java
}
SKIA_DECODER -> {
regionDecoderClass = SkiaImageRegionDecoder::class.java
bitmapDecoderClass = SkiaImageDecoder::class.java
regionDecoderClass = SkiaImageRegionDecoder::class.java
}
RAPID_DECODER -> {
bitmapDecoderClass = RapidImageDecoder::class.java
regionDecoderClass = RapidImageRegionDecoder::class.java
}
}
}

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.LeftToRightReader
import eu.kanade.tachiyomi.ui.reader.viewer.pager.horizontal.RightToLeftReader
import eu.kanade.tachiyomi.util.toast
import rx.subscriptions.CompositeSubscription
/**
@ -185,6 +186,16 @@ abstract class PagerReader : BaseReader() {
}
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

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

View File

@ -72,7 +72,7 @@ class WebtoonAdapter(val fragment: WebtoonReader) : RecyclerView.Adapter<Webtoon
* @param holder the holder to recycle.
*/
override fun onViewRecycled(holder: WebtoonHolder) {
holder.unsubscribeStatus()
holder.onRecycle()
}
}

View File

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

View File

@ -6,9 +6,11 @@ import android.view.*
import android.view.GestureDetector.SimpleOnGestureListener
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.ui.reader.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.base.BaseReader
import eu.kanade.tachiyomi.util.toast
import eu.kanade.tachiyomi.widget.PreCachingLayoutManager
import rx.subscriptions.CompositeSubscription
@ -114,7 +116,7 @@ class WebtoonReader : BaseReader() {
}
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)
super.onSaveInstanceState(outState)
}
@ -140,17 +142,28 @@ class WebtoonReader : BaseReader() {
}
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].
*
* @param chapter the chapter set.
* @param currentPage the initial page to display.
*/
override fun onChapterSet(chapter: ReaderChapter, currentPage: Page) {
this.currentPage = currentPage.pageNumber
this.currentPage = currentPage.index
// Make sure the view is already initialized.
if (view != null) {
@ -160,7 +173,6 @@ class WebtoonReader : BaseReader() {
/**
* Called when a chapter is appended in [BaseReader].
*
* @param chapter the chapter appended.
*/
override fun onChapterAppended(chapter: ReaderChapter) {
@ -184,7 +196,6 @@ class WebtoonReader : BaseReader() {
/**
* Sets the active page.
*
* @param pageNumber the index of the page from [pages].
*/
override fun setActivePage(pageNumber: Int) {

View File

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

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.recent_updates
import android.os.Bundle
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.MangaChapter
import eu.kanade.tachiyomi.data.download.DownloadManager
@ -97,7 +98,10 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
.map { mangaChapters ->
mangaChapters.map { it.toModel() }
}
.doOnNext { chapters = it }
.doOnNext {
setDownloadedChapters(it)
chapters = it
}
// Group chapters by the date they were fetched on a ordered map.
.flatMap { recentItems ->
Observable.from(recentItems)
@ -142,18 +146,29 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
// downloaded and assign it to the status.
if (download != null) {
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
}
/**
* 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.
* @param download download object containing progress.
@ -207,10 +222,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
* @param chapters list of chapters
*/
fun deleteChapters(chapters: List<RecentChapter>) {
val wasRunning = downloadManager.isRunning
if (wasRunning) {
DownloadService.stop(context)
}
Observable.from(chapters)
.doOnNext { deleteChapter(it) }
.toList()
@ -218,9 +229,6 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, result ->
view.onChaptersDeleted()
if (wasRunning) {
DownloadService.start(context)
}
}, { view, error ->
view.onChaptersDeletedError(error)
})
@ -253,7 +261,7 @@ class RecentChaptersPresenter : BasePresenter<RecentChaptersFragment>() {
*/
private fun deleteChapter(chapter: RecentChapter) {
val source = sourceManager.get(chapter.manga.source) ?: return
downloadManager.queue.del(chapter)
downloadManager.queue.remove(chapter)
downloadManager.deleteChapter(source, chapter.manga, chapter)
chapter.status = Download.NOT_DOWNLOADED
chapter.download = null

View File

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

View File

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

View File

@ -2,6 +2,8 @@ package eu.kanade.tachiyomi.ui.setting
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.support.v4.content.ContextCompat
@ -11,6 +13,7 @@ import android.support.v7.widget.RecyclerView
import android.view.View
import android.view.ViewGroup
import com.afollestad.materialdialogs.MaterialDialog
import com.hippo.unifile.UniFile
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
import com.nononsenseapps.filepicker.FilePickerActivity
import com.nononsenseapps.filepicker.FilePickerFragment
@ -26,7 +29,8 @@ import java.io.File
class SettingsDownloadsFragment : SettingsFragment() {
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 {
val args = Bundle()
@ -45,24 +49,30 @@ class SettingsDownloadsFragment : SettingsFragment() {
downloadDirPref.setOnPreferenceClickListener {
val currentDir = preferences.downloadsDirectory().getOrDefault()
val externalDirs = getExternalFilesDirs() + getString(R.string.custom_dir)
val selectedIndex = externalDirs.indexOf(File(currentDir))
val externalDirs = getExternalFilesDirs() + File(getString(R.string.custom_dir))
val selectedIndex = externalDirs.map(File::toString).indexOfFirst { it in currentDir }
MaterialDialog.Builder(activity)
.items(externalDirs)
.itemsCallbackSingleChoice(selectedIndex, { dialog, view, which, text ->
if (which == externalDirs.lastIndex) {
// Custom dir selected, open directory selector
val i = Intent(activity, CustomLayoutPickerActivity::class.java)
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
if (Build.VERSION.SDK_INT < 21) {
// Custom dir selected, open directory selector
val i = Intent(activity, CustomLayoutPickerActivity::class.java)
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
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 {
// 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
})
@ -72,7 +82,16 @@ class SettingsDownloadsFragment : SettingsFragment() {
}
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> {
@ -85,8 +104,22 @@ class SettingsDownloadsFragment : SettingsFragment() {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (data != null && requestCode == DOWNLOAD_DIR_CODE && resultCode == Activity.RESULT_OK) {
preferences.downloadsDirectory().set(data.data.path)
when (requestCode) {
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.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.XpPreferenceFragment
import android.view.View
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 rx.subscriptions.CompositeSubscription
@ -25,10 +21,6 @@ open class SettingsFragment : XpPreferenceFragment() {
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?) {
subscriptions = CompositeSubscription()
@ -40,18 +32,6 @@ open class SettingsFragment : XpPreferenceFragment() {
addPreferencesFromResource(R.xml.pref_advanced)
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.
preferenceScreen.title = activity.title
@ -74,16 +54,6 @@ open class SettingsFragment : XpPreferenceFragment() {
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> {
return lazy { findPreference(getString(resId)) as T }
}

View File

@ -7,7 +7,7 @@ import android.support.v7.preference.XpPreferenceFragment
import android.view.View
import eu.kanade.tachiyomi.R
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.util.plusAssign
import eu.kanade.tachiyomi.widget.preference.IntListPreference
@ -57,19 +57,20 @@ class SettingsGeneralFragment : SettingsFragment(),
.subscribe { updateColumnsSummary(it.first, it.second) }
updateInterval.setOnPreferenceChangeListener { preference, newValue ->
val interval = (newValue as String).toInt()
if (interval > 0)
LibraryUpdateTrigger.setupTask(context, interval)
else
LibraryUpdateTrigger.cancelTask(context)
// Always cancel the previous task, it seems that sometimes they are not updated.
LibraryUpdateJob.cancelTask()
val interval = (newValue as String).toInt()
if (interval > 0) {
LibraryUpdateJob.setupTask(interval)
}
true
}
updateRestriction.setOnPreferenceChangeListener { preference, newValue ->
// Post to event looper to allow the preference to be updated.
subscriptions += Observable.fromCallable {
LibraryUpdateTrigger.setupTask(context)
LibraryUpdateJob.setupTask()
}.subscribeOn(AndroidSchedulers.mainThread()).subscribe()
true

View File

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

View File

@ -36,7 +36,7 @@ fun syncChaptersWithSource(db: DatabaseHelper,
// Recognize number for new chapters.
toAdd.forEach {
if (source is OnlineSource) {
source.parseChapterNumber(it)
source.prepareNewChapter(it, manga)
}
ChapterRecognition.parseChapterNumber(it, manga)
}

View File

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

@ -1,7 +1,5 @@
package eu.kanade.tachiyomi.util
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import okio.BufferedSource
import okio.Okio
import java.io.File
@ -39,24 +37,3 @@ fun BufferedSource.saveTo(stream: OutputStream) {
}
}
}
/**
* Saves the given source to an output stream and closes both resources.
* The source is expected to be an image, and it may reencode the image.
*
* @param stream the stream where the source is copied.
* @param reencode whether to reencode the image or not.
*/
fun BufferedSource.saveImageTo(stream: OutputStream, reencode: Boolean = false) {
if (reencode) {
use {
val bitmap = BitmapFactory.decodeStream(it.inputStream())
stream.use {
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, it)
}
bitmap.recycle()
}
} else {
saveTo(stream)
}
}

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 {
private static final String JPG = ".jpg";
private static final String PNG = ".png";
private static final String GIF = ".gif";
private UrlUtil() throws InstantiationException {
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) {
init {
isItemPrefetchEnabled = false
}
companion object {
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.content.Context
import android.content.res.TypedArray
import android.os.Build
import android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH
import android.support.v7.preference.PreferenceViewHolder
import android.support.v7.widget.SwitchCompat
@ -11,7 +10,6 @@ import android.util.AttributeSet
import android.view.View
import android.widget.Checkable
import android.widget.CompoundButton
import android.widget.Switch
import eu.kanade.tachiyomi.util.getResourceColor
import net.xpece.android.support.preference.PreferenceCategory
import net.xpece.android.support.preference.R
@ -52,16 +50,12 @@ CompoundButton.OnCheckedChangeListener {
if (view is SwitchCompat) {
view.setOnCheckedChangeListener(null)
} else if (NATIVE_SWITCH_CAPABLE && view is Switch) {
view.setOnCheckedChangeListener(null)
}
view.toggle()
if (view is SwitchCompat) {
view.setOnCheckedChangeListener(this)
} else if (NATIVE_SWITCH_CAPABLE && view is Switch) {
view.setOnCheckedChangeListener(this)
}
}
}
@ -130,8 +124,4 @@ CompoundButton.OnCheckedChangeListener {
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

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