Преглед изворни кода

Almost showing a chapter reader

inorichi пре 9 година
родитељ
комит
5142df103b
28 измењених фајлова са 578 додато и 117 уклоњено
  1. 2 0
      app/build.gradle
  2. 9 0
      app/src/main/AndroidManifest.xml
  3. 4 0
      app/src/main/java/eu/kanade/mangafeed/AppComponent.java
  4. 22 44
      app/src/main/java/eu/kanade/mangafeed/data/caches/CacheManager.java
  5. 43 0
      app/src/main/java/eu/kanade/mangafeed/data/models/Page.java
  6. 7 0
      app/src/main/java/eu/kanade/mangafeed/presenter/MainPresenter.java
  7. 8 2
      app/src/main/java/eu/kanade/mangafeed/presenter/MangaChaptersPresenter.java
  8. 0 1
      app/src/main/java/eu/kanade/mangafeed/presenter/MangaInfoPresenter.java
  9. 0 1
      app/src/main/java/eu/kanade/mangafeed/presenter/SourcePresenter.java
  10. 93 0
      app/src/main/java/eu/kanade/mangafeed/presenter/ViewerPresenter.java
  11. 76 47
      app/src/main/java/eu/kanade/mangafeed/sources/Source.java
  12. 1 6
      app/src/main/java/eu/kanade/mangafeed/ui/activity/BaseActivity.java
  13. 3 3
      app/src/main/java/eu/kanade/mangafeed/ui/activity/MainActivity.java
  14. 3 6
      app/src/main/java/eu/kanade/mangafeed/ui/activity/MangaDetailActivity.java
  15. 40 0
      app/src/main/java/eu/kanade/mangafeed/ui/activity/ViewerActivity.java
  16. 17 0
      app/src/main/java/eu/kanade/mangafeed/ui/adapter/ChapterListHolder.java
  17. 36 0
      app/src/main/java/eu/kanade/mangafeed/ui/adapter/SmartFragmentStatePagerAdapter.java
  18. 39 0
      app/src/main/java/eu/kanade/mangafeed/ui/adapter/ViewerPageAdapter.java
  19. 9 1
      app/src/main/java/eu/kanade/mangafeed/ui/fragment/MangaChaptersFragment.java
  20. 84 0
      app/src/main/java/eu/kanade/mangafeed/ui/fragment/ViewerPageFragment.java
  21. 36 0
      app/src/main/java/eu/kanade/mangafeed/util/PageFileTarget.java
  22. 23 0
      app/src/main/java/eu/kanade/mangafeed/util/events/SourceChapterEvent.java
  23. 1 1
      app/src/main/res/layout/activity_manga_detail.xml
  24. 13 0
      app/src/main/res/layout/activity_viewer.xml
  25. 6 0
      app/src/main/res/layout/fragment_page.xml
  26. 1 0
      app/src/main/res/values/strings.xml
  27. 1 4
      app/src/test/java/eu/kanade/mangafeed/BatotoTest.java
  28. 1 1
      app/src/test/java/eu/kanade/mangafeed/MangahereTest.java

+ 2 - 0
app/build.gradle

@@ -62,6 +62,7 @@ dependencies {
     compile 'com.squareup.okhttp:okhttp-urlconnection:2.4.0'
     compile 'com.squareup.okhttp:okhttp:2.4.0'
     compile 'com.squareup.okio:okio:1.6.0'
+    compile 'com.google.code.gson:gson:2.4'
     compile 'com.jakewharton:disklrucache:2.0.2'
     compile 'org.jsoup:jsoup:1.8.3'
     compile 'io.reactivex:rxandroid:1.0.1'
@@ -76,6 +77,7 @@ dependencies {
     compile 'com.jakewharton.timber:timber:3.1.0'
     compile 'uk.co.ribot:easyadapter:1.5.0@aar'
     compile 'ch.acra:acra:4.6.2'
+    compile 'com.davemorrissey.labs:subsampling-scale-image-view:3.4.1'
     compile "frankiesardo:icepick:$ICEPICK_VERSION"
     provided "frankiesardo:icepick-processor:$ICEPICK_VERSION"
 

+ 9 - 0
app/src/main/AndroidManifest.xml

@@ -37,6 +37,15 @@
                 android:name="android.support.PARENT_ACTIVITY"
                 android:value="eu.kanade.mangafeed.ui.activity.MainActivity" />
         </activity>
+        <activity
+            android:name=".ui.activity.ViewerActivity"
+            android:label="@string/title_activity_viewer"
+            android:parentActivityName=".ui.activity.MangaDetailActivity"
+            android:theme="@style/AppTheme" >
+            <meta-data
+                android:name="android.support.PARENT_ACTIVITY"
+                android:value="eu.kanade.mangafeed.ui.activity.MangaDetailActivity" />
+        </activity>
     </application>
 
 </manifest>

+ 4 - 0
app/src/main/java/eu/kanade/mangafeed/AppComponent.java

@@ -8,10 +8,12 @@ import dagger.Component;
 import eu.kanade.mangafeed.data.DataModule;
 import eu.kanade.mangafeed.presenter.CataloguePresenter;
 import eu.kanade.mangafeed.presenter.LibraryPresenter;
+import eu.kanade.mangafeed.presenter.MainPresenter;
 import eu.kanade.mangafeed.presenter.MangaChaptersPresenter;
 import eu.kanade.mangafeed.presenter.MangaDetailPresenter;
 import eu.kanade.mangafeed.presenter.MangaInfoPresenter;
 import eu.kanade.mangafeed.presenter.SourcePresenter;
+import eu.kanade.mangafeed.presenter.ViewerPresenter;
 
 @Singleton
 @Component(
@@ -22,12 +24,14 @@ import eu.kanade.mangafeed.presenter.SourcePresenter;
 )
 public interface AppComponent {
 
+    void inject(MainPresenter mainPresenter);
     void inject(LibraryPresenter libraryPresenter);
     void inject(MangaDetailPresenter mangaDetailPresenter);
     void inject(SourcePresenter sourcePresenter);
     void inject(CataloguePresenter cataloguePresenter);
     void inject(MangaInfoPresenter mangaInfoPresenter);
     void inject(MangaChaptersPresenter mangaChaptersPresenter);
+    void inject(ViewerPresenter viewerPresenter);
 
     Application application();
 

+ 22 - 44
app/src/main/java/eu/kanade/mangafeed/data/caches/CacheManager.java

@@ -5,20 +5,23 @@ import android.content.Context;
 import com.bumptech.glide.Glide;
 import com.bumptech.glide.request.FutureTarget;
 import com.bumptech.glide.request.target.Target;
+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 java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 
+import eu.kanade.mangafeed.data.models.Page;
 import eu.kanade.mangafeed.util.DiskUtils;
 import rx.Observable;
-import rx.functions.Action0;
 
 public class CacheManager {
 
@@ -29,11 +32,13 @@ public class CacheManager {
     private static final int READ_TIMEOUT = 60;
 
     private Context mContext;
+    private Gson mGson;
 
     private DiskLruCache mDiskCache;
 
     public CacheManager(Context context) {
         mContext = context;
+        mGson = new Gson();
 
         try {
             mDiskCache = DiskLruCache.open(
@@ -109,16 +114,11 @@ public class CacheManager {
         return isSuccessful;
     }
 
-    public Observable<String> getImageUrlsFromDiskCache(final String chapterUrl) {
+    public Observable<List<Page>> getPageUrlsFromDiskCache(final String chapterUrl) {
         return Observable.create(subscriber -> {
             try {
-                String[] imageUrls = getImageUrlsFromDiskCacheImpl(chapterUrl);
-
-                for (String imageUrl : imageUrls) {
-                    if (!subscriber.isUnsubscribed()) {
-                        subscriber.onNext(imageUrl);
-                    }
-                }
+                List<Page> pages = getPageUrlsFromDiskCacheImpl(chapterUrl);
+                subscriber.onNext(pages);
                 subscriber.onCompleted();
             } catch (Throwable e) {
                 subscriber.onError(e);
@@ -126,35 +126,28 @@ public class CacheManager {
         });
     }
 
-    private String[] getImageUrlsFromDiskCacheImpl(String chapterUrl) throws IOException {
+    private List<Page> getPageUrlsFromDiskCacheImpl(String chapterUrl) throws IOException {
         DiskLruCache.Snapshot snapshot = null;
+        List<Page> pages = null;
 
         try {
             String key = DiskUtils.hashKeyForDisk(chapterUrl);
-
             snapshot = mDiskCache.get(key);
 
-            String joinedImageUrls = snapshot.getString(0);
-            return joinedImageUrls.split(",");
+            Type collectionType = new TypeToken<List<Page>>() {}.getType();
+            pages = mGson.fromJson(snapshot.getString(0), collectionType);
+        } catch (IOException e) {
+            // Do Nothing.
         } finally {
             if (snapshot != null) {
                 snapshot.close();
             }
         }
+        return pages;
     }
 
-    public Action0 putImageUrlsToDiskCache(final String chapterUrl, final List<String> imageUrls) {
-        return () -> {
-            try {
-                putImageUrlsToDiskCacheImpl(chapterUrl, imageUrls);
-            } catch (IOException e) {
-                // Do Nothing.
-            }
-        };
-    }
-
-    private void putImageUrlsToDiskCacheImpl(String chapterUrl, List<String> imageUrls) throws IOException {
-        String cachedValue = joinImageUrlsToCacheValue(imageUrls);
+    public void putPageUrlsToDiskCache(final String chapterUrl, final List<Page> pages) {
+        String cachedValue = mGson.toJson(pages);
 
         DiskLruCache.Editor editor = null;
         OutputStream outputStream = null;
@@ -171,13 +164,11 @@ public class CacheManager {
 
             mDiskCache.flush();
             editor.commit();
+        } catch (Exception e) {
+            // Do Nothing.
         } finally {
             if (editor != null) {
-                try {
-                    editor.abort();
-                } catch (IOException ignore) {
-                    // Do Nothing.
-                }
+                editor.abortUnlessCommitted();
             }
             if (outputStream != null) {
                 try {
@@ -189,22 +180,9 @@ public class CacheManager {
         }
     }
 
-    private String joinImageUrlsToCacheValue(List<String> imageUrls) {
-        StringBuilder stringBuilder = new StringBuilder();
-        for (int index = 0; index < imageUrls.size(); index++) {
-            if (index == 0) {
-                stringBuilder.append(imageUrls.get(index));
-            } else {
-                stringBuilder.append(",");
-                stringBuilder.append(imageUrls.get(index));
-            }
-        }
-
-        return stringBuilder.toString();
-    }
-
     public File getCacheDir() {
         return mDiskCache.getDirectory();
     }
+
 }
 

+ 43 - 0
app/src/main/java/eu/kanade/mangafeed/data/models/Page.java

@@ -0,0 +1,43 @@
+package eu.kanade.mangafeed.data.models;
+
+public class Page {
+
+    private int pageNumber;
+    private String url;
+    private String imageUrl;
+
+    public Page(int pageNumber, String url, String imageUrl) {
+        this.pageNumber = pageNumber;
+        this.url = url;
+        this.imageUrl = imageUrl;
+    }
+
+    public Page(int pageNumber, String url) {
+        this(pageNumber, url, null);
+    }
+
+    public int getPageNumber() {
+        return pageNumber;
+    }
+
+    public String getUrl() {
+        return url;
+    }
+
+    public String getImageUrl() {
+        return imageUrl;
+    }
+
+    public void setImageUrl(String imageUrl) {
+        this.imageUrl = imageUrl;
+    }
+
+    @Override
+    public String toString() {
+        return "Page{" +
+                "pageNumber=" + pageNumber +
+                ", url='" + url + '\'' +
+                ", imageUrl='" + imageUrl + '\'' +
+                '}';
+    }
+}

+ 7 - 0
app/src/main/java/eu/kanade/mangafeed/presenter/MainPresenter.java

@@ -0,0 +1,7 @@
+package eu.kanade.mangafeed.presenter;
+
+import eu.kanade.mangafeed.ui.activity.MainActivity;
+
+public class MainPresenter extends BasePresenter<MainActivity> {
+
+}

+ 8 - 2
app/src/main/java/eu/kanade/mangafeed/presenter/MangaChaptersPresenter.java

@@ -13,13 +13,14 @@ import eu.kanade.mangafeed.data.helpers.DatabaseHelper;
 import eu.kanade.mangafeed.data.helpers.SourceManager;
 import eu.kanade.mangafeed.data.models.Chapter;
 import eu.kanade.mangafeed.data.models.Manga;
+import eu.kanade.mangafeed.sources.Source;
 import eu.kanade.mangafeed.ui.fragment.MangaChaptersFragment;
 import eu.kanade.mangafeed.util.EventBusHook;
 import eu.kanade.mangafeed.util.events.ChapterCountEvent;
+import eu.kanade.mangafeed.util.events.SourceChapterEvent;
 import rx.Observable;
 import rx.android.schedulers.AndroidSchedulers;
 import rx.schedulers.Schedulers;
-import timber.log.Timber;
 
 public class MangaChaptersPresenter extends BasePresenter<MangaChaptersFragment> {
 
@@ -27,6 +28,7 @@ public class MangaChaptersPresenter extends BasePresenter<MangaChaptersFragment>
     @Inject SourceManager sourceManager;
 
     private Manga manga;
+    private Source source;
 
     private static final int DB_CHAPTERS = 1;
     private static final int ONLINE_CHAPTERS = 2;
@@ -71,6 +73,7 @@ public class MangaChaptersPresenter extends BasePresenter<MangaChaptersFragment>
     public void onEventMainThread(Manga manga) {
         if (this.manga == null) {
             this.manga = manga;
+            source = sourceManager.get(manga.source);
             start(DB_CHAPTERS);
 
             // Get chapters if it's an online source
@@ -94,11 +97,14 @@ public class MangaChaptersPresenter extends BasePresenter<MangaChaptersFragment>
     }
 
     private Observable<PostResult> getOnlineChaptersObs() {
-        return sourceManager.get(manga.source)
+        return source
                 .pullChaptersFromNetwork(manga.url)
                 .subscribeOn(Schedulers.io())
                 .flatMap(chapters -> db.insertOrRemoveChapters(manga, chapters))
                 .observeOn(AndroidSchedulers.mainThread());
     }
 
+    public void onChapterClicked(Chapter chapter) {
+        EventBus.getDefault().postSticky(new SourceChapterEvent(source, chapter));
+    }
 }

+ 0 - 1
app/src/main/java/eu/kanade/mangafeed/presenter/MangaInfoPresenter.java

@@ -10,7 +10,6 @@ import eu.kanade.mangafeed.ui.fragment.MangaInfoFragment;
 import eu.kanade.mangafeed.util.EventBusHook;
 import eu.kanade.mangafeed.util.events.ChapterCountEvent;
 import rx.Observable;
-import timber.log.Timber;
 
 public class MangaInfoPresenter extends BasePresenter<MangaInfoFragment> {
 

+ 0 - 1
app/src/main/java/eu/kanade/mangafeed/presenter/SourcePresenter.java

@@ -3,7 +3,6 @@ package eu.kanade.mangafeed.presenter;
 import javax.inject.Inject;
 
 import eu.kanade.mangafeed.data.helpers.SourceManager;
-import eu.kanade.mangafeed.sources.Source;
 import eu.kanade.mangafeed.ui.fragment.SourceFragment;
 
 

+ 93 - 0
app/src/main/java/eu/kanade/mangafeed/presenter/ViewerPresenter.java

@@ -0,0 +1,93 @@
+package eu.kanade.mangafeed.presenter;
+
+import android.os.Bundle;
+
+import java.util.List;
+
+import javax.inject.Inject;
+
+import de.greenrobot.event.EventBus;
+import eu.kanade.mangafeed.data.caches.CacheManager;
+import eu.kanade.mangafeed.data.models.Chapter;
+import eu.kanade.mangafeed.data.models.Page;
+import eu.kanade.mangafeed.sources.Source;
+import eu.kanade.mangafeed.ui.activity.ViewerActivity;
+import eu.kanade.mangafeed.util.EventBusHook;
+import eu.kanade.mangafeed.util.events.SourceChapterEvent;
+import rx.Observable;
+import rx.android.schedulers.AndroidSchedulers;
+import rx.schedulers.Schedulers;
+
+public class ViewerPresenter extends BasePresenter<ViewerActivity> {
+
+    private static final int GET_PAGE_LIST = 1;
+    private Source source;
+    private Chapter chapter;
+    private List<Page> pageList;
+
+    @Inject CacheManager cacheManager;
+
+    @Override
+    protected void onCreate(Bundle savedState) {
+        super.onCreate(savedState);
+
+        restartableReplay(GET_PAGE_LIST,
+                this::getPageListObservable,
+                (view, page) -> {
+                });
+    }
+
+    @Override
+    protected void onTakeView(ViewerActivity view) {
+        super.onTakeView(view);
+        registerForStickyEvents();
+    }
+
+    @Override
+    protected void onDropView() {
+        unregisterForEvents();
+        super.onDropView();
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        EventBus.getDefault().removeStickyEvent(SourceChapterEvent.class);
+        source.savePageList(chapter.url, pageList);
+    }
+
+    @EventBusHook
+    public void onEventMainThread(SourceChapterEvent event) {
+        if (source == null || chapter == null) {
+            source = event.getSource();
+            chapter = event.getChapter();
+
+            start(1);
+        }
+    }
+
+    private Observable<Page> getPageListObservable() {
+        return source.pullPageListFromNetwork(chapter.url)
+                .subscribeOn(Schedulers.io())
+                .observeOn(AndroidSchedulers.mainThread())
+                .flatMap(pageList -> {
+                    this.pageList = pageList;
+
+                    return Observable.merge(
+                        Observable.from(pageList)
+                                .filter(page -> page.getImageUrl() != null),
+
+                        source.getRemainingImageUrlsFromPageList(pageList)
+                                .doOnNext(this::replacePageUrl));
+                });
+    }
+
+    private void replacePageUrl(Page page) {
+        for (int i = 0; i < pageList.size(); i++) {
+            if (pageList.get(i).getPageNumber() == page.getPageNumber()) {
+                pageList.set(i, page);
+                return;
+            }
+        }
+    }
+}

+ 76 - 47
app/src/main/java/eu/kanade/mangafeed/sources/Source.java

@@ -10,11 +10,49 @@ import eu.kanade.mangafeed.data.caches.CacheManager;
 import eu.kanade.mangafeed.data.helpers.NetworkHelper;
 import eu.kanade.mangafeed.data.models.Chapter;
 import eu.kanade.mangafeed.data.models.Manga;
+import eu.kanade.mangafeed.data.models.Page;
 import rx.Observable;
 import rx.schedulers.Schedulers;
 
 public abstract class Source {
 
+    // Methods to implement or optionally override
+
+    // 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 getSourceId();
+
+    protected abstract String getUrlFromPageNumber(int page);
+    protected abstract String getSearchUrl(String query, int page);
+    protected abstract List<Manga> parsePopularMangasFromHtml(String unparsedHtml);
+    protected abstract List<Manga> parseSearchFromHtml(String unparsedHtml);
+    protected abstract Manga parseHtmlToManga(String mangaUrl, String unparsedHtml);
+    protected abstract List<Chapter> parseHtmlToChapters(String unparsedHtml);
+    protected abstract List<String> parseHtmlToPageUrls(String unparsedHtml);
+    protected abstract String parseHtmlToImageUrl(String unparsedHtml);
+
+    // Get the URL to the details of a manga, useful if the source provides some kind of API or fast calls
+    protected String getMangaUrl(String defaultMangaUrl) {
+        return defaultMangaUrl;
+    }
+
+    // Default headers, it can be overriden by children or just add new keys
+    protected Headers.Builder headersBuilder() {
+        Headers.Builder builder = new Headers.Builder();
+        builder.add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)");
+        return builder;
+    }
+
+    // Number of images to download at the same time
+    protected int getNumberOfConcurrentImageDownloads() {
+        return 3;
+    }
+
+
+    // ***** Source class implementation *****
+
     protected NetworkHelper mNetworkService;
     protected CacheManager mCacheManager;
     protected Headers mRequestHeaders;
@@ -25,13 +63,6 @@ public abstract class Source {
         mRequestHeaders = headersBuilder().build();
     }
 
-    // Default headers, it can be overriden by children or add new keys
-    protected Headers.Builder headersBuilder() {
-        Headers.Builder builder = new Headers.Builder();
-        builder.add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)");
-        return builder;
-    }
-
     // Get the most popular mangas from the source
     public Observable<List<Manga>> pullPopularMangasFromNetwork(int page) {
         String url = getUrlFromPageNumber(page);
@@ -62,56 +93,54 @@ public abstract class Source {
                         Observable.just(parseHtmlToChapters(unparsedHtml)));
     }
 
+    public Observable<List<Page>> pullPageListFromNetwork(final String chapterUrl) {
+        return mCacheManager.getPageUrlsFromDiskCache(chapterUrl)
+                .onErrorResumeNext(throwable -> {
+                    return mNetworkService
+                            .getStringResponse(chapterUrl, mNetworkService.NULL_CACHE_CONTROL, mRequestHeaders)
+                            .flatMap(unparsedHtml -> Observable.just(parseHtmlToPageUrls(unparsedHtml)))
+                            .flatMap(this::convertToPages)
+                            .doOnNext(pages -> savePageList(chapterUrl, pages));
+                })
+                .onBackpressureBuffer();
+    }
+
     // Get the URLs of the images of a chapter
-    public Observable<String> getImageUrlsFromNetwork(final String chapterUrl) {
-        return mNetworkService
-                .getStringResponse(chapterUrl, mNetworkService.NULL_CACHE_CONTROL, mRequestHeaders)
-                .flatMap(unparsedHtml -> Observable.from(parseHtmlToPageUrls(unparsedHtml)))
-                .buffer(3)
-                .concatMap(batchedPageUrls -> {
-                    List<Observable<String>> imageUrlObservables = new ArrayList<>();
-                    for (String pageUrl : batchedPageUrls) {
-                        Observable<String> temporaryObservable = mNetworkService
-                                .getStringResponse(pageUrl, mNetworkService.NULL_CACHE_CONTROL, mRequestHeaders)
-                                .flatMap(unparsedHtml -> Observable.just(parseHtmlToImageUrl(unparsedHtml)))
-                                .subscribeOn(Schedulers.io());
-
-                        imageUrlObservables.add(temporaryObservable);
+    public Observable<Page> getRemainingImageUrlsFromPageList(final List<Page> pages) {
+        return Observable.from(pages)
+                .filter(page -> page.getImageUrl() == null)
+                .buffer(getNumberOfConcurrentImageDownloads())
+                .concatMap(batchedPages -> {
+                    List<Observable<Page>> pageObservable = new ArrayList<>();
+                    for (Page page : batchedPages) {
+                        pageObservable.add(getImageUrlFromPage(page));
                     }
-
-                    return Observable.merge(imageUrlObservables);
+                    return Observable.merge(pageObservable);
                 });
     }
 
-    // Store the URLs of a chapter in the cache
-    public Observable<String> pullImageUrlsFromNetwork(final String chapterUrl) {
-        final List<String> temporaryCachedImageUrls = new ArrayList<>();
-
-        return mCacheManager.getImageUrlsFromDiskCache(chapterUrl)
-                .onErrorResumeNext(throwable -> {
-                    return getImageUrlsFromNetwork(chapterUrl)
-                            .doOnNext(imageUrl -> temporaryCachedImageUrls.add(imageUrl))
-                            .doOnCompleted(mCacheManager.putImageUrlsToDiskCache(chapterUrl, temporaryCachedImageUrls));
+    private Observable<Page> getImageUrlFromPage(final Page page) {
+        return mNetworkService
+                .getStringResponse(page.getUrl(), mNetworkService.NULL_CACHE_CONTROL, mRequestHeaders)
+                .flatMap(unparsedHtml -> Observable.just(parseHtmlToImageUrl(unparsedHtml)))
+                .flatMap(imageUrl -> {
+                    page.setImageUrl(imageUrl);
+                    return Observable.just(page);
                 })
-                .onBackpressureBuffer();
+                .subscribeOn(Schedulers.io());
     }
 
-    // Get the URL to the details of a manga, useful if the source provides some kind of API or fast calls
-    protected String getMangaUrl(String defaultMangaUrl) {
-        return defaultMangaUrl;
+    public void savePageList(String chapterUrl, List<Page> pages) {
+        mCacheManager.putPageUrlsToDiskCache(chapterUrl, pages);
     }
 
-    public abstract String getName();
-    public abstract int getSourceId();
-
-    protected abstract String getUrlFromPageNumber(int page);
-    protected abstract String getSearchUrl(String query, int page);
-    protected abstract List<Manga> parsePopularMangasFromHtml(String unparsedHtml);
-    protected abstract List<Manga> parseSearchFromHtml(String unparsedHtml);
-    protected abstract Manga parseHtmlToManga(String mangaUrl, String unparsedHtml);
-    protected abstract List<Chapter> parseHtmlToChapters(String unparsedHtml);
-    protected abstract List<String> parseHtmlToPageUrls(String unparsedHtml);
-    protected abstract String parseHtmlToImageUrl(String unparsedHtml);
+    private Observable<List<Page>> convertToPages(List<String> pageUrls) {
+        List<Page> pages = new ArrayList<>();
+        for (int i = 0; i < pageUrls.size(); i++) {
+            pages.add(new Page(i, pageUrls.get(i)));
+        }
+        return Observable.just(pages);
+    }
 
 
 }

+ 1 - 6
app/src/main/java/eu/kanade/mangafeed/ui/activity/BaseActivity.java

@@ -8,7 +8,6 @@ import eu.kanade.mangafeed.App;
 import nucleus.factory.PresenterFactory;
 import nucleus.presenter.Presenter;
 import nucleus.view.NucleusAppCompatActivity;
-import timber.log.Timber;
 
 public class BaseActivity<P extends Presenter> extends NucleusAppCompatActivity<P> {
 
@@ -17,11 +16,7 @@ public class BaseActivity<P extends Presenter> extends NucleusAppCompatActivity<
         final PresenterFactory<P> superFactory = super.getPresenterFactory();
         setPresenterFactory(() -> {
             P presenter = superFactory.createPresenter();
-            try {
-                App.getComponentReflection(getActivity()).inject(presenter);
-            } catch(Exception e) {
-                Timber.w("No injection for " + presenter.getClass().toString());
-            }
+            App.getComponentReflection(getActivity()).inject(presenter);
             return presenter;
         });
         super.onCreate(savedInstanceState);

+ 3 - 3
app/src/main/java/eu/kanade/mangafeed/ui/activity/MainActivity.java

@@ -13,13 +13,13 @@ import com.mikepenz.materialdrawer.model.PrimaryDrawerItem;
 import butterknife.Bind;
 import butterknife.ButterKnife;
 import eu.kanade.mangafeed.R;
-import eu.kanade.mangafeed.presenter.BasePresenter;
+import eu.kanade.mangafeed.presenter.MainPresenter;
 import eu.kanade.mangafeed.ui.fragment.LibraryFragment;
 import eu.kanade.mangafeed.ui.fragment.SourceFragment;
 import nucleus.factory.RequiresPresenter;
 
-@RequiresPresenter(BasePresenter.class)
-public class MainActivity extends BaseActivity<BasePresenter> {
+@RequiresPresenter(MainPresenter.class)
+public class MainActivity extends BaseActivity<MainPresenter> {
 
     @Bind(R.id.toolbar)
     Toolbar toolbar;

+ 3 - 6
app/src/main/java/eu/kanade/mangafeed/ui/activity/MangaDetailActivity.java

@@ -26,7 +26,7 @@ public class MangaDetailActivity extends BaseActivity<MangaDetailPresenter> {
 
     @Bind(R.id.toolbar) Toolbar toolbar;
     @Bind(R.id.tabs) TabLayout tabs;
-    @Bind(R.id.viewpager) ViewPager view_pager;
+    @Bind(R.id.view_pager) ViewPager view_pager;
 
     private MangaDetailAdapter adapter;
     private long manga_id;
@@ -80,8 +80,7 @@ public class MangaDetailActivity extends BaseActivity<MangaDetailPresenter> {
     private void setupViewPager() {
         adapter = new MangaDetailAdapter(
                 getSupportFragmentManager(),
-                getActivity(),
-                manga_id);
+                getActivity());
 
         view_pager.setAdapter(adapter);
         tabs.setupWithViewPager(view_pager);
@@ -107,19 +106,17 @@ public class MangaDetailActivity extends BaseActivity<MangaDetailPresenter> {
         final int PAGE_COUNT = 2;
         private String tab_titles[];
         private Context context;
-        private long manga_id;
 
         final static int INFO_FRAGMENT = 0;
         final static int CHAPTERS_FRAGMENT = 1;
 
-        public MangaDetailAdapter(FragmentManager fm, Context context, long manga_id) {
+        public MangaDetailAdapter(FragmentManager fm, Context context) {
             super(fm);
             this.context = context;
             tab_titles = new String[]{
                     context.getString(R.string.manga_detail_tab),
                     context.getString(R.string.manga_chapters_tab)
             };
-            this.manga_id = manga_id;
         }
 
         @Override

+ 40 - 0
app/src/main/java/eu/kanade/mangafeed/ui/activity/ViewerActivity.java

@@ -0,0 +1,40 @@
+package eu.kanade.mangafeed.ui.activity;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.v4.view.ViewPager;
+
+import butterknife.Bind;
+import butterknife.ButterKnife;
+import eu.kanade.mangafeed.R;
+import eu.kanade.mangafeed.presenter.ViewerPresenter;
+import eu.kanade.mangafeed.ui.adapter.ViewerPageAdapter;
+import nucleus.factory.RequiresPresenter;
+
+@RequiresPresenter(ViewerPresenter.class)
+public class ViewerActivity extends BaseActivity<ViewerPresenter> {
+
+    @Bind(R.id.view_pager) ViewPager viewPager;
+
+    private ViewerPageAdapter adapter;
+
+    public static Intent newInstance(Context context) {
+        return new Intent(context, ViewerActivity.class);
+    }
+
+    @Override
+    public void onCreate(Bundle savedState) {
+        super.onCreate(savedState);
+        setContentView(R.layout.activity_viewer);
+        ButterKnife.bind(this);
+
+        createAdapter();
+    }
+
+    private void createAdapter() {
+        adapter = new ViewerPageAdapter(getSupportFragmentManager());
+        viewPager.setAdapter(adapter);
+    }
+
+}

+ 17 - 0
app/src/main/java/eu/kanade/mangafeed/ui/adapter/ChapterListHolder.java

@@ -20,12 +20,29 @@ public class ChapterListHolder extends ItemViewHolder<Chapter> {
     @ViewId(R.id.chapter_download_image)
     ImageView download_icon;
 
+    View view;
+
     public ChapterListHolder(View view) {
         super(view);
+        this.view = view;
     }
 
     public void onSetValues(Chapter chapter, PositionInfo positionInfo) {
         title.setText(chapter.name);
         download_icon.setImageResource(R.drawable.ic_file_download_black_48dp);
     }
+
+    @Override
+    public void onSetListeners() {
+        view.setOnClickListener(view -> {
+            ChapterListener listener = getListener(ChapterListener.class);
+            if (listener != null) {
+                listener.onRowClicked(getItem());
+            }
+        });
+    }
+
+    public interface ChapterListener {
+        void onRowClicked(Chapter chapter);
+    }
 }

+ 36 - 0
app/src/main/java/eu/kanade/mangafeed/ui/adapter/SmartFragmentStatePagerAdapter.java

@@ -0,0 +1,36 @@
+package eu.kanade.mangafeed.ui.adapter;
+
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+import android.support.v4.app.FragmentStatePagerAdapter;
+import android.util.SparseArray;
+import android.view.ViewGroup;
+
+public abstract class SmartFragmentStatePagerAdapter extends FragmentStatePagerAdapter {
+    // Sparse array to keep track of registered fragments in memory
+    private SparseArray<Fragment> registeredFragments = new SparseArray<Fragment>();
+
+    public SmartFragmentStatePagerAdapter(FragmentManager fragmentManager) {
+        super(fragmentManager);
+    }
+
+    // Register the fragment when the item is instantiated
+    @Override
+    public Object instantiateItem(ViewGroup container, int position) {
+        Fragment fragment = (Fragment) super.instantiateItem(container, position);
+        registeredFragments.put(position, fragment);
+        return fragment;
+    }
+
+    // Unregister when the item is inactive
+    @Override
+    public void destroyItem(ViewGroup container, int position, Object object) {
+        registeredFragments.remove(position);
+        super.destroyItem(container, position, object);
+    }
+
+    // Returns the fragment for the position (if instantiated)
+    public Fragment getRegisteredFragment(int position) {
+        return registeredFragments.get(position);
+    }
+}

+ 39 - 0
app/src/main/java/eu/kanade/mangafeed/ui/adapter/ViewerPageAdapter.java

@@ -0,0 +1,39 @@
+package eu.kanade.mangafeed.ui.adapter;
+
+import android.support.v4.app.Fragment;
+import android.support.v4.app.FragmentManager;
+
+import java.util.List;
+
+import eu.kanade.mangafeed.ui.fragment.ViewerPageFragment;
+
+public class ViewerPageAdapter extends SmartFragmentStatePagerAdapter {
+
+    private List<String> imageUrls;
+
+    public ViewerPageAdapter(FragmentManager fragmentManager) {
+        super(fragmentManager);
+    }
+
+    @Override
+    public int getCount() {
+        if (imageUrls != null)
+            return imageUrls.size();
+
+        return 0;
+    }
+
+    @Override
+    public Fragment getItem(int position) {
+        return ViewerPageFragment.newInstance(imageUrls.get(position), position);
+    }
+
+    public List<String> getImageUrls() {
+        return imageUrls;
+    }
+
+    public void setImageUrls(List<String> imageUrls) {
+        this.imageUrls = imageUrls;
+    }
+
+}

+ 9 - 1
app/src/main/java/eu/kanade/mangafeed/ui/fragment/MangaChaptersFragment.java

@@ -1,5 +1,6 @@
 package eu.kanade.mangafeed.ui.fragment;
 
+import android.content.Intent;
 import android.os.Bundle;
 import android.support.v4.app.Fragment;
 import android.support.v4.widget.SwipeRefreshLayout;
@@ -20,6 +21,7 @@ import eu.kanade.mangafeed.R;
 import eu.kanade.mangafeed.data.models.Chapter;
 import eu.kanade.mangafeed.presenter.MangaChaptersPresenter;
 import eu.kanade.mangafeed.ui.activity.MangaDetailActivity;
+import eu.kanade.mangafeed.ui.activity.ViewerActivity;
 import eu.kanade.mangafeed.ui.adapter.ChapterListHolder;
 import nucleus.factory.RequiresPresenter;
 import uk.co.ribot.easyadapter.EasyRecyclerAdapter;
@@ -73,7 +75,13 @@ public class MangaChaptersFragment extends BaseFragment<MangaChaptersPresenter>
     }
 
     private void createAdapter() {
-        adapter = new EasyRecyclerAdapter<>(getActivity(), ChapterListHolder.class);
+        ChapterListHolder.ChapterListener listener = chapter -> {
+            getPresenter().onChapterClicked(chapter);
+            Intent intent = ViewerActivity.newInstance(getActivity());
+            startActivity(intent);
+        };
+
+        adapter = new EasyRecyclerAdapter<>(getActivity(), ChapterListHolder.class, listener);
         chapters.setAdapter(adapter);
     }
 

+ 84 - 0
app/src/main/java/eu/kanade/mangafeed/ui/fragment/ViewerPageFragment.java

@@ -0,0 +1,84 @@
+package eu.kanade.mangafeed.ui.fragment;
+
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v4.app.Fragment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import com.bumptech.glide.Glide;
+import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
+
+import eu.kanade.mangafeed.R;
+import eu.kanade.mangafeed.util.PageFileTarget;
+
+public class ViewerPageFragment extends Fragment {
+    public static final String URL_ARGUMENT_KEY = "UrlArgumentKey";
+
+    private SubsamplingScaleImageView mPageImageView;
+
+    private String mUrl;
+
+    public static ViewerPageFragment newInstance(String url, int position) {
+        ViewerPageFragment newInstance = new ViewerPageFragment();
+        Bundle arguments = new Bundle();
+        arguments.putString(URL_ARGUMENT_KEY, url);
+        newInstance.setArguments(arguments);
+        return newInstance;
+    }
+
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        Bundle arguments = getArguments();
+        if (arguments != null) {
+            if (arguments.containsKey(URL_ARGUMENT_KEY)) {
+                mUrl = arguments.getString(URL_ARGUMENT_KEY);
+            }
+        }
+    }
+
+    @Override
+    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+        mPageImageView = (SubsamplingScaleImageView)inflater.inflate(R.layout.fragment_page, container, false);
+        mPageImageView.setVisibility(View.INVISIBLE);
+        mPageImageView.setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_FIXED);
+        mPageImageView.setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE);
+        mPageImageView.setMinimumScaleType(SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE);
+        mPageImageView.setOnImageEventListener(new SubsamplingScaleImageView.OnImageEventListener() {
+            @Override
+            public void onReady() {
+                mPageImageView.setVisibility(View.VISIBLE);
+            }
+
+            @Override
+            public void onImageLoaded() {
+            }
+
+            @Override
+            public void onPreviewLoadError(Exception e) {
+            }
+
+            @Override
+            public void onImageLoadError(Exception e) {
+            }
+
+            @Override
+            public void onTileLoadError(Exception e) {
+            }
+        });
+
+        return mPageImageView;
+    }
+
+    @Override
+    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+        super.onActivityCreated(savedInstanceState);
+
+        Glide.with(getActivity())
+                .load(mUrl)
+                .downloadOnly(new PageFileTarget(mPageImageView));
+    }
+}

+ 36 - 0
app/src/main/java/eu/kanade/mangafeed/util/PageFileTarget.java

@@ -0,0 +1,36 @@
+package eu.kanade.mangafeed.util;
+
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+
+import com.bumptech.glide.request.animation.GlideAnimation;
+import com.bumptech.glide.request.target.ViewTarget;
+import com.davemorrissey.labs.subscaleview.ImageSource;
+import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
+
+import java.io.File;
+
+import eu.kanade.mangafeed.R;
+
+public class PageFileTarget extends ViewTarget<SubsamplingScaleImageView, File> {
+    public static final String TAG = PageFileTarget.class.getSimpleName();
+
+    public PageFileTarget(SubsamplingScaleImageView view) {
+        super(view);
+    }
+
+    @Override
+    public void onLoadCleared(Drawable placeholder) {
+        view.setImage(ImageSource.resource(R.drawable.ic_action_refresh));
+    }
+
+    @Override
+    public void onLoadStarted(Drawable placeholder) {
+        view.setImage(ImageSource.resource(R.drawable.ic_action_refresh));
+    }
+
+    @Override
+    public void onResourceReady(File resource, GlideAnimation<? super File> glideAnimation) {
+        view.setImage(ImageSource.uri(Uri.fromFile(resource)));
+    }
+}

+ 23 - 0
app/src/main/java/eu/kanade/mangafeed/util/events/SourceChapterEvent.java

@@ -0,0 +1,23 @@
+package eu.kanade.mangafeed.util.events;
+
+import eu.kanade.mangafeed.data.models.Chapter;
+import eu.kanade.mangafeed.sources.Source;
+
+public class SourceChapterEvent {
+
+    private Source source;
+    private Chapter chapter;
+
+    public SourceChapterEvent(Source source, Chapter chapter) {
+        this.source = source;
+        this.chapter = chapter;
+    }
+
+    public Source getSource() {
+        return source;
+    }
+
+    public Chapter getChapter() {
+        return chapter;
+    }
+}

+ 1 - 1
app/src/main/res/layout/activity_manga_detail.xml

@@ -28,7 +28,7 @@
     </android.support.design.widget.AppBarLayout>
 
     <android.support.v4.view.ViewPager
-        android:id="@+id/viewpager"
+        android:id="@+id/view_pager"
         android:layout_width="match_parent"
         android:layout_height="0px"
         android:layout_weight="1"

+ 13 - 0
app/src/main/res/layout/activity_viewer.xml

@@ -0,0 +1,13 @@
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:gravity="center"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <android.support.v4.view.ViewPager
+        android:id="@+id/view_pager"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+    </android.support.v4.view.ViewPager>
+
+
+</FrameLayout>

+ 6 - 0
app/src/main/res/layout/fragment_page.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:id="@+id/page_image_view" />

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -45,5 +45,6 @@
     <string name="description">Description</string>
     <string name="manga_detail_tab">Info</string>
     <string name="manga_chapters_tab">Chapters</string>
+    <string name="title_activity_viewer">ViewerActivity</string>
 
 </resources>

+ 1 - 4
app/src/test/java/eu/kanade/mangafeed/BatotoTest.java

@@ -19,9 +19,6 @@ import eu.kanade.mangafeed.data.models.Chapter;
 import eu.kanade.mangafeed.data.models.Manga;
 import eu.kanade.mangafeed.sources.Batoto;
 import eu.kanade.mangafeed.sources.Source;
-import rx.android.schedulers.AndroidSchedulers;
-import rx.observers.TestSubscriber;
-import rx.schedulers.Schedulers;
 
 @Config(constants = BuildConfig.class, sdk = Build.VERSION_CODES.LOLLIPOP)
 @RunWith(RobolectricGradleTestRunner.class)
@@ -44,7 +41,7 @@ public class BatotoTest {
 
     @Test
     public void testImageList() {
-        List<String> imageUrls = b.getImageUrlsFromNetwork(chapterUrl)
+        List<String> imageUrls = b.getRemainingImageUrlsFromPageList(chapterUrl)
                 .toList().toBlocking().single();
 
         Assert.assertTrue(imageUrls.size() > 5);

+ 1 - 1
app/src/test/java/eu/kanade/mangafeed/MangahereTest.java

@@ -39,7 +39,7 @@ public class MangahereTest {
 
     @Test
     public void testImageList() {
-        List<String> imageUrls = b.getImageUrlsFromNetwork(chapterUrl)
+        List<String> imageUrls = b.getRemainingImageUrlsFromPageList(chapterUrl)
                 .toList().toBlocking().single();
 
         Assert.assertTrue(imageUrls.size() > 5);