Merge pull request #169 from inorichi/kotlin

Partial migration of data package to Kotlin
This commit is contained in:
inorichi 2016-02-21 22:42:54 +01:00
commit db97250db8
60 changed files with 2012 additions and 1856 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
package eu.kanade.tachiyomi.data.network
interface ProgressListener {
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
@ -69,24 +77,6 @@ public abstract class BaseSource {
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() {
Headers.Builder builder = new Headers.Builder();

View File

@ -18,10 +18,12 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache;
import eu.kanade.tachiyomi.data.database.models.Chapter;
import eu.kanade.tachiyomi.data.database.models.Manga;
import eu.kanade.tachiyomi.data.network.NetworkHelper;
import eu.kanade.tachiyomi.data.network.ReqKt;
import eu.kanade.tachiyomi.data.preference.PreferencesHelper;
import eu.kanade.tachiyomi.data.source.model.MangasPage;
import eu.kanade.tachiyomi.data.source.model.Page;
import okhttp3.Headers;
import okhttp3.Request;
import okhttp3.Response;
import rx.Observable;
import rx.schedulers.Schedulers;
@ -47,13 +49,46 @@ public abstract class Source extends BaseSource {
return false;
}
protected Request popularMangaRequest(MangasPage page) {
if (page.page == 1) {
page.url = getInitialPopularMangasUrl();
}
return ReqKt.get(page.url, requestHeaders);
}
protected Request searchMangaRequest(MangasPage page, String query) {
if (page.page == 1) {
page.url = getInitialSearchUrl(query);
}
return ReqKt.get(page.url, requestHeaders);
}
protected Request mangaDetailsRequest(String mangaUrl) {
return ReqKt.get(getBaseUrl() + mangaUrl, requestHeaders);
}
protected Request chapterListRequest(String mangaUrl) {
return ReqKt.get(getBaseUrl() + mangaUrl, requestHeaders);
}
protected Request pageListRequest(String chapterUrl) {
return ReqKt.get(getBaseUrl() + chapterUrl, requestHeaders);
}
protected Request imageUrlRequest(Page page) {
return ReqKt.get(page.getUrl(), requestHeaders);
}
protected Request imageRequest(Page page) {
return ReqKt.get(page.getImageUrl(), requestHeaders);
}
// Get the most popular mangas from the source
public Observable<MangasPage> pullPopularMangasFromNetwork(MangasPage page) {
if (page.page == 1)
page.url = getInitialPopularMangasUrl();
return networkService
.getStringResponse(page.url, requestHeaders, true)
.requestBody(popularMangaRequest(page), true)
.map(Jsoup::parse)
.doOnNext(doc -> page.mangas = parsePopularMangasFromHtml(doc))
.doOnNext(doc -> page.nextPageUrl = parseNextPopularMangasUrl(doc, page))
@ -62,11 +97,8 @@ public abstract class Source extends BaseSource {
// Get mangas from the source with a query
public Observable<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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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