diff --git a/app/build.gradle b/app/build.gradle index 3c59b64b89..3ad59e14ea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ import java.text.SimpleDateFormat apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' apply plugin: 'com.neenbedankt.android-apt' apply plugin: 'me.tatarka.retrolambda' @@ -80,6 +81,13 @@ android { checkReleaseBuilds false } + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + // http://stackoverflow.com/questions/32759529/androidhttpclient-not-found-when-running-robolectric + useLibrary 'org.apache.http.legacy' + } apt { @@ -92,7 +100,8 @@ dependencies { final SUPPORT_LIBRARY_VERSION = '23.1.1' final DAGGER_VERSION = '2.0.2' final EVENTBUS_VERSION = '3.0.0' - final OKHTTP_VERSION = '3.1.1' + final OKHTTP_VERSION = '3.1.2' + final RETROFIT_VERSION = '2.0.0-beta4' final STORIO_VERSION = '1.8.0' final ICEPICK_VERSION = '3.1.0' final MOCKITO_VERSION = '1.10.19' @@ -111,20 +120,22 @@ dependencies { compile "com.squareup.okhttp3:okhttp:$OKHTTP_VERSION" compile "com.squareup.okhttp3:okhttp-urlconnection:$OKHTTP_VERSION" compile 'com.squareup.okio:okio:1.6.0' - compile 'com.google.code.gson:gson:2.5' + compile 'com.google.code.gson:gson:2.6.1' compile 'com.jakewharton:disklrucache:2.0.2' compile 'org.jsoup:jsoup:1.8.3' compile 'io.reactivex:rxandroid:1.1.0' - compile 'io.reactivex:rxjava:1.1.0' - compile 'com.squareup.retrofit:retrofit:1.9.0' + compile 'io.reactivex:rxjava:1.1.1' + compile "com.squareup.retrofit2:retrofit:$RETROFIT_VERSION" + compile "com.squareup.retrofit2:converter-gson:$RETROFIT_VERSION" + compile "com.squareup.retrofit2:adapter-rxjava:$RETROFIT_VERSION" compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.1' compile "com.pushtorefresh.storio:sqlite:$STORIO_VERSION" compile "com.pushtorefresh.storio:sqlite-annotations:$STORIO_VERSION" - compile 'info.android15.nucleus:nucleus:2.0.4' - compile 'com.github.bumptech.glide:glide:3.6.1' + compile 'info.android15.nucleus:nucleus:2.0.5' + compile 'com.github.bumptech.glide:glide:3.7.0' compile 'com.jakewharton:butterknife:7.0.1' compile 'com.jakewharton.timber:timber:4.1.0' - compile 'ch.acra:acra:4.8.1' + compile 'ch.acra:acra:4.8.2' compile "frankiesardo:icepick:$ICEPICK_VERSION" provided "frankiesardo:icepick-processor:$ICEPICK_VERSION" compile 'com.github.dmytrodanylyk.android-process-button:library:1.0.4' @@ -161,4 +172,19 @@ dependencies { } androidTestApt "com.google.dagger:dagger-compiler:$DAGGER_VERSION" + compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} + +buildscript { + ext.kotlin_version = '1.0.0' + repositories { + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +repositories { + mavenCentral() } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 873e9f4022..ed8a82fa7b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,17 +51,17 @@ android:theme="@style/FilePickerTheme"> - - @@ -69,7 +69,7 @@ + android:name=".data.library.LibraryUpdateAlarm"> diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.java b/app/src/main/java/eu/kanade/tachiyomi/App.java index 63dfa0cc7d..3992bffd49 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.java +++ b/app/src/main/java/eu/kanade/tachiyomi/App.java @@ -33,16 +33,18 @@ public class App extends Application { super.onCreate(); if (BuildConfig.DEBUG) Timber.plant(new Timber.DebugTree()); - applicationComponent = DaggerAppComponent.builder() - .appModule(new AppModule(this)) - .build(); + applicationComponent = prepareAppComponent().build(); componentInjector = new ComponentReflectionInjector<>(AppComponent.class, applicationComponent); setupEventBus(); + setupAcra(); + } - ACRA.init(this); + protected DaggerAppComponent.Builder prepareAppComponent() { + return DaggerAppComponent.builder() + .appModule(new AppModule(this)); } protected void setupEventBus() { @@ -52,13 +54,12 @@ public class App extends Application { .installDefaultEventBus(); } - public AppComponent getComponent() { - return applicationComponent; + protected void setupAcra() { + ACRA.init(this); } - // Needed to replace the component with a test specific one - public void setComponent(AppComponent applicationComponent) { - this.applicationComponent = applicationComponent; + public AppComponent getComponent() { + return applicationComponent; } public ComponentReflectionInjector getComponentReflection() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.java b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.java deleted file mode 100644 index 41b46df340..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.java +++ /dev/null @@ -1,268 +0,0 @@ -package eu.kanade.tachiyomi.data.cache; - -import android.content.Context; -import android.text.format.Formatter; - -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import com.jakewharton.disklrucache.DiskLruCache; - -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.IOException; -import java.io.OutputStream; -import java.lang.reflect.Type; -import java.util.List; - -import eu.kanade.tachiyomi.data.source.model.Page; -import eu.kanade.tachiyomi.util.DiskUtils; -import okhttp3.Response; -import okio.BufferedSink; -import okio.Okio; -import rx.Observable; - -/** - * Class used to create chapter cache - * For each image in a chapter a file is created - * For each chapter a Json list is created and converted to a file. - * The files are in format *md5key*.0 - */ -public class ChapterCache { - - /** Name of cache directory. */ - private static final String PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache"; - - /** Application cache version. */ - private static final int PARAMETER_APP_VERSION = 1; - - /** The number of values per cache entry. Must be positive. */ - private static final int PARAMETER_VALUE_COUNT = 1; - - /** The maximum number of bytes this cache should use to store. */ - private static final int PARAMETER_CACHE_SIZE = 75 * 1024 * 1024; - - /** Interface to global information about an application environment. */ - private final Context context; - - /** Google Json class used for parsing JSON files. */ - private final Gson gson; - - /** Cache class used for cache management. */ - private DiskLruCache diskCache; - - /** Page list collection used for deserializing from JSON. */ - private final Type pageListCollection; - - /** - * Constructor of ChapterCache. - * @param context application environment interface. - */ - public ChapterCache(Context context) { - this.context = context; - - // Initialize Json handler. - gson = new Gson(); - - // Try to open cache in default cache directory. - try { - diskCache = DiskLruCache.open( - new File(context.getCacheDir(), PARAMETER_CACHE_DIRECTORY), - PARAMETER_APP_VERSION, - PARAMETER_VALUE_COUNT, - PARAMETER_CACHE_SIZE - ); - } catch (IOException e) { - // Do Nothing. - } - - pageListCollection = new TypeToken>() {}.getType(); - } - - /** - * Returns directory of cache. - * @return directory of cache. - */ - public File getCacheDir() { - return diskCache.getDirectory(); - } - - /** - * Returns real size of directory. - * @return real size of directory. - */ - private long getRealSize() { - return DiskUtils.getDirectorySize(getCacheDir()); - } - - /** - * Returns real size of directory in human readable format. - * @return real size of directory. - */ - public String getReadableSize() { - return Formatter.formatFileSize(context, getRealSize()); - } - - /** - * Remove file from cache. - * @param file name of file "md5.0". - * @return status of deletion for the file. - */ - public boolean removeFileFromCache(String file) { - // Make sure we don't delete the journal file (keeps track of cache). - if (file.equals("journal") || file.startsWith("journal.")) - return false; - - try { - // Remove the extension from the file to get the key of the cache - String key = file.substring(0, file.lastIndexOf(".")); - // Remove file from cache. - return diskCache.remove(key); - } catch (IOException e) { - return false; - } - } - - /** - * Get page list from cache. - * @param chapterUrl the url of the chapter. - * @return an observable of the list of pages. - */ - public Observable> getPageListFromCache(final String chapterUrl) { - return Observable.fromCallable(() -> { - // Initialize snapshot (a snapshot of the values for an entry). - DiskLruCache.Snapshot snapshot = null; - - try { - // Create md5 key and retrieve snapshot. - String key = DiskUtils.hashKeyForDisk(chapterUrl); - snapshot = diskCache.get(key); - - // Convert JSON string to list of objects. - return gson.fromJson(snapshot.getString(0), pageListCollection); - - } finally { - if (snapshot != null) { - snapshot.close(); - } - } - }); - } - - /** - * Add page list to disk cache. - * @param chapterUrl the url of the chapter. - * @param pages list of pages. - */ - public void putPageListToCache(final String chapterUrl, final List pages) { - // Convert list of pages to json string. - String cachedValue = gson.toJson(pages); - - // Initialize the editor (edits the values for an entry). - DiskLruCache.Editor editor = null; - - // Initialize OutputStream. - OutputStream outputStream = null; - - try { - // Get editor from md5 key. - String key = DiskUtils.hashKeyForDisk(chapterUrl); - editor = diskCache.edit(key); - if (editor == null) { - return; - } - - // Write chapter urls to cache. - outputStream = new BufferedOutputStream(editor.newOutputStream(0)); - outputStream.write(cachedValue.getBytes()); - outputStream.flush(); - - diskCache.flush(); - editor.commit(); - } catch (Exception e) { - // Do Nothing. - } finally { - if (editor != null) { - editor.abortUnlessCommitted(); - } - if (outputStream != null) { - try { - outputStream.close(); - } catch (IOException ignore) { - // Do Nothing. - } - } - } - } - - /** - * Check if image is in cache. - * @param imageUrl url of image. - * @return true if in cache otherwise false. - */ - public boolean isImageInCache(final String imageUrl) { - try { - return diskCache.get(DiskUtils.hashKeyForDisk(imageUrl)) != null; - } catch (IOException e) { - return false; - } - } - - /** - * Get image path from url. - * @param imageUrl url of image. - * @return path of image. - */ - public String getImagePath(final String imageUrl) { - try { - // Get file from md5 key. - String imageName = DiskUtils.hashKeyForDisk(imageUrl) + ".0"; - File file = new File(diskCache.getDirectory(), imageName); - return file.getCanonicalPath(); - } catch (IOException e) { - return null; - } - } - - /** - * Add image to cache. - * @param imageUrl url of image. - * @param response http response from page. - * @throws IOException image error. - */ - public void putImageToCache(final String imageUrl, final Response response) throws IOException { - // Initialize editor (edits the values for an entry). - DiskLruCache.Editor editor = null; - - // Initialize BufferedSink (used for small writes). - BufferedSink sink = null; - - try { - // Get editor from md5 key. - String key = DiskUtils.hashKeyForDisk(imageUrl); - editor = diskCache.edit(key); - if (editor == null) { - throw new IOException("Unable to edit key"); - } - - // Initialize OutputStream and write image. - OutputStream outputStream = new BufferedOutputStream(editor.newOutputStream(0)); - sink = Okio.buffer(Okio.sink(outputStream)); - sink.writeAll(response.body().source()); - - diskCache.flush(); - editor.commit(); - } catch (Exception e) { - response.body().close(); - throw new IOException("Unable to save image"); - } finally { - if (editor != null) { - editor.abortUnlessCommitted(); - } - if (sink != null) { - sink.close(); - } - } - } - -} - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt new file mode 100644 index 0000000000..1ff58e6f30 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt @@ -0,0 +1,213 @@ +package eu.kanade.tachiyomi.data.cache + +import android.content.Context +import android.text.format.Formatter +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 okhttp3.Response +import okio.Okio +import rx.Observable +import java.io.File +import java.io.IOException +import java.lang.reflect.Type + +/** + * Class used to create chapter cache + * For each image in a chapter a file is created + * For each chapter a Json list is created and converted to a file. + * The files are in format *md5key*.0 + * + * @param context the application context. + * @constructor creates an instance of the chapter cache. + */ +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>() {}.type + + companion object { + /** Name of cache directory. */ + const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache" + + /** Application cache version. */ + const val PARAMETER_APP_VERSION = 1 + + /** The number of values per cache entry. Must be positive. */ + const val PARAMETER_VALUE_COUNT = 1 + + /** The maximum number of bytes this cache should use to store. */ + 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) + } + + /** + * 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) + + /** + * 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. + */ + fun removeFileFromCache(file: String): Boolean { + // Make sure we don't delete the journal file (keeps track of cache). + if (file == "journal" || file.startsWith("journal.")) + return false + + try { + // Remove the extension from the file to get the key of the cache + val key = file.substring(0, file.lastIndexOf(".")) + // Remove file from cache. + return diskCache.remove(key) + } catch (e: IOException) { + return false + } + } + + /** + * 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> { + return Observable.fromCallable> { + // Get the key for the chapter. + val key = DiskUtils.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) + } + } + } + + /** + * Add page list to disk cache. + * @param chapterUrl the url of the chapter. + * @param pages list of pages. + */ + fun putPageListToCache(chapterUrl: String, pages: List) { + // Convert list of pages to json string. + val cachedValue = gson.toJson(pages) + + // Initialize the editor (edits the values for an entry). + var editor: DiskLruCache.Editor? = null + + try { + // Get editor from md5 key. + val key = DiskUtils.hashKeyForDisk(chapterUrl) + editor = diskCache.edit(key) ?: return + + // Write chapter urls to cache. + Okio.buffer(Okio.sink(editor.newOutputStream(0))).use { + it.write(cachedValue.toByteArray()) + it.flush() + } + + diskCache.flush() + editor.commit() + editor.abortUnlessCommitted() + + } catch (e: Exception) { + // Ignore. + } finally { + editor?.abortUnlessCommitted() + } + } + + /** + * Check 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 + } catch (e: IOException) { + return false + } + } + + /** + * Get image path 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 + } + } + + /** + * 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) { + // Initialize editor (edits the values for an entry). + var editor: DiskLruCache.Editor? = null + + try { + // Get editor from md5 key. + val key = DiskUtils.hashKeyForDisk(imageUrl) + editor = diskCache.edit(key) ?: throw IOException("Unable to edit key") + + // Get OutputStream and write image with Okio. + Okio.buffer(Okio.sink(editor.newOutputStream(0))).use { + it.writeAll(response.body().source()) + it.flush() + } + + diskCache.flush() + editor.commit() + } catch (e: Exception) { + response.body().close() + throw IOException("Unable to save image") + } finally { + editor?.abortUnlessCommitted() + } + } + +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.java b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.java deleted file mode 100644 index 17ede81228..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.java +++ /dev/null @@ -1,235 +0,0 @@ -package eu.kanade.tachiyomi.data.cache; - -import android.content.Context; -import android.support.annotation.Nullable; -import android.text.TextUtils; -import android.widget.ImageView; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.bumptech.glide.load.model.GlideUrl; -import com.bumptech.glide.load.model.LazyHeaders; -import com.bumptech.glide.request.animation.GlideAnimation; -import com.bumptech.glide.request.target.SimpleTarget; -import com.bumptech.glide.signature.StringSignature; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; - -import eu.kanade.tachiyomi.util.DiskUtils; - -/** - * Class used to create cover cache - * It is used to store the covers of the library. - * Makes use of Glide (which can avoid repeating requests) to download covers. - * Names of files are created with the md5 of the thumbnail URL - */ -public class CoverCache { - - /** - * Name of cache directory. - */ - private static final String PARAMETER_CACHE_DIRECTORY = "cover_disk_cache"; - - /** - * Interface to global information about an application environment. - */ - private final Context context; - - /** - * Cache directory used for cache management. - */ - private final File cacheDir; - - /** - * Constructor of CoverCache. - * - * @param context application environment interface. - */ - public CoverCache(Context context) { - this.context = context; - - // Get cache directory from parameter. - cacheDir = new File(context.getCacheDir(), PARAMETER_CACHE_DIRECTORY); - - // Create cache directory. - createCacheDir(); - } - - /** - * Create cache directory if it doesn't exist - * - * @return true if cache dir is created otherwise false. - */ - private boolean createCacheDir() { - return !cacheDir.exists() && cacheDir.mkdirs(); - } - - /** - * Download the cover with Glide and save the file in this cache. - * - * @param thumbnailUrl url of thumbnail. - * @param headers headers included in Glide request. - */ - public void save(String thumbnailUrl, LazyHeaders headers) { - save(thumbnailUrl, headers, null); - } - - /** - * Download the cover with Glide and save the file. - * - * @param thumbnailUrl url of thumbnail. - * @param headers headers included in Glide request. - * @param imageView imageView where picture should be displayed. - */ - private void save(String thumbnailUrl, LazyHeaders headers, @Nullable ImageView imageView) { - // Check if url is empty. - if (TextUtils.isEmpty(thumbnailUrl)) - return; - - // Download the cover with Glide and save the file. - GlideUrl url = new GlideUrl(thumbnailUrl, headers); - Glide.with(context) - .load(url) - .downloadOnly(new SimpleTarget() { - @Override - public void onResourceReady(File resource, GlideAnimation anim) { - try { - // Copy the cover from Glide's cache to local cache. - copyToLocalCache(thumbnailUrl, resource); - - // Check if imageView isn't null and show picture in imageView. - if (imageView != null) { - loadFromCache(imageView, resource); - } - } catch (IOException e) { - // Do nothing. - } - } - }); - } - - /** - * Copy the cover from Glide's cache to this cache. - * - * @param thumbnailUrl url of thumbnail. - * @param source the cover image. - * @throws IOException exception returned - */ - public void copyToLocalCache(String thumbnailUrl, File source) throws IOException { - // Create cache directory if needed. - createCacheDir(); - - // Get destination file. - File dest = new File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl)); - - // Delete the current file if it exists. - if (dest.exists()) - dest.delete(); - - // Write thumbnail image to file. - InputStream in = new FileInputStream(source); - try { - OutputStream out = new FileOutputStream(dest); - try { - // Transfer bytes from in to out. - byte[] buf = new byte[1024]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - } finally { - out.close(); - } - } finally { - in.close(); - } - } - - - /** - * Returns the cover from cache. - * - * @param thumbnailUrl the thumbnail url. - * @return cover image. - */ - private File getCoverFromCache(String thumbnailUrl) { - return new File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl)); - } - - /** - * Delete the cover file from the cache. - * - * @param thumbnailUrl the thumbnail url. - * @return status of deletion. - */ - public boolean deleteCoverFromCache(String thumbnailUrl) { - // Check if url is empty. - if (TextUtils.isEmpty(thumbnailUrl)) - return false; - - // Remove file. - File file = new File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl)); - return file.exists() && file.delete(); - } - - /** - * Save or load the image from cache - * - * @param imageView imageView where picture should be displayed. - * @param thumbnailUrl the thumbnail url. - * @param headers headers included in Glide request. - */ - public void saveOrLoadFromCache(ImageView imageView, String thumbnailUrl, LazyHeaders headers) { - // If file exist load it otherwise save it. - File localCover = getCoverFromCache(thumbnailUrl); - if (localCover.exists()) { - loadFromCache(imageView, localCover); - } else { - save(thumbnailUrl, headers, imageView); - } - } - - /** - * Helper method to load the cover from the cache directory into the specified image view. - * Glide stores the resized image in its cache to improve performance. - * - * @param imageView imageView where picture should be displayed. - * @param file file to load. Must exist!. - */ - private void loadFromCache(ImageView imageView, File file) { - Glide.with(context) - .load(file) - .diskCacheStrategy(DiskCacheStrategy.RESULT) - .centerCrop() - .signature(new StringSignature(String.valueOf(file.lastModified()))) - .into(imageView); - } - - /** - * Helper method to load the cover from network into the specified image view. - * The source image is stored in Glide's cache so that it can be easily copied to this cache - * if the manga is added to the library. - * - * @param imageView imageView where picture should be displayed. - * @param thumbnailUrl url of thumbnail. - * @param headers headers included in Glide request. - */ - public void loadFromNetwork(ImageView imageView, String thumbnailUrl, LazyHeaders headers) { - // Check if url is empty. - if (TextUtils.isEmpty(thumbnailUrl)) - return; - - GlideUrl url = new GlideUrl(thumbnailUrl, headers); - Glide.with(context) - .load(url) - .diskCacheStrategy(DiskCacheStrategy.SOURCE) - .centerCrop() - .into(imageView); - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt new file mode 100644 index 0000000000..fb78a4f31a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt @@ -0,0 +1,158 @@ +package eu.kanade.tachiyomi.data.cache + +import android.content.Context +import android.text.TextUtils +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.model.GlideUrl +import com.bumptech.glide.load.model.LazyHeaders +import com.bumptech.glide.request.animation.GlideAnimation +import com.bumptech.glide.request.target.SimpleTarget +import com.bumptech.glide.signature.StringSignature +import eu.kanade.tachiyomi.util.DiskUtils +import java.io.File +import java.io.IOException + +/** + * Class used to create cover cache. + * It is used to store the covers of the library. + * Makes use of Glide (which can avoid repeating requests) to download covers. + * Names of files are created with the md5 of the thumbnail URL. + * + * @param context the application context. + * @constructor creates an instance of the cover cache. + */ +class CoverCache(private val context: Context) { + + /** + * Cache directory used for cache management. + */ + private val CACHE_DIRNAME = "cover_disk_cache" + private val cacheDir: File = File(context.cacheDir, CACHE_DIRNAME) + + /** + * Download the cover with Glide and save the file. + * @param thumbnailUrl url of thumbnail. + * @param headers headers included in Glide request. + * @param imageView imageView where picture should be displayed. + */ + @JvmOverloads + fun save(thumbnailUrl: String, headers: LazyHeaders, imageView: ImageView? = null) { + // Check if url is empty. + if (TextUtils.isEmpty(thumbnailUrl)) + return + + // Download the cover with Glide and save the file. + val url = GlideUrl(thumbnailUrl, headers) + Glide.with(context) + .load(url) + .downloadOnly(object : SimpleTarget() { + override fun onResourceReady(resource: File, anim: GlideAnimation) { + try { + // Copy the cover from Glide's cache to local cache. + copyToLocalCache(thumbnailUrl, resource) + + // Check if imageView isn't null and show picture in imageView. + if (imageView != null) { + loadFromCache(imageView, resource) + } + } catch (e: IOException) { + // Do nothing. + } + } + }) + } + + /** + * Copy the cover from Glide's cache to this cache. + * @param thumbnailUrl url of thumbnail. + * @param sourceFile the source file of the cover image. + * @throws IOException exception returned + */ + @Throws(IOException::class) + fun copyToLocalCache(thumbnailUrl: String, sourceFile: File) { + // Get destination file. + val destFile = File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl)) + + sourceFile.copyTo(destFile, overwrite = true) + } + + + /** + * Returns the cover from cache. + * @param thumbnailUrl the thumbnail url. + * @return cover image. + */ + private fun getCoverFromCache(thumbnailUrl: String): File { + return File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl)) + } + + /** + * Delete the cover file from the cache. + * @param thumbnailUrl the thumbnail url. + * @return status of deletion. + */ + fun deleteCoverFromCache(thumbnailUrl: String): Boolean { + // Check if url is empty. + if (TextUtils.isEmpty(thumbnailUrl)) + return false + + // Remove file. + val file = File(cacheDir, DiskUtils.hashKeyForDisk(thumbnailUrl)) + return file.exists() && file.delete() + } + + /** + * Save or load the image from cache + * @param imageView imageView where picture should be displayed. + * @param thumbnailUrl the thumbnail url. + * @param headers headers included in Glide request. + */ + fun saveOrLoadFromCache(imageView: ImageView, thumbnailUrl: String, headers: LazyHeaders) { + // If file exist load it otherwise save it. + val localCover = getCoverFromCache(thumbnailUrl) + if (localCover.exists()) { + loadFromCache(imageView, localCover) + } else { + save(thumbnailUrl, headers, imageView) + } + } + + /** + * Helper method to load the cover from the cache directory into the specified image view. + * Glide stores the resized image in its cache to improve performance. + * @param imageView imageView where picture should be displayed. + * @param file file to load. Must exist!. + */ + private fun loadFromCache(imageView: ImageView, file: File) { + Glide.with(context) + .load(file) + .diskCacheStrategy(DiskCacheStrategy.RESULT) + .centerCrop() + .signature(StringSignature(file.lastModified().toString())) + .into(imageView) + } + + /** + * Helper method to load the cover from network into the specified image view. + * The source image is stored in Glide's cache so that it can be easily copied to this cache + * if the manga is added to the library. + * @param imageView imageView where picture should be displayed. + * @param thumbnailUrl url of thumbnail. + * @param headers headers included in Glide request. + */ + fun loadFromNetwork(imageView: ImageView, thumbnailUrl: String, headers: LazyHeaders) { + // Check if url is empty. + if (TextUtils.isEmpty(thumbnailUrl)) + return + + val url = GlideUrl(thumbnailUrl, headers) + Glide.with(context) + .load(url) + .diskCacheStrategy(DiskCacheStrategy.SOURCE) + .centerCrop() + .into(imageView) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.java b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.java deleted file mode 100644 index c02cf9ce79..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.java +++ /dev/null @@ -1,30 +0,0 @@ -package eu.kanade.tachiyomi.data.cache; - -import android.content.Context; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.GlideBuilder; -import com.bumptech.glide.load.DecodeFormat; -import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory; -import com.bumptech.glide.module.GlideModule; - -/** - * Class used to update Glide module settings - */ -public class CoverGlideModule implements GlideModule { - - @Override - public void applyOptions(Context context, GlideBuilder builder) { - // Bitmaps decoded from most image formats (other than GIFs with hidden configs) - // will be decoded with the ARGB_8888 config. - builder.setDecodeFormat(DecodeFormat.PREFER_ARGB_8888); - - // Set the cache size of Glide to 15 MiB - builder.setDiskCache(new InternalCacheDiskCacheFactory(context, 15 * 1024 * 1024)); - } - - @Override - public void registerComponents(Context context, Glide glide) { - // Nothing to see here! - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.kt new file mode 100644 index 0000000000..3e7504802d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.data.cache + +import android.content.Context +import com.bumptech.glide.Glide +import com.bumptech.glide.GlideBuilder +import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory +import com.bumptech.glide.module.GlideModule + +/** + * Class used to update Glide module settings + */ +class CoverGlideModule : GlideModule { + + override fun applyOptions(context: Context, builder: GlideBuilder) { + // Set the cache size of Glide to 15 MiB + builder.setDiskCache(InternalCacheDiskCacheFactory(context, 15 * 1024 * 1024)) + } + + override fun registerComponents(context: Context, glide: Glide) { + // Nothing to see here! + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateAlarm.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateAlarm.kt new file mode 100644 index 0000000000..8f2368406c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateAlarm.kt @@ -0,0 +1,82 @@ +package eu.kanade.tachiyomi.data.library + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.SystemClock +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.alarmManager + +/** + * This class is used to update the library by firing an alarm after a specified time. + * It has a receiver reacting to system's boot and the intent fired by this alarm. + * See [onReceive] for more information. + */ +class LibraryUpdateAlarm : BroadcastReceiver() { + + companion object { + const val LIBRARY_UPDATE_ACTION = "eu.kanade.UPDATE_LIBRARY" + + /** + * Sets the alarm to run the intent that updates the library. + * @param context the application context. + * @param intervalInHours the time in hours when it will be executed. Defaults to the + * value stored in preferences. + */ + @JvmStatic + @JvmOverloads + fun startAlarm(context: Context, + intervalInHours: Int = PreferencesHelper.getLibraryUpdateInterval(context)) { + // Stop previous running alarms if needed, and do not restart it if the interval is 0. + stopAlarm(context) + if (intervalInHours == 0) + return + + // Get the time the alarm should fire the event to update. + val intervalInMillis = intervalInHours * 60 * 60 * 1000 + val nextRun = SystemClock.elapsedRealtime() + intervalInMillis + + // Start the alarm. + val pendingIntent = getPendingIntent(context) + context.alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, + nextRun, intervalInMillis.toLong(), pendingIntent) + } + + /** + * Stops the alarm if it's running. + * @param context the application context. + */ + fun stopAlarm(context: Context) { + val pendingIntent = getPendingIntent(context) + context.alarmManager.cancel(pendingIntent) + } + + /** + * Get the intent the alarm should run when it's fired. + * @param context the application context. + * @return the intent that will run when the alarm is fired. + */ + private fun getPendingIntent(context: Context): PendingIntent { + val intent = Intent(context, LibraryUpdateAlarm::class.java) + intent.action = LIBRARY_UPDATE_ACTION + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + } + + /** + * Handle the intents received by this [BroadcastReceiver]. + * @param context the application context. + * @param intent the intent to process. + */ + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + // Start the alarm when the system is booted. + Intent.ACTION_BOOT_COMPLETED -> startAlarm(context) + // Update the library when the alarm fires an event. + LIBRARY_UPDATE_ACTION -> LibraryUpdateService.start(context) + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt new file mode 100644 index 0000000000..fa71ca68c4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt @@ -0,0 +1,348 @@ +package eu.kanade.tachiyomi.data.library + +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.PowerManager +import android.support.v4.app.NotificationCompat +import android.util.Pair +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.source.SourceManager +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.AndroidComponentUtil +import eu.kanade.tachiyomi.util.NetworkUtil +import eu.kanade.tachiyomi.util.notification +import rx.Observable +import rx.Subscription +import rx.schedulers.Schedulers +import timber.log.Timber +import java.util.* +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject + +/** + * Get the start intent for [LibraryUpdateService]. + * @param context the application context. + * @return the intent of the service. + */ +fun getStartIntent(context: Context): Intent { + return Intent(context, LibraryUpdateService::class.java) +} + +/** + * Returns the status of the service. + * @param context the application context. + * @return true if the service is running, false otherwise. + */ +fun isRunning(context: Context): Boolean { + return AndroidComponentUtil.isServiceRunning(context, LibraryUpdateService::class.java) +} + +/** + * This class will take care of updating the chapters of the manga from the library. It can be + * started calling the [start] method. If it's already running, it won't do anything. + * While the library is updating, a [PowerManager.WakeLock] will be held until the update is + * completed, preventing the device from going to sleep mode. A notification will display the + * progress of the update, and if case of an unexpected error, this service will be silently + * destroyed. + */ +class LibraryUpdateService : Service() { + + // Dependencies injected through dagger. + @Inject lateinit var db: DatabaseHelper + @Inject lateinit var sourceManager: SourceManager + @Inject lateinit var preferences: PreferencesHelper + + // Wake lock that will be held until the service is destroyed. + private lateinit var wakeLock: PowerManager.WakeLock + + // Subscription where the update is done. + private var subscription: Subscription? = null + + companion object { + val UPDATE_NOTIFICATION_ID = 1 + + /** + * Static method to start the service. It will be started only if there isn't another + * instance already running. + * @param context the application context. + */ + @JvmStatic + fun start(context: Context) { + if (!isRunning(context)) { + context.startService(getStartIntent(context)) + } + } + + } + + /** + * Method called when the service is created. It injects dagger dependencies and acquire + * the wake lock. + */ + override fun onCreate() { + super.onCreate() + App.get(this).component.inject(this) + createAndAcquireWakeLock() + } + + /** + * Method called when the service is destroyed. It destroy the running subscription, resets + * the alarm and release the wake lock. + */ + override fun onDestroy() { + subscription?.unsubscribe() + LibraryUpdateAlarm.startAlarm(this) + destroyWakeLock() + super.onDestroy() + } + + /** + * This method needs to be implemented, but it's not used/needed. + */ + override fun onBind(intent: Intent): IBinder? { + return null + } + + /** + * Method called when the service receives an intent. In this case, the content of the intent + * is irrelevant, because everything required is fetched in [updateLibrary]. + * @param intent the intent from [start]. + * @param flags the flags of the command. + * @param startId the start id of this command. + * @return the start value of the command. + */ + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + // If there's no network available, set a component to start this service again when + // a connection is available. + if (!NetworkUtil.isNetworkConnected(this)) { + Timber.i("Sync canceled, connection not available") + AndroidComponentUtil.toggleComponent(this, SyncOnConnectionAvailable::class.java, true) + stopSelf(startId) + return Service.START_NOT_STICKY + } + + // Unsubscribe from any previous subscription if needed. + subscription?.unsubscribe() + + // Update favorite manga. Destroy service when completed or in case of an error. + subscription = Observable.defer { updateLibrary() } + .subscribeOn(Schedulers.io()) + .subscribe({}, + { + showNotification(getString(R.string.notification_update_error), "") + stopSelf(startId) + }, { + stopSelf(startId) + }) + + return Service.START_STICKY + } + + /** + * Method that updates the library. It's called in a background thread, so it's safe to do + * heavy operations or network calls here. + * For each manga it calls [updateManga] and updates the notification showing the current + * progress. + * @return an observable delivering the progress of each update. + */ + fun updateLibrary(): Observable { + // Initialize the variables holding the progress of the updates. + val count = AtomicInteger(0) + val newUpdates = ArrayList() + val failedUpdates = ArrayList() + + // Get the manga list that is going to be updated. + val allLibraryMangas = db.favoriteMangas.executeAsBlocking() + val toUpdate = if (!preferences.updateOnlyNonCompleted()) + allLibraryMangas + else + allLibraryMangas.filter { it.status != Manga.COMPLETED } + + // Emit each manga and update it sequentially. + return Observable.from(toUpdate) + // Notify manga that will update. + .doOnNext { showProgressNotification(it, count.andIncrement, toUpdate.size) } + // Update the chapters of the manga. + .concatMap { manga -> updateManga(manga) + // If there's any error, return empty update and continue. + .onErrorReturn { + failedUpdates.add(manga) + Pair(0, 0) + } + // Filter out mangas without new chapters (or failed). + .filter { pair -> pair.first > 0 } + // Convert to the manga that contains new chapters. + .map { manga } + } + // Add manga with new chapters to the list. + .doOnNext { newUpdates.add(it) } + // Notify result of the overall update. + .doOnCompleted { + if (newUpdates.isEmpty()) { + cancelNotification() + } else { + showResultNotification(newUpdates, failedUpdates) + } + } + } + + /** + * Updates the chapters for the given manga and adds them to the database. + * @param manga the manga to update. + * @return a pair of the inserted and removed chapters. + */ + fun updateManga(manga: Manga): Observable> { + return sourceManager.get(manga.source)!! + .pullChaptersFromNetwork(manga.url) + .flatMap { db.insertOrRemoveChapters(manga, it) } + } + + /** + * Returns the text that will be displayed in the notification when there are new chapters. + * @param updates a list of manga that contains new chapters. + * @param failedUpdates a list of manga that failed to update. + * @return the body of the notification to display. + */ + private fun getUpdatedMangasBody(updates: List, failedUpdates: List): String { + return with(StringBuilder()) { + if (updates.isEmpty()) { + append(getString(R.string.notification_no_new_chapters)) + append("\n") + } else { + append(getString(R.string.notification_new_chapters)) + for (manga in updates) { + append("\n") + append(manga.title) + } + } + if (!failedUpdates.isEmpty()) { + append("\n\n") + append(getString(R.string.notification_manga_update_failed)) + for (manga in failedUpdates) { + append("\n") + append(manga.title) + } + } + toString() + } + } + + /** + * Creates and acquires a wake lock until the library is updated. + */ + private fun createAndAcquireWakeLock() { + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock") + wakeLock.acquire() + } + + /** + * Releases the wake lock if it's held. + */ + private fun destroyWakeLock() { + if (wakeLock.isHeld) { + wakeLock.release() + } + } + + /** + * Shows the notification with the given title and body. + * @param title the title of the notification. + * @param body the body of the notification. + */ + private fun showNotification(title: String, body: String) { + val n = notification() { + setSmallIcon(R.drawable.ic_action_refresh) + setContentTitle(title) + setContentText(body) + } + notificationManager.notify(UPDATE_NOTIFICATION_ID, n) + } + + /** + * Shows the notification containing the currently updating manga and the progress. + * @param manga the manga that's being updated. + * @param current the current progress. + * @param total the total progress. + */ + private fun showProgressNotification(manga: Manga, current: Int, total: Int) { + val n = notification() { + setSmallIcon(R.drawable.ic_action_refresh) + setContentTitle(manga.title) + setProgress(total, current, false) + setOngoing(true) + } + notificationManager.notify(UPDATE_NOTIFICATION_ID, n) + } + + /** + * Shows the notification containing the result of the update done by the service. + * @param updates a list of manga with new updates. + * @param failed a list of manga that failed to update. + */ + private fun showResultNotification(updates: List, failed: List) { + val title = getString(R.string.notification_update_completed) + val body = getUpdatedMangasBody(updates, failed) + + val n = notification() { + setSmallIcon(R.drawable.ic_action_refresh) + setContentTitle(title) + setStyle(NotificationCompat.BigTextStyle().bigText(body)) + setContentIntent(notificationIntent) + setAutoCancel(true) + } + notificationManager.notify(UPDATE_NOTIFICATION_ID, n) + } + + /** + * Cancels the notification. + */ + private fun cancelNotification() { + notificationManager.cancel(UPDATE_NOTIFICATION_ID) + } + + /** + * Property that returns the notification manager. + */ + private val notificationManager : NotificationManager + get() = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + /** + * Property that returns an intent to open the main activity. + */ + private val notificationIntent: PendingIntent + get() { + val intent = Intent(this, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + /** + * Class that triggers the library to update when a connection is available. It receives + * network changes. + */ + class SyncOnConnectionAvailable : BroadcastReceiver() { + + /** + * Method called when a network change occurs. + * @param context the application context. + * @param intent the intent received. + */ + override fun onReceive(context: Context, intent: Intent) { + if (NetworkUtil.isNetworkConnected(context)) { + AndroidComponentUtil.toggleComponent(context, this.javaClass, false) + context.startService(getStartIntent(context)) + } + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.java b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.java deleted file mode 100644 index 3411b852b1..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.java +++ /dev/null @@ -1,40 +0,0 @@ -package eu.kanade.tachiyomi.data.mangasync; - -import android.content.Context; - -import java.util.ArrayList; -import java.util.List; - -import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService; -import eu.kanade.tachiyomi.data.mangasync.services.MyAnimeList; - -public class MangaSyncManager { - - private List services; - private MyAnimeList myAnimeList; - - public static final int MYANIMELIST = 1; - - public MangaSyncManager(Context context) { - services = new ArrayList<>(); - myAnimeList = new MyAnimeList(context); - services.add(myAnimeList); - } - - public MyAnimeList getMyAnimeList() { - return myAnimeList; - } - - public List getSyncServices() { - return services; - } - - public MangaSyncService getSyncService(int id) { - switch (id) { - case MYANIMELIST: - return myAnimeList; - } - return null; - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt new file mode 100644 index 0000000000..54a29975af --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.data.mangasync + +import android.content.Context +import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService +import eu.kanade.tachiyomi.data.mangasync.services.MyAnimeList + +class MangaSyncManager(private val context: Context) { + + val services: List + val myAnimeList: MyAnimeList + + companion object { + const val MYANIMELIST = 1 + } + + init { + myAnimeList = MyAnimeList(context, MYANIMELIST) + services = listOf(myAnimeList) + } + + fun getService(id: Int): MangaSyncService = services.find { it.id == id }!! + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/UpdateMangaSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/UpdateMangaSyncService.kt new file mode 100644 index 0000000000..81f0c74598 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/UpdateMangaSyncService.kt @@ -0,0 +1,78 @@ +package eu.kanade.tachiyomi.data.mangasync + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.MangaSync +import rx.Observable +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import rx.subscriptions.CompositeSubscription +import javax.inject.Inject + +class UpdateMangaSyncService : Service() { + + @Inject lateinit var syncManager: MangaSyncManager + @Inject lateinit var db: DatabaseHelper + + private lateinit var subscriptions: CompositeSubscription + + override fun onCreate() { + super.onCreate() + App.get(this).component.inject(this) + subscriptions = CompositeSubscription() + } + + override fun onDestroy() { + subscriptions.unsubscribe() + super.onDestroy() + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + val manga = intent.getSerializableExtra(EXTRA_MANGASYNC) + if (manga != null) { + updateLastChapterRead(manga as MangaSync, startId) + return Service.START_REDELIVER_INTENT + } else { + stopSelf(startId) + return Service.START_NOT_STICKY + } + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + private fun updateLastChapterRead(mangaSync: MangaSync, startId: Int) { + val sync = syncManager.getService(mangaSync.sync_id) + + subscriptions.add(Observable.defer { sync.update(mangaSync) } + .flatMap { + if (it.isSuccessful) { + db.insertMangaSync(mangaSync).asRxObservable() + } else { + Observable.error(Exception("Could not update manga in remote service")) + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ stopSelf(startId) }, + { stopSelf(startId) })) + } + + companion object { + + private val EXTRA_MANGASYNC = "extra_mangasync" + + @JvmStatic + fun start(context: Context, mangaSync: MangaSync) { + val intent = Intent(context, UpdateMangaSyncService::class.java) + intent.putExtra(EXTRA_MANGASYNC, mangaSync) + context.startService(intent) + } + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.java b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.java deleted file mode 100644 index 1445447fc2..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.java +++ /dev/null @@ -1,27 +0,0 @@ -package eu.kanade.tachiyomi.data.mangasync.base; - -import eu.kanade.tachiyomi.data.database.models.MangaSync; -import okhttp3.Response; -import rx.Observable; - -public abstract class MangaSyncService { - - // Name of the manga sync service to display - public abstract String getName(); - - // Id of the sync service (must be declared and obtained from MangaSyncManager to avoid conflicts) - public abstract int getId(); - - public abstract Observable login(String username, String password); - - public abstract boolean isLogged(); - - public abstract Observable update(MangaSync manga); - - public abstract Observable add(MangaSync manga); - - public abstract Observable bind(MangaSync manga); - - public abstract String getStatus(int status); - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.kt new file mode 100644 index 0000000000..723abdc518 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.data.mangasync.base + +import android.content.Context +import eu.kanade.tachiyomi.App +import eu.kanade.tachiyomi.data.database.models.MangaSync +import eu.kanade.tachiyomi.data.network.NetworkHelper +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import okhttp3.Response +import rx.Observable +import javax.inject.Inject + +abstract class MangaSyncService(private val context: Context, val id: Int) { + + @Inject lateinit var preferences: PreferencesHelper + @Inject lateinit var networkService: NetworkHelper + + init { + App.get(context).component.inject(this) + } + + // Name of the manga sync service to display + abstract val name: String + + abstract fun login(username: String, password: String): Observable + + open val isLogged: Boolean + get() = !preferences.getMangaSyncUsername(this).isEmpty() && + !preferences.getMangaSyncPassword(this).isEmpty() + + abstract fun update(manga: MangaSync): Observable + + abstract fun add(manga: MangaSync): Observable + + abstract fun bind(manga: MangaSync): Observable + + abstract fun getStatus(status: Int): String + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.java b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.java deleted file mode 100644 index add73acc9c..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.java +++ /dev/null @@ -1,263 +0,0 @@ -package eu.kanade.tachiyomi.data.mangasync.services; - -import android.content.Context; -import android.net.Uri; -import android.util.Xml; - -import org.jsoup.Jsoup; -import org.xmlpull.v1.XmlSerializer; - -import java.io.IOException; -import java.io.StringWriter; -import java.util.List; - -import javax.inject.Inject; - -import eu.kanade.tachiyomi.App; -import eu.kanade.tachiyomi.R; -import eu.kanade.tachiyomi.data.database.models.MangaSync; -import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager; -import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService; -import eu.kanade.tachiyomi.data.network.NetworkHelper; -import eu.kanade.tachiyomi.data.preference.PreferencesHelper; -import okhttp3.Credentials; -import okhttp3.FormBody; -import okhttp3.Headers; -import okhttp3.RequestBody; -import okhttp3.Response; -import rx.Observable; - -public class MyAnimeList extends MangaSyncService { - - @Inject PreferencesHelper preferences; - @Inject NetworkHelper networkService; - - private Headers headers; - private String username; - - public static final String BASE_URL = "http://myanimelist.net"; - - private static final String ENTRY_TAG = "entry"; - private static final String CHAPTER_TAG = "chapter"; - private static final String SCORE_TAG = "score"; - private static final String STATUS_TAG = "status"; - - public static final int READING = 1; - public static final int COMPLETED = 2; - public static final int ON_HOLD = 3; - public static final int DROPPED = 4; - public static final int PLAN_TO_READ = 6; - - public static final int DEFAULT_STATUS = READING; - public static final int DEFAULT_SCORE = 0; - - private Context context; - - public MyAnimeList(Context context) { - this.context = context; - App.get(context).getComponent().inject(this); - - String username = preferences.getMangaSyncUsername(this); - String password = preferences.getMangaSyncPassword(this); - - if (!username.isEmpty() && !password.isEmpty()) { - createHeaders(username, password); - } - } - - @Override - public String getName() { - return "MyAnimeList"; - } - - @Override - public int getId() { - return MangaSyncManager.MYANIMELIST; - } - - public String getLoginUrl() { - return Uri.parse(BASE_URL).buildUpon() - .appendEncodedPath("api/account/verify_credentials.xml") - .toString(); - } - - public Observable login(String username, String password) { - createHeaders(username, password); - return networkService.getResponse(getLoginUrl(), headers, false) - .map(response -> response.code() == 200); - } - - @Override - public boolean isLogged() { - return !preferences.getMangaSyncUsername(this).isEmpty() - && !preferences.getMangaSyncPassword(this).isEmpty(); - } - - public String getSearchUrl(String query) { - return Uri.parse(BASE_URL).buildUpon() - .appendEncodedPath("api/manga/search.xml") - .appendQueryParameter("q", query) - .toString(); - } - - public Observable> search(String query) { - return networkService.getStringResponse(getSearchUrl(query), headers, true) - .map(Jsoup::parse) - .flatMap(doc -> Observable.from(doc.select("entry"))) - .filter(entry -> !entry.select("type").text().equals("Novel")) - .map(entry -> { - MangaSync manga = MangaSync.create(this); - manga.title = entry.select("title").first().text(); - manga.remote_id = Integer.parseInt(entry.select("id").first().text()); - manga.total_chapters = Integer.parseInt(entry.select("chapters").first().text()); - return manga; - }) - .toList(); - } - - public String getListUrl(String username) { - return Uri.parse(BASE_URL).buildUpon() - .appendPath("malappinfo.php") - .appendQueryParameter("u", username) - .appendQueryParameter("status", "all") - .appendQueryParameter("type", "manga") - .toString(); - } - - public Observable> getList() { - // TODO cache this list for a few minutes - return networkService.getStringResponse(getListUrl(username), headers, true) - .map(Jsoup::parse) - .flatMap(doc -> Observable.from(doc.select("manga"))) - .map(entry -> { - MangaSync manga = MangaSync.create(this); - manga.title = entry.select("series_title").first().text(); - manga.remote_id = Integer.parseInt( - entry.select("series_mangadb_id").first().text()); - manga.last_chapter_read = Integer.parseInt( - entry.select("my_read_chapters").first().text()); - manga.status = Integer.parseInt( - entry.select("my_status").first().text()); - // MAL doesn't support score with decimals - manga.score = Integer.parseInt( - entry.select("my_score").first().text()); - manga.total_chapters = Integer.parseInt( - entry.select("series_chapters").first().text()); - return manga; - }) - .toList(); - } - - public String getUpdateUrl(MangaSync manga) { - return Uri.parse(BASE_URL).buildUpon() - .appendEncodedPath("api/mangalist/update") - .appendPath(manga.remote_id + ".xml") - .toString(); - } - - public Observable update(MangaSync manga) { - try { - if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) { - manga.status = COMPLETED; - } - RequestBody payload = getMangaPostPayload(manga); - return networkService.postData(getUpdateUrl(manga), payload, headers); - } catch (IOException e) { - return Observable.error(e); - } - } - - public String getAddUrl(MangaSync manga) { - return Uri.parse(BASE_URL).buildUpon() - .appendEncodedPath("api/mangalist/add") - .appendPath(manga.remote_id + ".xml") - .toString(); - } - - public Observable add(MangaSync manga) { - try { - RequestBody payload = getMangaPostPayload(manga); - return networkService.postData(getAddUrl(manga), payload, headers); - } catch (IOException e) { - return Observable.error(e); - } - } - - private RequestBody getMangaPostPayload(MangaSync manga) throws IOException { - XmlSerializer xml = Xml.newSerializer(); - StringWriter writer = new StringWriter(); - xml.setOutput(writer); - xml.startDocument("UTF-8", false); - xml.startTag("", ENTRY_TAG); - - // Last chapter read - if (manga.last_chapter_read != 0) { - xml.startTag("", CHAPTER_TAG); - xml.text(manga.last_chapter_read + ""); - xml.endTag("", CHAPTER_TAG); - } - // Manga status in the list - xml.startTag("", STATUS_TAG); - xml.text(manga.status + ""); - xml.endTag("", STATUS_TAG); - // Manga score - xml.startTag("", SCORE_TAG); - xml.text(manga.score + ""); - xml.endTag("", SCORE_TAG); - - xml.endTag("", ENTRY_TAG); - xml.endDocument(); - - FormBody.Builder form = new FormBody.Builder(); - form.add("data", writer.toString()); - return form.build(); - } - - public Observable bind(MangaSync manga) { - return getList() - .flatMap(list -> { - manga.sync_id = getId(); - for (MangaSync remoteManga : list) { - if (remoteManga.remote_id == manga.remote_id) { - // Manga is already in the list - manga.copyPersonalFrom(remoteManga); - return update(manga); - } - } - // Set default fields if it's not found in the list - manga.score = DEFAULT_SCORE; - manga.status = DEFAULT_STATUS; - return add(manga); - }); - } - - @Override - public String getStatus(int status) { - switch (status) { - case READING: - return context.getString(R.string.reading); - case COMPLETED: - return context.getString(R.string.completed); - case ON_HOLD: - return context.getString(R.string.on_hold); - case DROPPED: - return context.getString(R.string.dropped); - case PLAN_TO_READ: - return context.getString(R.string.plan_to_read); - } - return ""; - } - - public void createHeaders(String username, String password) { - this.username = username; - Headers.Builder builder = new Headers.Builder(); - builder.add("Authorization", Credentials.basic(username, password)); - builder.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C"); - setHeaders(builder.build()); - } - - public void setHeaders(Headers headers) { - this.headers = headers; - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.kt new file mode 100644 index 0000000000..ab91b30503 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.kt @@ -0,0 +1,216 @@ +package eu.kanade.tachiyomi.data.mangasync.services + +import android.content.Context +import android.net.Uri +import android.util.Xml +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.MangaSync +import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService +import eu.kanade.tachiyomi.data.network.get +import eu.kanade.tachiyomi.data.network.post +import eu.kanade.tachiyomi.util.selectInt +import eu.kanade.tachiyomi.util.selectText +import okhttp3.* +import org.jsoup.Jsoup +import org.xmlpull.v1.XmlSerializer +import rx.Observable +import java.io.StringWriter + +fun XmlSerializer.inTag(tag: String, body: String, namespace: String = "") { + startTag(namespace, tag) + text(body) + endTag(namespace, tag) +} + +class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(context, id) { + + private lateinit var headers: Headers + private lateinit var username: String + + companion object { + val BASE_URL = "http://myanimelist.net" + + private val ENTRY_TAG = "entry" + private val CHAPTER_TAG = "chapter" + private val SCORE_TAG = "score" + private val STATUS_TAG = "status" + + val READING = 1 + val COMPLETED = 2 + val ON_HOLD = 3 + val DROPPED = 4 + val PLAN_TO_READ = 6 + + val DEFAULT_STATUS = READING + val DEFAULT_SCORE = 0 + } + + init { + val username = preferences.getMangaSyncUsername(this) + val password = preferences.getMangaSyncPassword(this) + + if (!username.isEmpty() && !password.isEmpty()) { + createHeaders(username, password) + } + } + + override val name: String + get() = "MyAnimeList" + + fun getLoginUrl(): String { + return Uri.parse(BASE_URL).buildUpon() + .appendEncodedPath("api/account/verify_credentials.xml") + .toString() + } + + override fun login(username: String, password: String): Observable { + createHeaders(username, password) + return networkService.request(get(getLoginUrl(), headers)) + .map { it.code() == 200 } + } + + fun getSearchUrl(query: String): String { + return Uri.parse(BASE_URL).buildUpon() + .appendEncodedPath("api/manga/search.xml") + .appendQueryParameter("q", query) + .toString() + } + + fun search(query: String): Observable> { + return networkService.requestBody(get(getSearchUrl(query), headers)) + .map { Jsoup.parse(it) } + .flatMap { Observable.from(it.select("entry")) } + .filter { it.select("type").text() != "Novel" } + .map { + val manga = MangaSync.create(this) + manga.title = it.selectText("title") + manga.remote_id = it.selectInt("id") + manga.total_chapters = it.selectInt("chapters") + manga + } + .toList() + } + + fun getListUrl(username: String): String { + return Uri.parse(BASE_URL).buildUpon() + .appendPath("malappinfo.php") + .appendQueryParameter("u", username) + .appendQueryParameter("status", "all") + .appendQueryParameter("type", "manga") + .toString() + } + + // MAL doesn't support score with decimals + fun getList(): Observable> { + return networkService.requestBody(get(getListUrl(username), headers), true) + .map { Jsoup.parse(it) } + .flatMap { Observable.from(it.select("manga")) } + .map { + val manga = MangaSync.create(this) + manga.title = it.selectText("series_title") + manga.remote_id = it.selectInt("series_mangadb_id") + manga.last_chapter_read = it.selectInt("my_read_chapters") + manga.status = it.selectInt("my_status") + manga.score = it.selectInt("my_score").toFloat() + manga.total_chapters = it.selectInt("series_chapters") + manga + } + .toList() + } + + fun getUpdateUrl(manga: MangaSync): String { + return Uri.parse(BASE_URL).buildUpon() + .appendEncodedPath("api/mangalist/update") + .appendPath(manga.remote_id.toString() + ".xml") + .toString() + } + + override fun update(manga: MangaSync): Observable { + return Observable.defer { + if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) { + manga.status = COMPLETED + } + networkService.request(post(getUpdateUrl(manga), headers, getMangaPostPayload(manga))) + } + + } + + fun getAddUrl(manga: MangaSync): String { + return Uri.parse(BASE_URL).buildUpon() + .appendEncodedPath("api/mangalist/add") + .appendPath(manga.remote_id.toString() + ".xml") + .toString() + } + + override fun add(manga: MangaSync): Observable { + return Observable.defer { + networkService.request(post(getAddUrl(manga), headers, getMangaPostPayload(manga))) + } + } + + private fun getMangaPostPayload(manga: MangaSync): RequestBody { + val xml = Xml.newSerializer() + val writer = StringWriter() + + with(xml) { + setOutput(writer) + startDocument("UTF-8", false) + startTag("", ENTRY_TAG) + + // Last chapter read + if (manga.last_chapter_read != 0) { + inTag(CHAPTER_TAG, manga.last_chapter_read.toString()) + } + // Manga status in the list + inTag(STATUS_TAG, manga.status.toString()) + + // Manga score + inTag(SCORE_TAG, manga.score.toString()) + + endTag("", ENTRY_TAG) + endDocument() + } + + val form = FormBody.Builder() + form.add("data", writer.toString()) + return form.build() + } + + override fun bind(manga: MangaSync): Observable { + return getList() + .flatMap { + manga.sync_id = id + for (remoteManga in it) { + if (remoteManga.remote_id == manga.remote_id) { + // Manga is already in the list + manga.copyPersonalFrom(remoteManga) + return@flatMap update(manga) + } + } + // Set default fields if it's not found in the list + manga.score = DEFAULT_SCORE.toFloat() + manga.status = DEFAULT_STATUS + return@flatMap add(manga) + } + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + ON_HOLD -> getString(R.string.on_hold) + DROPPED -> getString(R.string.dropped) + PLAN_TO_READ -> getString(R.string.plan_to_read) + else -> "" + } + } + + fun createHeaders(username: String, password: String) { + this.username = username + val builder = Headers.Builder() + builder.add("Authorization", Credentials.basic(username, password)) + builder.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C") + headers = builder.build() + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.java b/app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.java deleted file mode 100644 index 4ecbbaae62..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.java +++ /dev/null @@ -1,141 +0,0 @@ -package eu.kanade.tachiyomi.data.network; - - -import android.content.Context; - -import java.io.File; -import java.net.CookieManager; -import java.net.CookiePolicy; -import java.net.CookieStore; -import java.util.concurrent.TimeUnit; - -import okhttp3.Cache; -import okhttp3.CacheControl; -import okhttp3.FormBody; -import okhttp3.Headers; -import okhttp3.Interceptor; -import okhttp3.JavaNetCookieJar; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import rx.Observable; - -public final class NetworkHelper { - - private OkHttpClient client; - private OkHttpClient forceCacheClient; - - private CookieManager cookieManager; - - public final Headers NULL_HEADERS = new Headers.Builder().build(); - public final RequestBody NULL_REQUEST_BODY = new FormBody.Builder().build(); - public final CacheControl CACHE_CONTROL = new CacheControl.Builder() - .maxAge(10, TimeUnit.MINUTES) - .build(); - - private static final Interceptor REWRITE_CACHE_CONTROL_INTERCEPTOR = chain -> { - Response originalResponse = chain.proceed(chain.request()); - return originalResponse.newBuilder() - .removeHeader("Pragma") - .header("Cache-Control", "max-age=" + 600) - .build(); - }; - - private static final int CACHE_SIZE = 5 * 1024 * 1024; // 5 MiB - private static final String CACHE_DIR_NAME = "network_cache"; - - public NetworkHelper(Context context) { - File cacheDir = new File(context.getCacheDir(), CACHE_DIR_NAME); - - cookieManager = new CookieManager(); - cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL); - - client = new OkHttpClient.Builder() - .cookieJar(new JavaNetCookieJar(cookieManager)) - .cache(new Cache(cacheDir, CACHE_SIZE)) - .build(); - - forceCacheClient = client.newBuilder() - .addNetworkInterceptor(REWRITE_CACHE_CONTROL_INTERCEPTOR) - .build(); - } - - public Observable getResponse(final String url, final Headers headers, boolean forceCache) { - return Observable.defer(() -> { - try { - OkHttpClient c = forceCache ? forceCacheClient : client; - - Request request = new Request.Builder() - .url(url) - .headers(headers != null ? headers : NULL_HEADERS) - .cacheControl(CACHE_CONTROL) - .build(); - - return Observable.just(c.newCall(request).execute()); - } catch (Throwable e) { - return Observable.error(e); - } - }).retry(1); - } - - public Observable mapResponseToString(final Response response) { - return Observable.defer(() -> { - try { - return Observable.just(response.body().string()); - } catch (Throwable e) { - return Observable.error(e); - } - }); - } - - public Observable getStringResponse(final String url, final Headers headers, boolean forceCache) { - return getResponse(url, headers, forceCache) - .flatMap(this::mapResponseToString); - } - - public Observable postData(final String url, final RequestBody formBody, final Headers headers) { - return Observable.defer(() -> { - try { - Request request = new Request.Builder() - .url(url) - .post(formBody != null ? formBody : NULL_REQUEST_BODY) - .headers(headers != null ? headers : NULL_HEADERS) - .build(); - return Observable.just(client.newCall(request).execute()); - } catch (Throwable e) { - return Observable.error(e); - } - }).retry(1); - } - - public Observable getProgressResponse(final String url, final Headers headers, final ProgressListener listener) { - return Observable.defer(() -> { - try { - Request request = new Request.Builder() - .url(url) - .cacheControl(CacheControl.FORCE_NETWORK) - .headers(headers != null ? headers : NULL_HEADERS) - .build(); - - OkHttpClient progressClient = client.newBuilder() - .cache(null) - .addNetworkInterceptor(chain -> { - Response originalResponse = chain.proceed(chain.request()); - return originalResponse.newBuilder() - .body(new ProgressResponseBody(originalResponse.body(), listener)) - .build(); - }).build(); - - return Observable.just(progressClient.newCall(request).execute()); - } catch (Throwable e) { - return Observable.error(e); - } - }).retry(1); - } - - public CookieStore getCookies() { - return cookieManager.getCookieStore(); - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.kt new file mode 100644 index 0000000000..05225aad5b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.kt @@ -0,0 +1,78 @@ +package eu.kanade.tachiyomi.data.network + +import android.content.Context +import okhttp3.* +import rx.Observable +import java.io.File +import java.net.CookieManager +import java.net.CookiePolicy +import java.net.CookieStore + +class NetworkHelper(context: Context) { + + private val client: OkHttpClient + private val forceCacheClient: OkHttpClient + + private val cookieManager: CookieManager + + private val forceCacheInterceptor = { chain: Interceptor.Chain -> + val originalResponse = chain.proceed(chain.request()) + originalResponse.newBuilder() + .removeHeader("Pragma") + .header("Cache-Control", "max-age=" + 600) + .build() + } + + private val cacheSize = 5L * 1024 * 1024 // 5 MiB + private val cacheDir = "network_cache" + + init { + val cacheDir = File(context.cacheDir, cacheDir) + + cookieManager = CookieManager() + cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL) + + client = OkHttpClient.Builder() + .cookieJar(JavaNetCookieJar(cookieManager)) + .cache(Cache(cacheDir, cacheSize)) + .build() + + forceCacheClient = client.newBuilder() + .addNetworkInterceptor(forceCacheInterceptor) + .build() + } + + @JvmOverloads + fun request(request: Request, forceCache: Boolean = false): Observable { + return Observable.fromCallable { + val c = if (forceCache) forceCacheClient else client + c.newCall(request).execute() + } + } + + @JvmOverloads + fun requestBody(request: Request, forceCache: Boolean = false): Observable { + return request(request, forceCache) + .map { it.body().string() } + } + + fun requestBodyProgress(request: Request, listener: ProgressListener): Observable { + return Observable.fromCallable { + val progressClient = client.newBuilder() + .cache(null) + .addNetworkInterceptor { chain -> + val originalResponse = chain.proceed(chain.request()) + originalResponse.newBuilder() + .body(ProgressResponseBody(originalResponse.body(), listener)) + .build() + } + .build() + + progressClient.newCall(request).execute() + }.retry(1) + } + + val cookies: CookieStore + get() = cookieManager.cookieStore + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.java b/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.java deleted file mode 100644 index ae43b27f77..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package eu.kanade.tachiyomi.data.network; - -public interface ProgressListener { - void update(long bytesRead, long contentLength, boolean done); -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.kt b/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.kt new file mode 100644 index 0000000000..f624e2b621 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.kt @@ -0,0 +1,5 @@ +package eu.kanade.tachiyomi.data.network + +interface ProgressListener { + fun update(bytesRead: Long, contentLength: Long, done: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.java b/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.java deleted file mode 100644 index b74016bc42..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.java +++ /dev/null @@ -1,52 +0,0 @@ -package eu.kanade.tachiyomi.data.network; - -import java.io.IOException; - -import okhttp3.MediaType; -import okhttp3.ResponseBody; -import okio.Buffer; -import okio.BufferedSource; -import okio.ForwardingSource; -import okio.Okio; -import okio.Source; - -public class ProgressResponseBody extends ResponseBody { - - private final ResponseBody responseBody; - private final ProgressListener progressListener; - private BufferedSource bufferedSource; - - public ProgressResponseBody(ResponseBody responseBody, ProgressListener progressListener) { - this.responseBody = responseBody; - this.progressListener = progressListener; - } - - @Override public MediaType contentType() { - return responseBody.contentType(); - } - - @Override public long contentLength() { - return responseBody.contentLength(); - } - - @Override public BufferedSource source() { - if (bufferedSource == null) { - bufferedSource = Okio.buffer(source(responseBody.source())); - } - return bufferedSource; - } - - private Source source(Source source) { - return new ForwardingSource(source) { - long totalBytesRead = 0L; - - @Override public long read(Buffer sink, long byteCount) throws IOException { - long bytesRead = super.read(sink, byteCount); - // read() returns the number of bytes read, or -1 if this source is exhausted. - totalBytesRead += bytesRead != -1 ? bytesRead : 0; - progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1); - return bytesRead; - } - }; - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.kt b/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.kt new file mode 100644 index 0000000000..67c639b1ae --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.kt @@ -0,0 +1,40 @@ +package eu.kanade.tachiyomi.data.network + +import okhttp3.MediaType +import okhttp3.ResponseBody +import okio.* +import java.io.IOException + +class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() { + + private val bufferedSource: BufferedSource by lazy { + Okio.buffer(source(responseBody.source())) + } + + override fun contentType(): MediaType { + return responseBody.contentType() + } + + override fun contentLength(): Long { + return responseBody.contentLength() + } + + override fun source(): BufferedSource { + return bufferedSource + } + + private fun source(source: Source): Source { + return object : ForwardingSource(source) { + internal var totalBytesRead = 0L + + @Throws(IOException::class) + override fun read(sink: Buffer, byteCount: Long): Long { + val bytesRead = super.read(sink, byteCount) + // read() returns the number of bytes read, or -1 if this source is exhausted. + totalBytesRead += if (bytesRead != -1L) bytesRead else 0 + progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L) + return bytesRead + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/network/Req.kt b/app/src/main/java/eu/kanade/tachiyomi/data/network/Req.kt new file mode 100644 index 0000000000..0cedb2e970 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/network/Req.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.data.network + +import okhttp3.* +import java.util.concurrent.TimeUnit + +private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, TimeUnit.MINUTES).build() +private val DEFAULT_HEADERS = Headers.Builder().build() +private val DEFAULT_BODY: RequestBody = FormBody.Builder().build() + +@JvmOverloads +fun get(url: String, + headers: Headers = DEFAULT_HEADERS, + cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { + + return Request.Builder() + .url(url) + .headers(headers) + .cacheControl(cache) + .build() +} + +@JvmOverloads +fun post(url: String, + headers: Headers = DEFAULT_HEADERS, + body: RequestBody = DEFAULT_BODY, + cache: CacheControl = DEFAULT_CACHE_CONTROL): Request { + + return Request.Builder() + .url(url) + .post(body) + .headers(headers) + .cacheControl(cache) + .build() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.java b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.java index 4e93f17681..342481f1f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.java +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.java @@ -190,4 +190,8 @@ public class PreferencesHelper { context.getString(R.string.pref_library_update_interval_key), 0); } + public Preference libraryUpdateInterval() { + return rxPrefs.getInteger(getKey(R.string.pref_library_update_interval_key), 0); + } + } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/rest/GithubService.java b/app/src/main/java/eu/kanade/tachiyomi/data/rest/GithubService.java deleted file mode 100644 index 2f34f6b5b8..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/rest/GithubService.java +++ /dev/null @@ -1,15 +0,0 @@ -package eu.kanade.tachiyomi.data.rest; - -import retrofit.http.GET; -import rx.Observable; - - -/** - * Used to connect with the Github API - */ -public interface GithubService { - String SERVICE_ENDPOINT = "https://api.github.com"; - - @GET("/repos/inorichi/tachiyomi/releases/latest") Observable getLatestVersion(); - -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/rest/Release.java b/app/src/main/java/eu/kanade/tachiyomi/data/rest/Release.java deleted file mode 100644 index b0f2398cbe..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/rest/Release.java +++ /dev/null @@ -1,93 +0,0 @@ -package eu.kanade.tachiyomi.data.rest; - -import com.google.gson.annotations.SerializedName; - -import java.util.List; - -/** - * Release object - * Contains information about the latest release - */ -public class Release { - /** - * Version name V0.0.0 - */ - @SerializedName("tag_name") - private final String version; - - /** Change Log */ - @SerializedName("body") - private final String log; - - /** Assets containing download url */ - @SerializedName("assets") - private final List assets; - - /** - * Release constructor - * - * @param version version of latest release - * @param log log of latest release - * @param assets assets of latest release - */ - public Release(String version, String log, List assets) { - this.version = version; - this.log = log; - this.assets = assets; - } - - /** - * Get latest release version - * - * @return latest release version - */ - public String getVersion() { - return version; - } - - /** - * Get change log of latest release - * - * @return change log of latest release - */ - public String getChangeLog() { - return log; - } - - /** - * Get download link of latest release - * - * @return download link of latest release - */ - public String getDownloadLink() { - return assets.get(0).getDownloadLink(); - } - - /** - * Assets class containing download url - */ - class Assets { - @SerializedName("browser_download_url") - private final String download_url; - - - /** - * Assets Constructor - * - * @param download_url download url - */ - @SuppressWarnings("unused") public Assets(String download_url) { - this.download_url = download_url; - } - - /** - * Get download link of latest release - * - * @return download link of latest release - */ - public String getDownloadLink() { - return download_url; - } - } -} - diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/rest/ServiceFactory.java b/app/src/main/java/eu/kanade/tachiyomi/data/rest/ServiceFactory.java deleted file mode 100644 index 6cf455fda7..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/rest/ServiceFactory.java +++ /dev/null @@ -1,21 +0,0 @@ -package eu.kanade.tachiyomi.data.rest; - -import retrofit.RestAdapter; - -public class ServiceFactory { - - /** - * Creates a retrofit service from an arbitrary class (clazz) - * - * @param clazz Java interface of the retrofit service - * @param endPoint REST endpoint url - * @return retrofit service with defined endpoint - */ - public static T createRetrofitService(final Class clazz, final String endPoint) { - final RestAdapter restAdapter = new RestAdapter.Builder() - .setEndpoint(endPoint) - .build(); - - return restAdapter.create(clazz); - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.java b/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.java deleted file mode 100644 index 2a7d183f15..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.java +++ /dev/null @@ -1,68 +0,0 @@ -package eu.kanade.tachiyomi.data.source; - -import android.content.Context; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; - -import eu.kanade.tachiyomi.data.source.base.Source; -import eu.kanade.tachiyomi.data.source.online.english.Batoto; -import eu.kanade.tachiyomi.data.source.online.english.Kissmanga; -import eu.kanade.tachiyomi.data.source.online.english.Mangafox; -import eu.kanade.tachiyomi.data.source.online.english.Mangahere; - -public class SourceManager { - - public static final int BATOTO = 1; - public static final int MANGAHERE = 2; - public static final int MANGAFOX = 3; - public static final int KISSMANGA = 4; - - private HashMap sourcesMap; - private Context context; - - public SourceManager(Context context) { - sourcesMap = new HashMap<>(); - this.context = context; - - initializeSources(); - } - - public Source get(int sourceKey) { - if (!sourcesMap.containsKey(sourceKey)) { - sourcesMap.put(sourceKey, createSource(sourceKey)); - } - return sourcesMap.get(sourceKey); - } - - private Source createSource(int sourceKey) { - switch (sourceKey) { - case BATOTO: - return new Batoto(context); - case MANGAHERE: - return new Mangahere(context); - case MANGAFOX: - return new Mangafox(context); - case KISSMANGA: - return new Kissmanga(context); - } - - return null; - } - - private void initializeSources() { - sourcesMap.put(BATOTO, createSource(BATOTO)); - sourcesMap.put(MANGAHERE, createSource(MANGAHERE)); - sourcesMap.put(MANGAFOX, createSource(MANGAFOX)); - sourcesMap.put(KISSMANGA, createSource(KISSMANGA)); - } - - public List getSources() { - List sources = new ArrayList<>(sourcesMap.values()); - Collections.sort(sources, (s1, s2) -> s1.getName().compareTo(s2.getName())); - return sources; - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.kt new file mode 100644 index 0000000000..f002e3ef7c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.kt @@ -0,0 +1,52 @@ +package eu.kanade.tachiyomi.data.source + +import android.content.Context +import eu.kanade.tachiyomi.data.source.base.Source +import eu.kanade.tachiyomi.data.source.online.english.Batoto +import eu.kanade.tachiyomi.data.source.online.english.Kissmanga +import eu.kanade.tachiyomi.data.source.online.english.Mangafox +import eu.kanade.tachiyomi.data.source.online.english.Mangahere +import java.util.* + +open class SourceManager(private val context: Context) { + + val sourcesMap: HashMap + val sources: List + + val BATOTO = 1 + val MANGAHERE = 2 + val MANGAFOX = 3 + val KISSMANGA = 4 + + val LAST_SOURCE = 4 + + init { + sourcesMap = createSourcesMap() + sources = ArrayList(sourcesMap.values).sortedBy { it.name } + } + + open fun get(sourceKey: Int): Source? { + return sourcesMap[sourceKey] + } + + private fun createSource(sourceKey: Int): Source? = when (sourceKey) { + BATOTO -> Batoto(context) + MANGAHERE -> Mangahere(context) + MANGAFOX -> Mangafox(context) + KISSMANGA -> Kissmanga(context) + else -> null + } + + private fun createSourcesMap(): HashMap { + val map = HashMap() + for (i in 1..LAST_SOURCE) { + val source = createSource(i) + if (source != null) { + source.id = i + map.put(i, source) + } + } + return map + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/base/BaseSource.java b/app/src/main/java/eu/kanade/tachiyomi/data/source/base/BaseSource.java index 890c5b986f..3410ae0416 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/base/BaseSource.java +++ b/app/src/main/java/eu/kanade/tachiyomi/data/source/base/BaseSource.java @@ -13,12 +13,20 @@ import rx.Observable; public abstract class BaseSource { + private int id; + + // Id of the source + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + // Name of the source to display public abstract String getName(); - // Id of the source (must be declared and obtained from SourceManager to avoid conflicts) - public abstract int getId(); - // Base url of the source, like: http://example.com public abstract String getBaseUrl(); @@ -68,24 +76,6 @@ public abstract class BaseSource { protected boolean isAuthenticationSuccessful(Response response) { throw new UnsupportedOperationException("Not implemented"); } - - - // Default fields, they can be overriden by sources' implementation - - // Get the URL to the details of a manga, useful if the source provides some kind of API or fast calls - protected String overrideMangaUrl(String defaultMangaUrl) { - return defaultMangaUrl; - } - - // Get the URL of the first page that contains a source image and the page list - protected String overrideChapterUrl(String defaultPageUrl) { - return defaultPageUrl; - } - - // Get the URL of the pages that contains source images - protected String overridePageUrl(String defaultPageUrl) { - return defaultPageUrl; - } // Default headers, it can be overriden by children or just add new keys protected Headers.Builder headersBuilder() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/base/Source.java b/app/src/main/java/eu/kanade/tachiyomi/data/source/base/Source.java index 417d60bd13..89e2738aa6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/base/Source.java +++ b/app/src/main/java/eu/kanade/tachiyomi/data/source/base/Source.java @@ -18,10 +18,12 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache; import eu.kanade.tachiyomi.data.database.models.Chapter; import eu.kanade.tachiyomi.data.database.models.Manga; import eu.kanade.tachiyomi.data.network.NetworkHelper; +import eu.kanade.tachiyomi.data.network.ReqKt; import eu.kanade.tachiyomi.data.preference.PreferencesHelper; import eu.kanade.tachiyomi.data.source.model.MangasPage; import eu.kanade.tachiyomi.data.source.model.Page; import okhttp3.Headers; +import okhttp3.Request; import okhttp3.Response; import rx.Observable; import rx.schedulers.Schedulers; @@ -47,13 +49,46 @@ public abstract class Source extends BaseSource { return false; } + protected Request popularMangaRequest(MangasPage page) { + if (page.page == 1) { + page.url = getInitialPopularMangasUrl(); + } + + return ReqKt.get(page.url, requestHeaders); + } + + protected Request searchMangaRequest(MangasPage page, String query) { + if (page.page == 1) { + page.url = getInitialSearchUrl(query); + } + + return ReqKt.get(page.url, requestHeaders); + } + + protected Request mangaDetailsRequest(String mangaUrl) { + return ReqKt.get(getBaseUrl() + mangaUrl, requestHeaders); + } + + protected Request chapterListRequest(String mangaUrl) { + return ReqKt.get(getBaseUrl() + mangaUrl, requestHeaders); + } + + protected Request pageListRequest(String chapterUrl) { + return ReqKt.get(getBaseUrl() + chapterUrl, requestHeaders); + } + + protected Request imageUrlRequest(Page page) { + return ReqKt.get(page.getUrl(), requestHeaders); + } + + protected Request imageRequest(Page page) { + return ReqKt.get(page.getImageUrl(), requestHeaders); + } + // Get the most popular mangas from the source public Observable pullPopularMangasFromNetwork(MangasPage page) { - if (page.page == 1) - page.url = getInitialPopularMangasUrl(); - return networkService - .getStringResponse(page.url, requestHeaders, true) + .requestBody(popularMangaRequest(page), true) .map(Jsoup::parse) .doOnNext(doc -> page.mangas = parsePopularMangasFromHtml(doc)) .doOnNext(doc -> page.nextPageUrl = parseNextPopularMangasUrl(doc, page)) @@ -62,11 +97,8 @@ public abstract class Source extends BaseSource { // Get mangas from the source with a query public Observable searchMangasFromNetwork(MangasPage page, String query) { - if (page.page == 1) - page.url = getInitialSearchUrl(query); - return networkService - .getStringResponse(page.url, requestHeaders, true) + .requestBody(searchMangaRequest(page, query), true) .map(Jsoup::parse) .doOnNext(doc -> page.mangas = parseSearchFromHtml(doc)) .doOnNext(doc -> page.nextPageUrl = parseNextSearchUrl(doc, page, query)) @@ -76,14 +108,14 @@ public abstract class Source extends BaseSource { // Get manga details from the source public Observable pullMangaFromNetwork(final String mangaUrl) { return networkService - .getStringResponse(getBaseUrl() + overrideMangaUrl(mangaUrl), requestHeaders, true) + .requestBody(mangaDetailsRequest(mangaUrl)) .flatMap(unparsedHtml -> Observable.just(parseHtmlToManga(mangaUrl, unparsedHtml))); } // Get chapter list of a manga from the source public Observable> pullChaptersFromNetwork(final String mangaUrl) { return networkService - .getStringResponse(getBaseUrl() + mangaUrl, requestHeaders, false) + .requestBody(chapterListRequest(mangaUrl)) .flatMap(unparsedHtml -> { List chapters = parseHtmlToChapters(unparsedHtml); return !chapters.isEmpty() ? @@ -102,7 +134,7 @@ public abstract class Source extends BaseSource { public Observable> pullPageListFromNetwork(final String chapterUrl) { return networkService - .getStringResponse(getBaseUrl() + overrideChapterUrl(chapterUrl), requestHeaders, false) + .requestBody(pageListRequest(chapterUrl)) .flatMap(unparsedHtml -> { List pages = convertToPages(parseHtmlToPageUrls(unparsedHtml)); return !pages.isEmpty() ? @@ -127,7 +159,7 @@ public abstract class Source extends BaseSource { public Observable getImageUrlFromPage(final Page page) { page.setStatus(Page.LOAD_PAGE); return networkService - .getStringResponse(overridePageUrl(page.getUrl()), requestHeaders, false) + .requestBody(imageUrlRequest(page)) .flatMap(unparsedHtml -> Observable.just(parseHtmlToImageUrl(unparsedHtml))) .onErrorResumeNext(e -> { page.setStatus(Page.ERROR); @@ -177,7 +209,7 @@ public abstract class Source extends BaseSource { } public Observable getImageProgressResponse(final Page page) { - return networkService.getProgressResponse(page.getImageUrl(), requestHeaders, page); + return networkService.requestBodyProgress(imageRequest(page), page); } public void savePageList(String chapterUrl, List pages) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.java b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.java index 827cab0899..174a190069 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.java +++ b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.java @@ -27,13 +27,14 @@ import java.util.regex.Pattern; import eu.kanade.tachiyomi.data.database.models.Chapter; import eu.kanade.tachiyomi.data.database.models.Manga; -import eu.kanade.tachiyomi.data.source.SourceManager; +import eu.kanade.tachiyomi.data.network.ReqKt; import eu.kanade.tachiyomi.data.source.base.LoginSource; import eu.kanade.tachiyomi.data.source.model.MangasPage; import eu.kanade.tachiyomi.data.source.model.Page; import eu.kanade.tachiyomi.util.Parser; import okhttp3.FormBody; import okhttp3.Headers; +import okhttp3.Request; import okhttp3.Response; import rx.Observable; @@ -41,11 +42,11 @@ public class Batoto extends LoginSource { public static final String NAME = "Batoto (EN)"; public static final String BASE_URL = "http://bato.to"; - public static final String POPULAR_MANGAS_URL = BASE_URL + "/search_ajax?order_cond=views&order=desc&p=%d"; + public static final String POPULAR_MANGAS_URL = BASE_URL + "/search_ajax?order_cond=views&order=desc&p=%s"; public static final String SEARCH_URL = BASE_URL + "/search_ajax?name=%s&p=%s"; - public static final String CHAPTER_URL = "/areader?id=%s&p=1"; + public static final String CHAPTER_URL = BASE_URL + "/areader?id=%s&p=1"; public static final String PAGE_URL = BASE_URL + "/areader?id=%s&p=%s"; - public static final String MANGA_URL = "/comic_pop?id=%s"; + public static final String MANGA_URL = BASE_URL + "/comic_pop?id=%s"; public static final String LOGIN_URL = BASE_URL + "/forums/index.php?app=core&module=global§ion=login"; public static final Pattern staffNotice = Pattern.compile("=+Batoto Staff Notice=+([^=]+)==+", Pattern.CASE_INSENSITIVE); @@ -73,11 +74,6 @@ public class Batoto extends LoginSource { return NAME; } - @Override - public int getId() { - return SourceManager.BATOTO; - } - @Override public String getBaseUrl() { return BASE_URL; @@ -102,23 +98,24 @@ public class Batoto extends LoginSource { } @Override - protected String overrideMangaUrl(String defaultMangaUrl) { - String mangaId = defaultMangaUrl.substring(defaultMangaUrl.lastIndexOf("r") + 1); - return String.format(MANGA_URL, mangaId); + protected Request mangaDetailsRequest(String mangaUrl) { + String mangaId = mangaUrl.substring(mangaUrl.lastIndexOf("r") + 1); + return ReqKt.get(String.format(MANGA_URL, mangaId), requestHeaders); } @Override - protected String overrideChapterUrl(String defaultPageUrl) { - String id = defaultPageUrl.substring(defaultPageUrl.indexOf("#") + 1); - return String.format(CHAPTER_URL, id); + protected Request pageListRequest(String pageUrl) { + String id = pageUrl.substring(pageUrl.indexOf("#") + 1); + return ReqKt.get(String.format(CHAPTER_URL, id), requestHeaders); } @Override - protected String overridePageUrl(String defaultPageUrl) { - int start = defaultPageUrl.indexOf("#") + 1; - int end = defaultPageUrl.indexOf("_", start); - String id = defaultPageUrl.substring(start, end); - return String.format(PAGE_URL, id, defaultPageUrl.substring(end+1)); + protected Request imageUrlRequest(Page page) { + String pageUrl = page.getUrl(); + int start = pageUrl.indexOf("#") + 1; + int end = pageUrl.indexOf("_", start); + String id = pageUrl.substring(start, end); + return ReqKt.get(String.format(PAGE_URL, id, pageUrl.substring(end+1)), requestHeaders); } private List parseMangasFromHtml(Document parsedHtml) { @@ -318,7 +315,7 @@ public class Batoto extends LoginSource { @Override public Observable login(String username, String password) { - return networkService.getStringResponse(LOGIN_URL, requestHeaders, false) + return networkService.requestBody(ReqKt.get(LOGIN_URL, requestHeaders)) .flatMap(response -> doLogin(response, username, password)) .map(this::isAuthenticationSuccessful); } @@ -337,7 +334,7 @@ public class Batoto extends LoginSource { formBody.add("invisible", "1"); formBody.add("rememberMe", "1"); - return networkService.postData(postUrl, formBody.build(), requestHeaders); + return networkService.request(ReqKt.post(postUrl, requestHeaders, formBody.build())); } @Override diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.java b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.java index 75a9d78b85..50cbd1bf7a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.java +++ b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.java @@ -17,15 +17,14 @@ import java.util.regex.Pattern; import eu.kanade.tachiyomi.data.database.models.Chapter; import eu.kanade.tachiyomi.data.database.models.Manga; -import eu.kanade.tachiyomi.data.source.SourceManager; +import eu.kanade.tachiyomi.data.network.ReqKt; import eu.kanade.tachiyomi.data.source.base.Source; import eu.kanade.tachiyomi.data.source.model.MangasPage; import eu.kanade.tachiyomi.data.source.model.Page; import eu.kanade.tachiyomi.util.Parser; import okhttp3.FormBody; import okhttp3.Headers; -import okhttp3.Response; -import rx.Observable; +import okhttp3.Request; public class Kissmanga extends Source { @@ -52,11 +51,6 @@ public class Kissmanga extends Source { return NAME; } - @Override - public int getId() { - return SourceManager.KISSMANGA; - } - @Override public String getBaseUrl() { return BASE_URL; @@ -72,6 +66,31 @@ public class Kissmanga extends Source { return SEARCH_URL; } + @Override + protected Request searchMangaRequest(MangasPage page, String query) { + if (page.page == 1) { + page.url = getInitialSearchUrl(query); + } + + FormBody.Builder form = new FormBody.Builder(); + form.add("authorArtist", ""); + form.add("mangaName", query); + form.add("status", ""); + form.add("genres", ""); + + return ReqKt.post(page.url, requestHeaders, form.build()); + } + + @Override + protected Request pageListRequest(String chapterUrl) { + return ReqKt.post(getBaseUrl() + chapterUrl, requestHeaders); + } + + @Override + protected Request imageRequest(Page page) { + return ReqKt.get(page.getImageUrl()); + } + @Override protected List parsePopularMangasFromHtml(Document parsedHtml) { List mangaList = new ArrayList<>(); @@ -104,25 +123,6 @@ public class Kissmanga extends Source { return path != null ? BASE_URL + path : null; } - public Observable searchMangasFromNetwork(MangasPage page, String query) { - if (page.page == 1) - page.url = getInitialSearchUrl(query); - - FormBody.Builder form = new FormBody.Builder(); - form.add("authorArtist", ""); - form.add("mangaName", query); - form.add("status", ""); - form.add("genres", ""); - - return networkService - .postData(page.url, form.build(), requestHeaders) - .flatMap(networkService::mapResponseToString) - .map(Jsoup::parse) - .doOnNext(doc -> page.mangas = parseSearchFromHtml(doc)) - .doOnNext(doc -> page.nextPageUrl = parseNextSearchUrl(doc, page, query)) - .map(response -> page); - } - @Override protected List parseSearchFromHtml(Document parsedHtml) { return parsePopularMangasFromHtml(parsedHtml); @@ -195,19 +195,6 @@ public class Kissmanga extends Source { return chapter; } - @Override - public Observable> pullPageListFromNetwork(final String chapterUrl) { - return networkService - .postData(getBaseUrl() + overrideChapterUrl(chapterUrl), null, requestHeaders) - .flatMap(networkService::mapResponseToString) - .flatMap(unparsedHtml -> { - List pages = convertToPages(parseHtmlToPageUrls(unparsedHtml)); - return !pages.isEmpty() ? - Observable.just(parseFirstPage(pages, unparsedHtml)) : - Observable.error(new Exception("Page list is empty")); - }); - } - @Override protected List parseHtmlToPageUrls(String unparsedHtml) { Document parsedDocument = Jsoup.parse(unparsedHtml); @@ -238,9 +225,4 @@ public class Kissmanga extends Source { return null; } - @Override - public Observable getImageProgressResponse(final Page page) { - return networkService.getProgressResponse(page.getImageUrl(), null, page); - } - } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.java b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.java index 4ec2abacc5..a7b16f41b1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.java +++ b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.java @@ -18,7 +18,6 @@ import java.util.Locale; import eu.kanade.tachiyomi.data.database.models.Chapter; import eu.kanade.tachiyomi.data.database.models.Manga; -import eu.kanade.tachiyomi.data.source.SourceManager; import eu.kanade.tachiyomi.data.source.base.Source; import eu.kanade.tachiyomi.data.source.model.MangasPage; import eu.kanade.tachiyomi.util.Parser; @@ -40,11 +39,6 @@ public class Mangafox extends Source { return NAME; } - @Override - public int getId() { - return SourceManager.MANGAFOX; - } - @Override public String getBaseUrl() { return BASE_URL; diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.java b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.java index 97c55092dc..b545b7e8ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.java +++ b/app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.java @@ -18,7 +18,6 @@ import java.util.Locale; import eu.kanade.tachiyomi.data.database.models.Chapter; import eu.kanade.tachiyomi.data.database.models.Manga; -import eu.kanade.tachiyomi.data.source.SourceManager; import eu.kanade.tachiyomi.data.source.base.Source; import eu.kanade.tachiyomi.data.source.model.MangasPage; import eu.kanade.tachiyomi.util.Parser; @@ -39,11 +38,6 @@ public class Mangahere extends Source { return NAME; } - @Override - public int getId() { - return SourceManager.MANGAHERE; - } - @Override public String getBaseUrl() { return BASE_URL; diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateAlarm.java b/app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateAlarm.java deleted file mode 100644 index 6c8df38fbf..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateAlarm.java +++ /dev/null @@ -1,62 +0,0 @@ -package eu.kanade.tachiyomi.data.sync; - -import android.app.AlarmManager; -import android.app.PendingIntent; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.SystemClock; - -import eu.kanade.tachiyomi.data.preference.PreferencesHelper; -import timber.log.Timber; - -public class LibraryUpdateAlarm extends BroadcastReceiver { - - public static final String LIBRARY_UPDATE_ACTION = "eu.kanade.UPDATE_LIBRARY"; - - public static void startAlarm(Context context) { - startAlarm(context, PreferencesHelper.getLibraryUpdateInterval(context)); - } - - public static void startAlarm(Context context, int intervalInHours) { - stopAlarm(context); - if (intervalInHours == 0) - return; - - int intervalInMillis = intervalInHours * 60 * 60 * 1000; - long nextRun = SystemClock.elapsedRealtime() + intervalInMillis; - - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - PendingIntent pendingIntent = getPendingIntent(context); - alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, - nextRun, intervalInMillis, pendingIntent); - - Timber.i("Alarm set. Library will update on " + nextRun); - } - - public static void stopAlarm(Context context) { - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - PendingIntent pendingIntent = getPendingIntent(context); - alarmManager.cancel(pendingIntent); - } - - private static PendingIntent getPendingIntent(Context context) { - Intent intent = new Intent(context, LibraryUpdateAlarm.class); - intent.setAction(LIBRARY_UPDATE_ACTION); - return PendingIntent.getBroadcast(context, 0, intent, 0); - } - - @Override - public void onReceive(Context context, Intent intent) { - if (intent.getAction() == null) - return; - - if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { - startAlarm(context); - } else if (intent.getAction().equals(LIBRARY_UPDATE_ACTION)) { - LibraryUpdateService.start(context); - } - - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateService.java b/app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateService.java deleted file mode 100644 index eb9ae44607..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateService.java +++ /dev/null @@ -1,258 +0,0 @@ -package eu.kanade.tachiyomi.data.sync; - -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.os.IBinder; -import android.os.PowerManager; -import android.support.v4.app.NotificationCompat; -import android.util.Pair; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import javax.inject.Inject; - -import eu.kanade.tachiyomi.App; -import eu.kanade.tachiyomi.BuildConfig; -import eu.kanade.tachiyomi.R; -import eu.kanade.tachiyomi.data.database.DatabaseHelper; -import eu.kanade.tachiyomi.data.database.models.Manga; -import eu.kanade.tachiyomi.data.preference.PreferencesHelper; -import eu.kanade.tachiyomi.data.source.SourceManager; -import eu.kanade.tachiyomi.ui.main.MainActivity; -import eu.kanade.tachiyomi.util.AndroidComponentUtil; -import eu.kanade.tachiyomi.util.NetworkUtil; -import rx.Observable; -import rx.Subscription; -import rx.schedulers.Schedulers; -import timber.log.Timber; - -public class LibraryUpdateService extends Service { - - @Inject DatabaseHelper db; - @Inject SourceManager sourceManager; - @Inject PreferencesHelper preferences; - - private PowerManager.WakeLock wakeLock; - private Subscription subscription; - - public static final int UPDATE_NOTIFICATION_ID = 1; - - public static void start(Context context) { - if (!isRunning(context)) { - context.startService(getStartIntent(context)); - } - } - - private static Intent getStartIntent(Context context) { - return new Intent(context, LibraryUpdateService.class); - } - - private static boolean isRunning(Context context) { - return AndroidComponentUtil.isServiceRunning(context, LibraryUpdateService.class); - } - - @Override - public void onCreate() { - super.onCreate(); - App.get(this).getComponent().inject(this); - createAndAcquireWakeLock(); - } - - @Override - public void onDestroy() { - if (subscription != null) - subscription.unsubscribe(); - // Reset the alarm - LibraryUpdateAlarm.startAlarm(this); - destroyWakeLock(); - super.onDestroy(); - } - - @Override - public int onStartCommand(Intent intent, int flags, final int startId) { - Timber.i("Starting sync..."); - - if (!NetworkUtil.isNetworkConnected(this)) { - Timber.i("Sync canceled, connection not available"); - AndroidComponentUtil.toggleComponent(this, SyncOnConnectionAvailable.class, true); - stopSelf(startId); - return START_NOT_STICKY; - } - - subscription = Observable.fromCallable(() -> db.getFavoriteMangas().executeAsBlocking()) - .subscribeOn(Schedulers.io()) - .flatMap(this::updateLibrary) - .subscribe(next -> {}, - error -> { - showNotification(getString(R.string.notification_update_error), ""); - stopSelf(startId); - }, () -> { - Timber.i("Library updated"); - stopSelf(startId); - }); - - return START_STICKY; - } - - private Observable updateLibrary(List allLibraryMangas) { - final AtomicInteger count = new AtomicInteger(0); - final List updates = new ArrayList<>(); - final List failedUpdates = new ArrayList<>(); - - final List mangas = !preferences.updateOnlyNonCompleted() ? allLibraryMangas : - Observable.from(allLibraryMangas) - .filter(manga -> manga.status != Manga.COMPLETED) - .toList().toBlocking().single(); - - return Observable.from(mangas) - .doOnNext(manga -> showProgressNotification( - getString(R.string.notification_update_progress, - count.incrementAndGet(), mangas.size()), manga.title)) - .concatMap(manga -> updateManga(manga) - .onErrorReturn(error -> { - failedUpdates.add(manga); - return Pair.create(0, 0); - }) - // Filter out mangas without new chapters - .filter(pair -> pair.first > 0) - .map(pair -> new MangaUpdate(manga, pair.first))) - .doOnNext(updates::add) - .doOnCompleted(() -> { - if (updates.isEmpty()) { - cancelNotification(); - } else { - showResultNotification(getString(R.string.notification_update_completed), - getUpdatedMangasResult(updates, failedUpdates)); - } - }); - } - - private Observable> updateManga(Manga manga) { - return sourceManager.get(manga.source) - .pullChaptersFromNetwork(manga.url) - .flatMap(chapters -> db.insertOrRemoveChapters(manga, chapters)); - } - - private String getUpdatedMangasResult(List updates, List failedUpdates) { - final StringBuilder result = new StringBuilder(); - if (updates.isEmpty()) { - result.append(getString(R.string.notification_no_new_chapters)).append("\n"); - } else { - result.append(getString(R.string.notification_new_chapters)); - - for (MangaUpdate update : updates) { - result.append("\n").append(update.manga.title); - } - } - if (!failedUpdates.isEmpty()) { - result.append("\n"); - result.append(getString(R.string.notification_manga_update_failed)); - for (Manga manga : failedUpdates) { - result.append("\n").append(manga.title); - } - } - - return result.toString(); - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - private void createAndAcquireWakeLock() { - wakeLock = ((PowerManager)getSystemService(POWER_SERVICE)).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock"); - wakeLock.acquire(); - } - - private void destroyWakeLock() { - if (wakeLock != null && wakeLock.isHeld()) { - wakeLock.release(); - wakeLock = null; - } - } - - private void showNotification(String title, String body) { - NotificationCompat.Builder builder = new NotificationCompat.Builder(this) - .setSmallIcon(R.drawable.ic_action_refresh) - .setContentTitle(title) - .setContentText(body); - - NotificationManager notificationManager = - (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - - notificationManager.notify(UPDATE_NOTIFICATION_ID, builder.build()); - } - - private void showProgressNotification(String title, String body) { - NotificationCompat.Builder builder = new NotificationCompat.Builder(this) - .setSmallIcon(R.drawable.ic_action_refresh) - .setContentTitle(title) - .setContentText(body) - .setOngoing(true); - - NotificationManager notificationManager = - (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - - notificationManager.notify(UPDATE_NOTIFICATION_ID, builder.build()); - } - - private void showResultNotification(String title, String body) { - NotificationCompat.Builder builder = new NotificationCompat.Builder(this) - .setSmallIcon(R.drawable.ic_action_refresh) - .setContentTitle(title) - .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) - .setContentIntent(getNotificationIntent()) - .setAutoCancel(true); - - NotificationManager notificationManager = - (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - - notificationManager.notify(UPDATE_NOTIFICATION_ID, builder.build()); - } - - private void cancelNotification() { - NotificationManager notificationManager = - (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - - notificationManager.cancel(UPDATE_NOTIFICATION_ID); - } - - private PendingIntent getNotificationIntent() { - Intent intent = new Intent(this, MainActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); - return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); - } - - public static class SyncOnConnectionAvailable extends BroadcastReceiver { - - @Override - public void onReceive(Context context, Intent intent) { - if (NetworkUtil.isNetworkConnected(context)) { - if (BuildConfig.DEBUG) { - Timber.i("Connection is now available, triggering sync..."); - } - AndroidComponentUtil.toggleComponent(context, this.getClass(), false); - context.startService(getStartIntent(context)); - } - } - } - - private static class MangaUpdate { - public Manga manga; - public int newChapters; - - public MangaUpdate(Manga manga, int newChapters) { - this.manga = manga; - this.newChapters = newChapters; - } - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/sync/UpdateMangaSyncService.java b/app/src/main/java/eu/kanade/tachiyomi/data/sync/UpdateMangaSyncService.java deleted file mode 100644 index 3b291509ea..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/sync/UpdateMangaSyncService.java +++ /dev/null @@ -1,79 +0,0 @@ -package eu.kanade.tachiyomi.data.sync; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.IBinder; - -import javax.inject.Inject; - -import eu.kanade.tachiyomi.App; -import eu.kanade.tachiyomi.data.database.DatabaseHelper; -import eu.kanade.tachiyomi.data.database.models.MangaSync; -import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager; -import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService; -import rx.Observable; -import rx.android.schedulers.AndroidSchedulers; -import rx.schedulers.Schedulers; -import rx.subscriptions.CompositeSubscription; - -public class UpdateMangaSyncService extends Service { - - @Inject MangaSyncManager syncManager; - @Inject DatabaseHelper db; - - private CompositeSubscription subscriptions; - - private static final String EXTRA_MANGASYNC = "extra_mangasync"; - - public static void start(Context context, MangaSync mangaSync) { - Intent intent = new Intent(context, UpdateMangaSyncService.class); - intent.putExtra(EXTRA_MANGASYNC, mangaSync); - context.startService(intent); - } - - @Override - public void onCreate() { - super.onCreate(); - App.get(this).getComponent().inject(this); - subscriptions = new CompositeSubscription(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - MangaSync mangaSync = (MangaSync) intent.getSerializableExtra(EXTRA_MANGASYNC); - updateLastChapterRead(mangaSync, startId); - return START_STICKY; - } - - @Override - public void onDestroy() { - subscriptions.unsubscribe(); - super.onDestroy(); - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - private void updateLastChapterRead(MangaSync mangaSync, int startId) { - MangaSyncService sync = syncManager.getSyncService(mangaSync.sync_id); - - subscriptions.add(Observable.defer(() -> sync.update(mangaSync)) - .flatMap(response -> { - if (response.isSuccessful()) { - return db.insertMangaSync(mangaSync).asRxObservable(); - } - return Observable.error(new Exception("Could not update MAL")); - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - stopSelf(startId); - }, error -> { - stopSelf(startId); - })); - } - -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt new file mode 100644 index 0000000000..400b46c89a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.data.updater + +import com.google.gson.annotations.SerializedName + +/** + * Release object. + * Contains information about the latest release from Github. + * + * @param version version of latest release. + * @param changeLog log of latest release. + * @param assets assets of latest release. + */ +class GithubRelease(@SerializedName("tag_name") val version: String, + @SerializedName("body") val changeLog: String, + @SerializedName("assets") val assets: List) { + + /** + * Get download link of latest release from the assets. + * @return download link of latest release. + */ + val downloadLink: String + get() = assets[0].downloadLink + + /** + * Assets class containing download url. + * @param downloadLink download url. + */ + inner class Assets(@SerializedName("browser_download_url") val downloadLink: String) +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubService.kt new file mode 100644 index 0000000000..7bce4082b8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubService.kt @@ -0,0 +1,30 @@ +package eu.kanade.tachiyomi.data.updater + +import retrofit2.Retrofit +import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import rx.Observable + + +/** + * Used to connect with the Github API. + */ +interface GithubService { + + companion object { + fun create(): GithubService { + val restAdapter = Retrofit.Builder() + .baseUrl("https://api.github.com") + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) + .build() + + return restAdapter.create(GithubService::class.java) + } + } + + @GET("/repos/inorichi/tachiyomi/releases/latest") + fun getLatestVersion(): Observable + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubUpdateChecker.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubUpdateChecker.kt new file mode 100644 index 0000000000..306fab71b9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubUpdateChecker.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.data.updater + +import android.content.Context +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.toast +import rx.Observable + + +class GithubUpdateChecker(private val context: Context) { + + val service: GithubService = GithubService.create() + + /** + * Returns observable containing release information + */ + fun checkForApplicationUpdate(): Observable { + context.toast(R.string.update_check_look_for_updates) + return service.getLatestVersion() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.java b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.java deleted file mode 100644 index 1f12d8f108..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.java +++ /dev/null @@ -1,31 +0,0 @@ -package eu.kanade.tachiyomi.data.updater; - - -import android.content.Context; - -import eu.kanade.tachiyomi.R; -import eu.kanade.tachiyomi.data.rest.GithubService; -import eu.kanade.tachiyomi.data.rest.Release; -import eu.kanade.tachiyomi.data.rest.ServiceFactory; -import eu.kanade.tachiyomi.util.ToastUtil; -import rx.Observable; - - -public class UpdateChecker { - private final Context context; - - public UpdateChecker(Context context) { - this.context = context; - } - - /** - * Returns observable containing release information - * - */ - public Observable checkForApplicationUpdate() { - ToastUtil.showShort(context, context.getString(R.string.update_check_look_for_updates)); - //Create Github service to retrieve Github data - GithubService service = ServiceFactory.createRetrofitService(GithubService.class, GithubService.SERVICE_ENDPOINT); - return service.getLatestVersion(); - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.java b/app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.java index 99b76491ca..b45fb246bf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.java +++ b/app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.java @@ -6,10 +6,10 @@ import javax.inject.Singleton; import dagger.Component; import eu.kanade.tachiyomi.data.download.DownloadService; -import eu.kanade.tachiyomi.data.mangasync.services.MyAnimeList; +import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService; import eu.kanade.tachiyomi.data.source.base.Source; -import eu.kanade.tachiyomi.data.sync.LibraryUpdateService; -import eu.kanade.tachiyomi.data.sync.UpdateMangaSyncService; +import eu.kanade.tachiyomi.data.library.LibraryUpdateService; +import eu.kanade.tachiyomi.data.mangasync.UpdateMangaSyncService; import eu.kanade.tachiyomi.data.updater.UpdateDownloader; import eu.kanade.tachiyomi.injection.module.AppModule; import eu.kanade.tachiyomi.injection.module.DataModule; @@ -22,7 +22,6 @@ import eu.kanade.tachiyomi.ui.manga.MangaPresenter; import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersPresenter; import eu.kanade.tachiyomi.ui.manga.info.MangaInfoPresenter; import eu.kanade.tachiyomi.ui.manga.myanimelist.MyAnimeListPresenter; -import eu.kanade.tachiyomi.ui.reader.ReaderActivity; import eu.kanade.tachiyomi.ui.reader.ReaderPresenter; import eu.kanade.tachiyomi.ui.recent.RecentChaptersPresenter; import eu.kanade.tachiyomi.ui.setting.SettingsAccountsFragment; @@ -48,15 +47,13 @@ public interface AppComponent { void inject(CategoryPresenter categoryPresenter); void inject(RecentChaptersPresenter recentChaptersPresenter); - void inject(ReaderActivity readerActivity); void inject(MangaActivity mangaActivity); void inject(SettingsAccountsFragment settingsAccountsFragment); void inject(SettingsActivity settingsActivity); void inject(Source source); - - void inject(MyAnimeList myAnimeList); + void inject(MangaSyncService mangaSyncService); void inject(LibraryUpdateService libraryUpdateService); void inject(DownloadService downloadService); diff --git a/app/src/main/java/eu/kanade/tachiyomi/injection/module/DataModule.java b/app/src/main/java/eu/kanade/tachiyomi/injection/module/DataModule.java index 3aa9cf97a3..d516fe5849 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/injection/module/DataModule.java +++ b/app/src/main/java/eu/kanade/tachiyomi/injection/module/DataModule.java @@ -29,7 +29,7 @@ public class DataModule { @Provides @Singleton - DatabaseHelper provideDatabaseHelper(Application app) { + public DatabaseHelper provideDatabaseHelper(Application app) { return new DatabaseHelper(app); } @@ -47,13 +47,13 @@ public class DataModule { @Provides @Singleton - NetworkHelper provideNetworkHelper(Application app) { + public NetworkHelper provideNetworkHelper(Application app) { return new NetworkHelper(app); } @Provides @Singleton - SourceManager provideSourceManager(Application app) { + public SourceManager provideSourceManager(Application app) { return new SourceManager(app); } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.java b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.java index 0374c7a59c..2176004488 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.java +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.java @@ -35,7 +35,7 @@ import eu.kanade.tachiyomi.R; import eu.kanade.tachiyomi.data.database.models.Category; import eu.kanade.tachiyomi.data.database.models.Manga; import eu.kanade.tachiyomi.data.io.IOHandler; -import eu.kanade.tachiyomi.data.sync.LibraryUpdateService; +import eu.kanade.tachiyomi.data.library.LibraryUpdateService; import eu.kanade.tachiyomi.event.LibraryMangasEvent; import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment; import eu.kanade.tachiyomi.ui.library.category.CategoryActivity; diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.java b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.java index aede73ffd5..dbfb5bf2d4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.java +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.java @@ -20,12 +20,12 @@ import eu.kanade.tachiyomi.data.database.models.MangaSync; import eu.kanade.tachiyomi.data.download.DownloadManager; import eu.kanade.tachiyomi.data.download.model.Download; import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager; +import eu.kanade.tachiyomi.data.mangasync.UpdateMangaSyncService; import eu.kanade.tachiyomi.data.mangasync.base.MangaSyncService; import eu.kanade.tachiyomi.data.preference.PreferencesHelper; import eu.kanade.tachiyomi.data.source.SourceManager; import eu.kanade.tachiyomi.data.source.base.Source; import eu.kanade.tachiyomi.data.source.model.Page; -import eu.kanade.tachiyomi.data.sync.UpdateMangaSyncService; import eu.kanade.tachiyomi.event.ReaderEvent; import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter; import icepick.State; @@ -348,7 +348,7 @@ public class ReaderPresenter extends BasePresenter { public void updateMangaSyncLastChapterRead() { for (MangaSync mangaSync : mangaSyncList) { - MangaSyncService service = syncManager.getSyncService(mangaSync.sync_id); + MangaSyncService service = syncManager.getService(mangaSync.sync_id); if (service.isLogged() && mangaSync.update) { UpdateMangaSyncService.start(getContext(), mangaSync); } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAboutFragment.java b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAboutFragment.java index 4fe669d999..0562a58716 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAboutFragment.java +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAboutFragment.java @@ -17,7 +17,7 @@ import java.util.TimeZone; import eu.kanade.tachiyomi.BuildConfig; import eu.kanade.tachiyomi.R; -import eu.kanade.tachiyomi.data.updater.UpdateChecker; +import eu.kanade.tachiyomi.data.updater.GithubUpdateChecker; import eu.kanade.tachiyomi.data.updater.UpdateDownloader; import eu.kanade.tachiyomi.util.ToastUtil; import rx.Subscription; @@ -28,7 +28,7 @@ public class SettingsAboutFragment extends SettingsNestedFragment { /** * Checks for new releases */ - private UpdateChecker updateChecker; + private GithubUpdateChecker updateChecker; /** * The subscribtion service of the obtained release object @@ -44,7 +44,7 @@ public class SettingsAboutFragment extends SettingsNestedFragment { @Override public void onCreate(Bundle savedInstanceState) { //Check for update - updateChecker = new UpdateChecker(getActivity()); + updateChecker = new GithubUpdateChecker(getActivity()); super.onCreate(savedInstanceState); } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAccountsFragment.java b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAccountsFragment.java index b705fbc50b..ae8440c2c6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAccountsFragment.java +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAccountsFragment.java @@ -60,7 +60,7 @@ public class SettingsAccountsFragment extends SettingsNestedFragment { mangaSyncCategory.setTitle("Sync"); screen.addPreference(mangaSyncCategory); - for (MangaSyncService sync : syncManager.getSyncServices()) { + for (MangaSyncService sync : syncManager.getServices()) { MangaSyncLoginDialog dialog = new MangaSyncLoginDialog( screen.getContext(), preferences, sync); dialog.setTitle(sync.getName()); diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralFragment.java b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralFragment.java index f5089e6992..e3a51a60be 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralFragment.java +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralFragment.java @@ -7,7 +7,7 @@ import android.view.ViewGroup; import eu.kanade.tachiyomi.R; import eu.kanade.tachiyomi.data.preference.PreferencesHelper; -import eu.kanade.tachiyomi.data.sync.LibraryUpdateAlarm; +import eu.kanade.tachiyomi.data.library.LibraryUpdateAlarm; import eu.kanade.tachiyomi.widget.preference.IntListPreference; import eu.kanade.tachiyomi.widget.preference.LibraryColumnsDialog; diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt new file mode 100644 index 0000000000..c320839189 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt @@ -0,0 +1,35 @@ +package eu.kanade.tachiyomi.util + +import android.app.AlarmManager +import android.app.Notification +import android.content.Context +import android.support.annotation.StringRes +import android.support.v4.app.NotificationCompat +import android.widget.Toast + +/** + * Display a toast in this context. + * @param resource the text resource. + * @param duration the duration of the toast. Defaults to short. + */ +fun Context.toast(@StringRes resource: Int, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, resource, duration).show() +} + +/** + * Helper method to create a notification. + * @param func the function that will execute inside the builder. + * @return a notification to be displayed or updated. + */ +inline fun Context.notification(func: NotificationCompat.Builder.() -> Unit): Notification { + val builder = NotificationCompat.Builder(this) + builder.func() + return builder.build() +} + +/** + * Property to get the alarm manager from the context. + * @return the alarm manager. + */ +val Context.alarmManager: AlarmManager + get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/JsoupExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/JsoupExtensions.kt new file mode 100644 index 0000000000..be971001e0 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/JsoupExtensions.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.util + +import org.jsoup.nodes.Element + +fun Element.selectText(css: String, defaultValue: String? = null): String? { + return select(css).first()?.text() ?: defaultValue +} + +fun Element.selectInt(css: String, defaultValue: Int = 0): Int { + return select(css).first()?.text()?.toInt() ?: defaultValue +} + diff --git a/app/src/test/java/eu/kanade/tachiyomi/CustomBuildConfig.java b/app/src/test/java/eu/kanade/tachiyomi/CustomBuildConfig.java new file mode 100644 index 0000000000..de3ec0ad77 --- /dev/null +++ b/app/src/test/java/eu/kanade/tachiyomi/CustomBuildConfig.java @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi; + +public class CustomBuildConfig { + public static final boolean DEBUG = Boolean.parseBoolean("true"); + public static final String APPLICATION_ID = "eu.kanade.tachiyomi"; + public static final String BUILD_TYPE = "debug"; + public static final String FLAVOR = ""; + public static final int VERSION_CODE = 4; + public static final String VERSION_NAME = "0.1.3"; + // Fields from default config. + public static final String BUILD_TIME = "2016-02-19T14:49Z"; + public static final String COMMIT_COUNT = "482"; + public static final String COMMIT_SHA = "e52c498"; + public static final boolean INCLUDE_UPDATER = true; +} diff --git a/app/src/test/java/eu/kanade/tachiyomi/TestApp.java b/app/src/test/java/eu/kanade/tachiyomi/TestApp.java index ba670b6247..451db6e415 100644 --- a/app/src/test/java/eu/kanade/tachiyomi/TestApp.java +++ b/app/src/test/java/eu/kanade/tachiyomi/TestApp.java @@ -1,9 +1,24 @@ package eu.kanade.tachiyomi; +import eu.kanade.tachiyomi.injection.component.DaggerAppComponent; +import eu.kanade.tachiyomi.injection.module.AppModule; + public class TestApp extends App { + @Override + protected DaggerAppComponent.Builder prepareAppComponent() { + return DaggerAppComponent.builder() + .appModule(new AppModule(this)) + .dataModule(new TestDataModule()); + } + @Override protected void setupEventBus() { // Do nothing } + + @Override + protected void setupAcra() { + // Do nothing + } } diff --git a/app/src/test/java/eu/kanade/tachiyomi/TestDataModule.java b/app/src/test/java/eu/kanade/tachiyomi/TestDataModule.java new file mode 100644 index 0000000000..803386e691 --- /dev/null +++ b/app/src/test/java/eu/kanade/tachiyomi/TestDataModule.java @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi; + +import android.app.Application; + +import org.mockito.Mockito; + +import eu.kanade.tachiyomi.data.database.DatabaseHelper; +import eu.kanade.tachiyomi.data.network.NetworkHelper; +import eu.kanade.tachiyomi.data.source.SourceManager; +import eu.kanade.tachiyomi.injection.module.DataModule; + +public class TestDataModule extends DataModule { + + @Override + public DatabaseHelper provideDatabaseHelper(Application app) { + return Mockito.mock(DatabaseHelper.class, Mockito.RETURNS_DEEP_STUBS); + } + + @Override + public NetworkHelper provideNetworkHelper(Application app) { + return Mockito.mock(NetworkHelper.class); + } + + @Override + public SourceManager provideSourceManager(Application app) { + return Mockito.mock(SourceManager.class, Mockito.RETURNS_DEEP_STUBS); + } + +} diff --git a/app/src/test/java/eu/kanade/tachiyomi/UseModule.java b/app/src/test/java/eu/kanade/tachiyomi/UseModule.java deleted file mode 100644 index 3cab866a9e..0000000000 --- a/app/src/test/java/eu/kanade/tachiyomi/UseModule.java +++ /dev/null @@ -1,16 +0,0 @@ -package eu.kanade.tachiyomi; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Created by len on 1/10/15. - */ - -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -public @interface UseModule { - Class value(); -} \ No newline at end of file diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/library/LibraryUpdateAlarmTest.java b/app/src/test/java/eu/kanade/tachiyomi/data/library/LibraryUpdateAlarmTest.java new file mode 100644 index 0000000000..02ffadb631 --- /dev/null +++ b/app/src/test/java/eu/kanade/tachiyomi/data/library/LibraryUpdateAlarmTest.java @@ -0,0 +1,140 @@ +package eu.kanade.tachiyomi.data.library; + +import android.app.AlarmManager; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.SystemClock; + +import org.assertj.core.data.Offset; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricGradleTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlarmManager; +import org.robolectric.shadows.ShadowApplication; +import org.robolectric.shadows.ShadowPendingIntent; + +import eu.kanade.tachiyomi.CustomBuildConfig; +import eu.kanade.tachiyomi.data.preference.PreferencesHelper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.spy; +import static org.robolectric.Shadows.shadowOf; + +@Config(constants = CustomBuildConfig.class, sdk = Build.VERSION_CODES.LOLLIPOP) +@RunWith(RobolectricGradleTestRunner.class) +public class LibraryUpdateAlarmTest { + + ShadowApplication app; + Context context; + ShadowAlarmManager alarmManager; + + @Before + public void setup() { + app = ShadowApplication.getInstance(); + context = spy(app.getApplicationContext()); + + alarmManager = shadowOf((AlarmManager) context.getSystemService(Context.ALARM_SERVICE)); + } + + @Test + public void testLibraryIntentHandling() { + Intent intent = new Intent(LibraryUpdateAlarm.LIBRARY_UPDATE_ACTION); + assertThat(app.hasReceiverForIntent(intent)).isTrue(); + } + + @Test + public void testAlarmIsNotStarted() { + assertThat(alarmManager.getNextScheduledAlarm()).isNull(); + } + + @Test + public void testAlarmIsNotStartedWhenBootReceivedAndSettingZero() { + LibraryUpdateAlarm alarm = new LibraryUpdateAlarm(); + alarm.onReceive(context, new Intent(Intent.ACTION_BOOT_COMPLETED)); + + assertThat(alarmManager.getNextScheduledAlarm()).isNull(); + } + + @Test + public void testAlarmIsStartedWhenBootReceivedAndSettingNotZero() { + PreferencesHelper prefs = new PreferencesHelper(context); + prefs.libraryUpdateInterval().set(1); + + LibraryUpdateAlarm alarm = new LibraryUpdateAlarm(); + alarm.onReceive(context, new Intent(Intent.ACTION_BOOT_COMPLETED)); + + assertThat(alarmManager.getNextScheduledAlarm()).isNotNull(); + } + + @Test + public void testOnlyOneAlarmExists() { + PreferencesHelper prefs = new PreferencesHelper(context); + prefs.libraryUpdateInterval().set(1); + + LibraryUpdateAlarm.startAlarm(context); + LibraryUpdateAlarm.startAlarm(context); + LibraryUpdateAlarm.startAlarm(context); + + assertThat(alarmManager.getScheduledAlarms()).hasSize(1); + } + + @Test + public void testLibraryWillBeUpdatedWhenAlarmFired() { + PreferencesHelper prefs = new PreferencesHelper(context); + prefs.libraryUpdateInterval().set(1); + + Intent expectedIntent = new Intent(context, LibraryUpdateAlarm.class); + expectedIntent.setAction(LibraryUpdateAlarm.LIBRARY_UPDATE_ACTION); + + LibraryUpdateAlarm.startAlarm(context); + + ShadowAlarmManager.ScheduledAlarm scheduledAlarm = alarmManager.getNextScheduledAlarm(); + ShadowPendingIntent pendingIntent = shadowOf(scheduledAlarm.operation); + assertThat(pendingIntent.isBroadcastIntent()).isTrue(); + assertThat(pendingIntent.getSavedIntents()).hasSize(1); + assertThat(expectedIntent.getComponent()).isEqualTo(pendingIntent.getSavedIntents()[0].getComponent()); + assertThat(expectedIntent.getAction()).isEqualTo(pendingIntent.getSavedIntents()[0].getAction()); + } + + @Test + public void testLibraryUpdateServiceIsStartedWhenUpdateIntentIsReceived() { + Intent intent = new Intent(context, LibraryUpdateService.class); + assertThat(app.getNextStartedService()).isNotEqualTo(intent); + + LibraryUpdateAlarm alarm = new LibraryUpdateAlarm(); + alarm.onReceive(context, new Intent(LibraryUpdateAlarm.LIBRARY_UPDATE_ACTION)); + + assertThat(app.getNextStartedService()).isEqualTo(intent); + } + + @Test + public void testReceiverDoesntReactToNullActions() { + PreferencesHelper prefs = new PreferencesHelper(context); + prefs.libraryUpdateInterval().set(1); + + Intent intent = new Intent(context, LibraryUpdateService.class); + + LibraryUpdateAlarm alarm = new LibraryUpdateAlarm(); + alarm.onReceive(context, new Intent()); + + assertThat(app.getNextStartedService()).isNotEqualTo(intent); + assertThat(alarmManager.getScheduledAlarms()).hasSize(0); + } + + @Test + public void testAlarmFiresCloseToDesiredTime() { + int hours = 2; + LibraryUpdateAlarm.startAlarm(context, hours); + + long shouldRunAt = SystemClock.elapsedRealtime() + (hours * 60 * 60 * 1000); + + // Margin error of 3 seconds + Offset offset = Offset.offset(3 * 1000L); + + assertThat(alarmManager.getNextScheduledAlarm().triggerAtTime).isCloseTo(shouldRunAt, offset); + } + +} diff --git a/app/src/test/java/eu/kanade/tachiyomi/data/library/LibraryUpdateServiceTest.java b/app/src/test/java/eu/kanade/tachiyomi/data/library/LibraryUpdateServiceTest.java new file mode 100644 index 0000000000..7206522375 --- /dev/null +++ b/app/src/test/java/eu/kanade/tachiyomi/data/library/LibraryUpdateServiceTest.java @@ -0,0 +1,130 @@ +package eu.kanade.tachiyomi.data.library; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.util.Pair; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricGradleTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowApplication; + +import java.util.ArrayList; +import java.util.List; + +import eu.kanade.tachiyomi.CustomBuildConfig; +import eu.kanade.tachiyomi.data.database.models.Chapter; +import eu.kanade.tachiyomi.data.database.models.Manga; +import eu.kanade.tachiyomi.data.source.base.Source; +import rx.Observable; + +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Config(constants = CustomBuildConfig.class, sdk = Build.VERSION_CODES.LOLLIPOP) +@RunWith(RobolectricGradleTestRunner.class) +public class LibraryUpdateServiceTest { + + ShadowApplication app; + Context context; + LibraryUpdateService service; + Source source; + + @Before + public void setup() { + app = ShadowApplication.getInstance(); + context = app.getApplicationContext(); + service = Robolectric.setupService(LibraryUpdateService.class); + source = mock(Source.class); + when(service.sourceManager.get(anyInt())).thenReturn(source); + } + + @Test + public void testStartCommand() { + service.onStartCommand(new Intent(), 0, 0); + verify(service.db).getFavoriteMangas(); + } + + @Test + public void testLifecycle() { + // Smoke test + Robolectric.buildService(LibraryUpdateService.class) + .attach() + .create() + .startCommand(0, 0) + .destroy() + .get(); + } + + @Test + public void testUpdateManga() { + Manga manga = Manga.create("manga1"); + List chapters = createChapters("/chapter1", "/chapter2"); + + when(source.pullChaptersFromNetwork(manga.url)).thenReturn(Observable.just(chapters)); + when(service.db.insertOrRemoveChapters(manga, chapters)) + .thenReturn(Observable.just(Pair.create(2, 0))); + + service.updateManga(manga).subscribe(); + + verify(service.db).insertOrRemoveChapters(manga, chapters); + } + + @Test + public void testContinuesUpdatingWhenAMangaFails() { + Manga manga1 = Manga.create("manga1"); + Manga manga2 = Manga.create("manga2"); + Manga manga3 = Manga.create("manga3"); + + List favManga = createManga("manga1", "manga2", "manga3"); + + List chapters = createChapters("/chapter1", "/chapter2"); + List chapters3 = createChapters("/achapter1", "/achapter2"); + + when(service.db.getFavoriteMangas().executeAsBlocking()).thenReturn(favManga); + + // One of the updates will fail + when(source.pullChaptersFromNetwork("manga1")).thenReturn(Observable.just(chapters)); + when(source.pullChaptersFromNetwork("manga2")).thenReturn(Observable.error(new Exception())); + when(source.pullChaptersFromNetwork("manga3")).thenReturn(Observable.just(chapters3)); + + when(service.db.insertOrRemoveChapters(manga1, chapters)).thenReturn(Observable.just(Pair.create(2, 0))); + when(service.db.insertOrRemoveChapters(manga3, chapters)).thenReturn(Observable.just(Pair.create(2, 0))); + + service.updateLibrary().subscribe(); + + // There are 3 network attempts and 2 insertions (1 request failed) + verify(source, times(3)).pullChaptersFromNetwork(any()); + verify(service.db, times(2)).insertOrRemoveChapters(any(), any()); + verify(service.db, never()).insertOrRemoveChapters(eq(manga2), any()); + } + + private List createChapters(String... urls) { + List list = new ArrayList<>(); + for (String url : urls) { + Chapter c = Chapter.create(); + c.url = url; + list.add(c); + } + return list; + } + + private List createManga(String... urls) { + List list = new ArrayList<>(); + for (String url : urls) { + Manga m = Manga.create(url); + list.add(m); + } + return list; + } +} diff --git a/build.gradle b/build.gradle index 11eb25d59a..ff86c05a26 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.0.0-beta2' + classpath 'com.android.tools.build:gradle:2.0.0-beta5' classpath 'com.neenbedankt.gradle.plugins:android-apt:1.8' classpath 'me.tatarka:gradle-retrolambda:3.2.4' classpath 'com.github.ben-manes:gradle-versions-plugin:0.12.0'