Browse Source

Partial migration of data package to Kotlin

inorichi 9 years ago
parent
commit
c0452038f7
60 changed files with 2012 additions and 1856 deletions
  1. 33 7
      app/build.gradle
  2. 4 4
      app/src/main/AndroidManifest.xml
  3. 10 9
      app/src/main/java/eu/kanade/tachiyomi/App.java
  4. 0 268
      app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.java
  5. 213 0
      app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt
  6. 0 235
      app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.java
  7. 158 0
      app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.kt
  8. 0 30
      app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.java
  9. 22 0
      app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.kt
  10. 82 0
      app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateAlarm.kt
  11. 348 0
      app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt
  12. 0 40
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.java
  13. 23 0
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt
  14. 78 0
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/UpdateMangaSyncService.kt
  15. 0 27
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.java
  16. 38 0
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.kt
  17. 0 263
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.java
  18. 216 0
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.kt
  19. 0 141
      app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.java
  20. 78 0
      app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.kt
  21. 0 5
      app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.java
  22. 5 0
      app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.kt
  23. 0 52
      app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.java
  24. 40 0
      app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.kt
  25. 34 0
      app/src/main/java/eu/kanade/tachiyomi/data/network/Req.kt
  26. 4 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.java
  27. 0 15
      app/src/main/java/eu/kanade/tachiyomi/data/rest/GithubService.java
  28. 0 93
      app/src/main/java/eu/kanade/tachiyomi/data/rest/Release.java
  29. 0 21
      app/src/main/java/eu/kanade/tachiyomi/data/rest/ServiceFactory.java
  30. 0 68
      app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.java
  31. 52 0
      app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.kt
  32. 11 21
      app/src/main/java/eu/kanade/tachiyomi/data/source/base/BaseSource.java
  33. 45 13
      app/src/main/java/eu/kanade/tachiyomi/data/source/base/Source.java
  34. 19 22
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Batoto.java
  35. 27 45
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Kissmanga.java
  36. 0 6
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangafox.java
  37. 0 6
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.java
  38. 0 62
      app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateAlarm.java
  39. 0 258
      app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateService.java
  40. 0 79
      app/src/main/java/eu/kanade/tachiyomi/data/sync/UpdateMangaSyncService.java
  41. 30 0
      app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubRelease.kt
  42. 30 0
      app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubService.kt
  43. 20 0
      app/src/main/java/eu/kanade/tachiyomi/data/updater/GithubUpdateChecker.kt
  44. 0 31
      app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.java
  45. 4 7
      app/src/main/java/eu/kanade/tachiyomi/injection/component/AppComponent.java
  46. 3 3
      app/src/main/java/eu/kanade/tachiyomi/injection/module/DataModule.java
  47. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryFragment.java
  48. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.java
  49. 3 3
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAboutFragment.java
  50. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAccountsFragment.java
  51. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralFragment.java
  52. 35 0
      app/src/main/java/eu/kanade/tachiyomi/util/ContextExtensions.kt
  53. 12 0
      app/src/main/java/eu/kanade/tachiyomi/util/JsoupExtensions.kt
  54. 15 0
      app/src/test/java/eu/kanade/tachiyomi/CustomBuildConfig.java
  55. 15 0
      app/src/test/java/eu/kanade/tachiyomi/TestApp.java
  56. 29 0
      app/src/test/java/eu/kanade/tachiyomi/TestDataModule.java
  57. 0 16
      app/src/test/java/eu/kanade/tachiyomi/UseModule.java
  58. 140 0
      app/src/test/java/eu/kanade/tachiyomi/data/library/LibraryUpdateAlarmTest.java
  59. 130 0
      app/src/test/java/eu/kanade/tachiyomi/data/library/LibraryUpdateServiceTest.java
  60. 1 1
      build.gradle

+ 33 - 7
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()
 }

+ 4 - 4
app/src/main/AndroidManifest.xml

@@ -51,17 +51,17 @@
             android:theme="@style/FilePickerTheme">
         </activity>
 
-        <service android:name=".data.sync.LibraryUpdateService"
+        <service android:name=".data.library.LibraryUpdateService"
             android:exported="false"/>
 
         <service android:name=".data.download.DownloadService"
             android:exported="false"/>
 
-        <service android:name=".data.sync.UpdateMangaSyncService"
+        <service android:name=".data.mangasync.UpdateMangaSyncService"
             android:exported="false"/>
 
         <receiver
-            android:name=".data.sync.LibraryUpdateService$SyncOnConnectionAvailable"
+            android:name=".data.library.LibraryUpdateService$SyncOnConnectionAvailable"
             android:enabled="false">
             <intent-filter>
                 <action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
@@ -69,7 +69,7 @@
         </receiver>
 
         <receiver
-            android:name=".data.sync.LibraryUpdateAlarm">
+            android:name=".data.library.LibraryUpdateAlarm">
             <intent-filter>
                 <action android:name="android.intent.action.BOOT_COMPLETED"/>
                 <action android:name="eu.kanade.UPDATE_LIBRARY" />

+ 10 - 9
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<AppComponent> getComponentReflection() {

+ 0 - 268
app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.java

@@ -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<List<Page>>() {}.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<List<Page>> 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<Page> 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();
-            }
-        }
-    }
-
-}
-

+ 213 - 0
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<List<Page>>() {}.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<List<Page>> {
+        return Observable.fromCallable<List<Page>> {
+            // 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<Page>) {
+        // 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()
+        }
+    }
+
+}
+

+ 0 - 235
app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverCache.java

@@ -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<File>() {
-                    @Override
-                    public void onResourceReady(File resource, GlideAnimation<? super File> 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);
-    }
-
-}

+ 158 - 0
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<File>() {
+                    override fun onResourceReady(resource: File, anim: GlideAnimation<in File>) {
+                        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)
+    }
+
+}

+ 0 - 30
app/src/main/java/eu/kanade/tachiyomi/data/cache/CoverGlideModule.java

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

+ 22 - 0
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!
+    }
+}

+ 82 - 0
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)
+        }
+    }
+
+}

+ 348 - 0
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<Manga> {
+        // Initialize the variables holding the progress of the updates.
+        val count = AtomicInteger(0)
+        val newUpdates = ArrayList<Manga>()
+        val failedUpdates = ArrayList<Manga>()
+
+        // 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<Pair<Int, Int>> {
+        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<Manga>, failedUpdates: List<Manga>): 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<Manga>, failed: List<Manga>) {
+        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))
+            }
+        }
+    }
+
+}

+ 0 - 40
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.java

@@ -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<MangaSyncService> 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<MangaSyncService> getSyncServices() {
-        return services;
-    }
-
-    public MangaSyncService getSyncService(int id) {
-        switch (id) {
-            case MYANIMELIST:
-                return myAnimeList;
-        }
-        return null;
-    }
-
-}

+ 23 - 0
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<MangaSyncService>
+    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 }!!
+
+}

+ 78 - 0
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)
+        }
+    }
+
+}

+ 0 - 27
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/base/MangaSyncService.java

@@ -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<Boolean> login(String username, String password);
-
-    public abstract boolean isLogged();
-
-    public abstract Observable<Response> update(MangaSync manga);
-
-    public abstract Observable<Response> add(MangaSync manga);
-
-    public abstract Observable<Response> bind(MangaSync manga);
-
-    public abstract String getStatus(int status);
-
-}

+ 38 - 0
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<Boolean>
+
+    open val isLogged: Boolean
+        get() = !preferences.getMangaSyncUsername(this).isEmpty() &&
+                !preferences.getMangaSyncPassword(this).isEmpty()
+
+    abstract fun update(manga: MangaSync): Observable<Response>
+
+    abstract fun add(manga: MangaSync): Observable<Response>
+
+    abstract fun bind(manga: MangaSync): Observable<Response>
+
+    abstract fun getStatus(status: Int): String
+
+}

+ 0 - 263
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/services/MyAnimeList.java

@@ -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<Boolean> 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<List<MangaSync>> 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<List<MangaSync>> 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<Response> 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<Response> 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<Response> 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;
-    }
-
-}

+ 216 - 0
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<Boolean> {
+        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<List<MangaSync>> {
+        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<List<MangaSync>> {
+        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<Response> {
+        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<Response> {
+        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<Response> {
+        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()
+    }
+
+}

+ 0 - 141
app/src/main/java/eu/kanade/tachiyomi/data/network/NetworkHelper.java

@@ -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<Response> 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<String> mapResponseToString(final Response response) {
-        return Observable.defer(() -> {
-            try {
-                return Observable.just(response.body().string());
-            } catch (Throwable e) {
-                return Observable.error(e);
-            }
-        });
-    }
-
-    public Observable<String> getStringResponse(final String url, final Headers headers, boolean forceCache) {
-        return getResponse(url, headers, forceCache)
-                .flatMap(this::mapResponseToString);
-    }
-
-    public Observable<Response> 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<Response> 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();
-    }
-
-}

+ 78 - 0
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<Response> {
+        return Observable.fromCallable {
+            val c = if (forceCache) forceCacheClient else client
+            c.newCall(request).execute()
+        }
+    }
+
+    @JvmOverloads
+    fun requestBody(request: Request, forceCache: Boolean = false): Observable<String> {
+        return request(request, forceCache)
+                .map { it.body().string() }
+    }
+
+    fun requestBodyProgress(request: Request, listener: ProgressListener): Observable<Response> {
+        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
+
+}

+ 0 - 5
app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressListener.java

@@ -1,5 +0,0 @@
-package eu.kanade.tachiyomi.data.network;
-
-public interface ProgressListener {
-    void update(long bytesRead, long contentLength, boolean done);
-}

+ 5 - 0
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)
+}

+ 0 - 52
app/src/main/java/eu/kanade/tachiyomi/data/network/ProgressResponseBody.java

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

+ 40 - 0
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
+            }
+        }
+    }
+}

+ 34 - 0
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()
+}

+ 4 - 0
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<Integer> libraryUpdateInterval() {
+        return rxPrefs.getInteger(getKey(R.string.pref_library_update_interval_key), 0);
+    }
+
 }

+ 0 - 15
app/src/main/java/eu/kanade/tachiyomi/data/rest/GithubService.java

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

+ 0 - 93
app/src/main/java/eu/kanade/tachiyomi/data/rest/Release.java

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

+ 0 - 21
app/src/main/java/eu/kanade/tachiyomi/data/rest/ServiceFactory.java

@@ -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> T createRetrofitService(final Class<T> clazz, final String endPoint) {
-        final RestAdapter restAdapter = new RestAdapter.Builder()
-                .setEndpoint(endPoint)
-                .build();
-
-        return restAdapter.create(clazz);
-    }
-}

+ 0 - 68
app/src/main/java/eu/kanade/tachiyomi/data/source/SourceManager.java

@@ -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<Integer, Source> 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<Source> getSources() {
-        List<Source> sources = new ArrayList<>(sourcesMap.values());
-        Collections.sort(sources, (s1, s2) -> s1.getName().compareTo(s2.getName()));
-        return sources;
-    }
-
-}

+ 52 - 0
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<Int, Source>
+    val sources: List<Source>
+
+    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<Int, Source> {
+        val map = HashMap<Int, Source>()
+        for (i in 1..LAST_SOURCE) {
+            val source = createSource(i)
+            if (source != null) {
+                source.id = i
+                map.put(i, source)
+            }
+        }
+        return map
+    }
+
+}

+ 11 - 21
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() {

+ 45 - 13
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;
     }
 
-    // Get the most popular mangas from the source
-    public Observable<MangasPage> pullPopularMangasFromNetwork(MangasPage page) {
-        if (page.page == 1)
+    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<MangasPage> pullPopularMangasFromNetwork(MangasPage page) {
         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<MangasPage> 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<Manga> 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<List<Chapter>> pullChaptersFromNetwork(final String mangaUrl) {
         return networkService
-                .getStringResponse(getBaseUrl() + mangaUrl, requestHeaders, false)
+                .requestBody(chapterListRequest(mangaUrl))
                 .flatMap(unparsedHtml -> {
                     List<Chapter> chapters = parseHtmlToChapters(unparsedHtml);
                     return !chapters.isEmpty() ?
@@ -102,7 +134,7 @@ public abstract class Source extends BaseSource {
 
     public Observable<List<Page>> pullPageListFromNetwork(final String chapterUrl) {
         return networkService
-                .getStringResponse(getBaseUrl() + overrideChapterUrl(chapterUrl), requestHeaders, false)
+                .requestBody(pageListRequest(chapterUrl))
                 .flatMap(unparsedHtml -> {
                     List<Page> pages = convertToPages(parseHtmlToPageUrls(unparsedHtml));
                     return !pages.isEmpty() ?
@@ -127,7 +159,7 @@ public abstract class Source extends BaseSource {
     public Observable<Page> 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<Response> getImageProgressResponse(final Page page) {
-        return networkService.getProgressResponse(page.getImageUrl(), requestHeaders, page);
+        return networkService.requestBodyProgress(imageRequest(page), page);
     }
 
     public void savePageList(String chapterUrl, List<Page> pages) {

+ 19 - 22
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&section=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<Manga> parseMangasFromHtml(Document parsedHtml) {
@@ -318,7 +315,7 @@ public class Batoto extends LoginSource {
 
     @Override
     public Observable<Boolean> 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

+ 27 - 45
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<Manga> parsePopularMangasFromHtml(Document parsedHtml) {
         List<Manga> mangaList = new ArrayList<>();
@@ -104,25 +123,6 @@ public class Kissmanga extends Source {
         return path != null ? BASE_URL + path : null;
     }
 
-    public Observable<MangasPage> 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<Manga> parseSearchFromHtml(Document parsedHtml) {
         return parsePopularMangasFromHtml(parsedHtml);
@@ -195,19 +195,6 @@ public class Kissmanga extends Source {
         return chapter;
     }
 
-    @Override
-    public Observable<List<Page>> pullPageListFromNetwork(final String chapterUrl) {
-        return networkService
-                .postData(getBaseUrl() + overrideChapterUrl(chapterUrl), null, requestHeaders)
-                .flatMap(networkService::mapResponseToString)
-                .flatMap(unparsedHtml -> {
-                    List<Page> pages = convertToPages(parseHtmlToPageUrls(unparsedHtml));
-                    return !pages.isEmpty() ?
-                            Observable.just(parseFirstPage(pages, unparsedHtml)) :
-                            Observable.error(new Exception("Page list is empty"));
-                });
-    }
-
     @Override
     protected List<String> parseHtmlToPageUrls(String unparsedHtml) {
         Document parsedDocument = Jsoup.parse(unparsedHtml);
@@ -238,9 +225,4 @@ public class Kissmanga extends Source {
         return null;
     }
 
-    @Override
-    public Observable<Response> getImageProgressResponse(final Page page) {
-        return networkService.getProgressResponse(page.getImageUrl(), null, page);
-    }
-
 }

+ 0 - 6
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;

+ 0 - 6
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;

+ 0 - 62
app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateAlarm.java

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

+ 0 - 258
app/src/main/java/eu/kanade/tachiyomi/data/sync/LibraryUpdateService.java

@@ -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<MangaUpdate> updateLibrary(List<Manga> allLibraryMangas) {
-        final AtomicInteger count = new AtomicInteger(0);
-        final List<MangaUpdate> updates = new ArrayList<>();
-        final List<Manga> failedUpdates = new ArrayList<>();
-
-        final List<Manga> 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<Pair<Integer, Integer>> updateManga(Manga manga) {
-        return sourceManager.get(manga.source)
-                .pullChaptersFromNetwork(manga.url)
-                .flatMap(chapters -> db.insertOrRemoveChapters(manga, chapters));
-    }
-
-    private String getUpdatedMangasResult(List<MangaUpdate> updates, List<Manga> 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;
-        }
-    }
-
-}

+ 0 - 79
app/src/main/java/eu/kanade/tachiyomi/data/sync/UpdateMangaSyncService.java

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

+ 30 - 0
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<Assets>) {
+
+    /**
+     * 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)
+}
+

+ 30 - 0
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<GithubRelease>
+
+}

+ 20 - 0
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<GithubRelease> {
+        context.toast(R.string.update_check_look_for_updates)
+        return service.getLatestVersion()
+    }
+}

+ 0 - 31
app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdateChecker.java

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

+ 4 - 7
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);

+ 3 - 3
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);
     }
 

+ 1 - 1
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;

+ 2 - 2
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<ReaderActivity> {
 
     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);
             }

+ 3 - 3
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);
     }

+ 1 - 1
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());

+ 1 - 1
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;
 

+ 35 - 0
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

+ 12 - 0
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
+}
+

+ 15 - 0
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;
+}

+ 15 - 0
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
+    }
 }

+ 29 - 0
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);
+    }
+
+}

+ 0 - 16
app/src/test/java/eu/kanade/tachiyomi/UseModule.java

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

+ 140 - 0
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<Long> offset = Offset.offset(3 * 1000L);
+
+        assertThat(alarmManager.getNextScheduledAlarm().triggerAtTime).isCloseTo(shouldRunAt, offset);
+    }
+
+}

+ 130 - 0
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<Chapter> 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<Manga> favManga = createManga("manga1", "manga2", "manga3");
+
+        List<Chapter> chapters = createChapters("/chapter1", "/chapter2");
+        List<Chapter> 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<Chapter> createChapters(String... urls) {
+        List<Chapter> list = new ArrayList<>();
+        for (String url : urls) {
+            Chapter c = Chapter.create();
+            c.url = url;
+            list.add(c);
+        }
+        return list;
+    }
+
+    private List<Manga> createManga(String... urls) {
+        List<Manga> list = new ArrayList<>();
+        for (String url : urls) {
+            Manga m = Manga.create(url);
+            list.add(m);
+        }
+        return list;
+    }
+}

+ 1 - 1
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'