Fix an issue where the retry button wasn't doing anything. Preload the first pages of the next chapter (if available). Show a toast if no next/previous chapter is available. Other minor changes.

This commit is contained in:
inorichi 2015-11-28 10:48:01 +01:00
parent 9db81b1832
commit 30b907bdf2
11 changed files with 475 additions and 68 deletions

View File

@ -80,8 +80,8 @@ public abstract class BaseSource {
return defaultPageUrl;
}
// Get the URL of the remaining pages that contains source images
protected String overrideRemainingPagesUrl(String defaultPageUrl) {
// Get the URL of the pages that contains source images
protected String overridePageUrl(String defaultPageUrl) {
return defaultPageUrl;
}

View File

@ -103,7 +103,7 @@ public abstract class Source extends BaseSource {
public Observable<Page> getImageUrlFromPage(final Page page) {
page.setStatus(Page.LOAD_PAGE);
return mNetworkService
.getStringResponse(overrideRemainingPagesUrl(page.getUrl()), mRequestHeaders, null)
.getStringResponse(overridePageUrl(page.getUrl()), mRequestHeaders, null)
.flatMap(unparsedHtml -> Observable.just(parseHtmlToImageUrl(unparsedHtml)))
.onErrorResumeNext(e -> {
page.setStatus(Page.ERROR);

View File

@ -135,7 +135,7 @@ public class Batoto extends Source {
}
@Override
protected String overrideRemainingPagesUrl(String defaultPageUrl) {
protected String overridePageUrl(String defaultPageUrl) {
int start = defaultPageUrl.indexOf("#") + 1;
int end = defaultPageUrl.indexOf("_", start);
String id = defaultPageUrl.substring(start, end);

View File

@ -6,7 +6,6 @@ import android.support.annotation.NonNull;
import de.greenrobot.event.EventBus;
import icepick.Icepick;
import nucleus.presenter.RxPresenter;
import nucleus.view.ViewWithPresenter;
public class BasePresenter<V extends ViewWithPresenter> extends RxPresenter<V> {

View File

@ -0,0 +1,332 @@
package eu.kanade.mangafeed.ui.base.presenter;
import android.os.Bundle;
import android.support.annotation.CallSuper;
import android.support.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import nucleus.presenter.Presenter;
import nucleus.presenter.delivery.DeliverFirst;
import nucleus.presenter.delivery.DeliverLatestCache;
import nucleus.presenter.delivery.DeliverReplay;
import nucleus.presenter.delivery.Delivery;
import rx.Observable;
import rx.Subscription;
import rx.functions.Action1;
import rx.functions.Action2;
import rx.functions.Func0;
import rx.internal.util.SubscriptionList;
import rx.subjects.BehaviorSubject;
/**
* This is an extension of {@link Presenter} which provides RxJava functionality.
*
* @param <View> a type of view.
*/
public class RxPresenter<View> extends Presenter<View> {
private static final String REQUESTED_KEY = RxPresenter.class.getName() + "#requested";
private final BehaviorSubject<View> views = BehaviorSubject.create();
private final SubscriptionList subscriptions = new SubscriptionList();
private final HashMap<Integer, Func0<Subscription>> restartables = new HashMap<>();
private final HashMap<Integer, Subscription> restartableSubscriptions = new HashMap<>();
private final ArrayList<Integer> requested = new ArrayList<>();
/**
* Returns an {@link rx.Observable} that emits the current attached view or null.
* See {@link BehaviorSubject} for more information.
*
* @return an observable that emits the current attached view or null.
*/
public Observable<View> view() {
return views;
}
/**
* Registers a subscription to automatically unsubscribe it during onDestroy.
* See {@link SubscriptionList#add(Subscription) for details.}
*
* @param subscription a subscription to add.
*/
public void add(Subscription subscription) {
subscriptions.add(subscription);
}
/**
* Removes and unsubscribes a subscription that has been registered with {@link #add} previously.
* See {@link SubscriptionList#remove(Subscription)} for details.
*
* @param subscription a subscription to remove.
*/
public void remove(Subscription subscription) {
subscriptions.remove(subscription);
}
/**
* A restartable is any RxJava observable that can be started (subscribed) and
* should be automatically restarted (re-subscribed) after a process restart if
* it was still subscribed at the moment of saving presenter's state.
*
* Registers a factory. Re-subscribes the restartable after the process restart.
*
* @param restartableId id of the restartable
* @param factory factory of the restartable
*/
public void restartable(int restartableId, Func0<Subscription> factory) {
restartables.put(restartableId, factory);
if (requested.contains(restartableId))
start(restartableId);
}
/**
* Starts the given restartable.
*
* @param restartableId id of the restartable
*/
public void start(int restartableId) {
stop(restartableId);
requested.add(restartableId);
restartableSubscriptions.put(restartableId, restartables.get(restartableId).call());
}
/**
* Unsubscribes a restartable
*
* @param restartableId id of a restartable.
*/
public void stop(int restartableId) {
requested.remove((Integer)restartableId);
Subscription subscription = restartableSubscriptions.get(restartableId);
if (subscription != null)
subscription.unsubscribe();
}
/**
* Checks if a restartable is started.
*
* @param restartableId id of a restartable.
* @return True if the restartable is started, false otherwise.
*/
public boolean isStarted(int restartableId) {
return requested.contains(restartableId);
}
/**
* This is a shortcut that can be used instead of combining together
* {@link #restartable(int, Func0)},
* {@link #deliverFirst()},
* {@link #split(Action2, Action2)}.
*
* @param restartableId an id of the restartable.
* @param observableFactory a factory that should return an Observable when the restartable should run.
* @param onNext a callback that will be called when received data should be delivered to view.
* @param onError a callback that will be called if the source observable emits onError.
* @param <T> the type of the observable.
*/
public <T> void restartableFirst(int restartableId, final Func0<Observable<T>> observableFactory,
final Action2<View, T> onNext, @Nullable final Action2<View, Throwable> onError) {
restartable(restartableId, new Func0<Subscription>() {
@Override
public Subscription call() {
return observableFactory.call()
.compose(RxPresenter.this.<T>deliverFirst())
.subscribe(split(onNext, onError));
}
});
}
/**
* This is a shortcut for calling {@link #restartableFirst(int, Func0, Action2, Action2)} with the last parameter = null.
*/
public <T> void restartableFirst(int restartableId, final Func0<Observable<T>> observableFactory, final Action2<View, T> onNext) {
restartableFirst(restartableId, observableFactory, onNext, null);
}
/**
* This is a shortcut that can be used instead of combining together
* {@link #restartable(int, Func0)},
* {@link #deliverLatestCache()},
* {@link #split(Action2, Action2)}.
*
* @param restartableId an id of the restartable.
* @param observableFactory a factory that should return an Observable when the restartable should run.
* @param onNext a callback that will be called when received data should be delivered to view.
* @param onError a callback that will be called if the source observable emits onError.
* @param <T> the type of the observable.
*/
public <T> void restartableLatestCache(int restartableId, final Func0<Observable<T>> observableFactory,
final Action2<View, T> onNext, @Nullable final Action2<View, Throwable> onError) {
restartable(restartableId, new Func0<Subscription>() {
@Override
public Subscription call() {
return observableFactory.call()
.compose(RxPresenter.this.<T>deliverLatestCache())
.subscribe(split(onNext, onError));
}
});
}
/**
* This is a shortcut for calling {@link #restartableLatestCache(int, Func0, Action2, Action2)} with the last parameter = null.
*/
public <T> void restartableLatestCache(int restartableId, final Func0<Observable<T>> observableFactory, final Action2<View, T> onNext) {
restartableLatestCache(restartableId, observableFactory, onNext, null);
}
/**
* This is a shortcut that can be used instead of combining together
* {@link #restartable(int, Func0)},
* {@link #deliverReplay()},
* {@link #split(Action2, Action2)}.
*
* @param restartableId an id of the restartable.
* @param observableFactory a factory that should return an Observable when the restartable should run.
* @param onNext a callback that will be called when received data should be delivered to view.
* @param onError a callback that will be called if the source observable emits onError.
* @param <T> the type of the observable.
*/
public <T> void restartableReplay(int restartableId, final Func0<Observable<T>> observableFactory,
final Action2<View, T> onNext, @Nullable final Action2<View, Throwable> onError) {
restartable(restartableId, new Func0<Subscription>() {
@Override
public Subscription call() {
return observableFactory.call()
.compose(RxPresenter.this.<T>deliverReplay())
.subscribe(split(onNext, onError));
}
});
}
/**
* This is a shortcut for calling {@link #restartableReplay(int, Func0, Action2, Action2)} with the last parameter = null.
*/
public <T> void restartableReplay(int restartableId, final Func0<Observable<T>> observableFactory, final Action2<View, T> onNext) {
restartableReplay(restartableId, observableFactory, onNext, null);
}
/**
* Returns an {@link rx.Observable.Transformer} that couples views with data that has been emitted by
* the source {@link rx.Observable}.
*
* {@link #deliverLatestCache} keeps the latest onNext value and emits it each time a new view gets attached.
* If a new onNext value appears while a view is attached, it will be delivered immediately.
*
* @param <T> the type of source observable emissions
*/
public <T> DeliverLatestCache<View, T> deliverLatestCache() {
return new DeliverLatestCache<>(views);
}
/**
* Returns an {@link rx.Observable.Transformer} that couples views with data that has been emitted by
* the source {@link rx.Observable}.
*
* {@link #deliverFirst} delivers only the first onNext value that has been emitted by the source observable.
*
* @param <T> the type of source observable emissions
*/
public <T> DeliverFirst<View, T> deliverFirst() {
return new DeliverFirst<>(views);
}
/**
* Returns an {@link rx.Observable.Transformer} that couples views with data that has been emitted by
* the source {@link rx.Observable}.
*
* {@link #deliverReplay} keeps all onNext values and emits them each time a new view gets attached.
* If a new onNext value appears while a view is attached, it will be delivered immediately.
*
* @param <T> the type of source observable emissions
*/
public <T> DeliverReplay<View, T> deliverReplay() {
return new DeliverReplay<>(views);
}
/**
* Returns a method that can be used for manual restartable chain build. It returns an Action1 that splits
* a received {@link Delivery} into two {@link Action2} onNext and onError calls.
*
* @param onNext a method that will be called if the delivery contains an emitted onNext value.
* @param onError a method that will be called if the delivery contains an onError throwable.
* @param <T> a type on onNext value.
* @return an Action1 that splits a received {@link Delivery} into two {@link Action2} onNext and onError calls.
*/
public <T> Action1<Delivery<View, T>> split(final Action2<View, T> onNext, @Nullable final Action2<View, Throwable> onError) {
return new Action1<Delivery<View, T>>() {
@Override
public void call(Delivery<View, T> delivery) {
delivery.split(onNext, onError);
}
};
}
/**
* This is a shortcut for calling {@link #split(Action2, Action2)} when the second parameter is null.
*/
public <T> Action1<Delivery<View, T>> split(Action2<View, T> onNext) {
return split(onNext, null);
}
/**
* {@inheritDoc}
*/
@CallSuper
@Override
protected void onCreate(Bundle savedState) {
if (savedState != null)
requested.addAll(savedState.getIntegerArrayList(REQUESTED_KEY));
}
/**
* {@inheritDoc}
*/
@CallSuper
@Override
protected void onDestroy() {
views.onCompleted();
subscriptions.unsubscribe();
for (Map.Entry<Integer, Subscription> entry : restartableSubscriptions.entrySet())
entry.getValue().unsubscribe();
}
/**
* {@inheritDoc}
*/
@CallSuper
@Override
protected void onSave(Bundle state) {
for (int i = requested.size() - 1; i >= 0; i--) {
int restartableId = requested.get(i);
Subscription subscription = restartableSubscriptions.get(restartableId);
if (subscription != null && subscription.isUnsubscribed())
requested.remove(i);
}
state.putIntegerArrayList(REQUESTED_KEY, requested);
}
/**
* {@inheritDoc}
*/
@CallSuper
@Override
protected void onTakeView(View view) {
views.onNext(view);
}
/**
* {@inheritDoc}
*/
@CallSuper
@Override
protected void onDropView() {
views.onNext(null);
}
}

View File

@ -84,6 +84,7 @@ public class MyAnimeListPresenter extends BasePresenter<MyAnimeListFragment> {
chapterSync.last_chapter_read = chapterNumber;
add(updateSubscription = myAnimeList.update(chapterSync)
.flatMap(response -> db.insertChapterSync(chapterSync).createObservable())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(response -> {},

View File

@ -45,6 +45,7 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
private Chapter nextChapter;
private Chapter previousChapter;
private List<Page> pageList;
private List<Page> nextChapterPageList;
private boolean isDownloaded;
@State int currentPage;
@ -56,6 +57,7 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
private static final int GET_PAGE_LIST = 1;
private static final int GET_PAGE_IMAGES = 2;
private static final int RETRY_IMAGES = 3;
private static final int PRELOAD_NEXT_CHAPTER = 4;
@Override
protected void onCreate(Bundle savedState) {
@ -81,7 +83,8 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
});
restartableReplay(GET_PAGE_IMAGES,
this::getPageImagesObservable,
() -> getPageImagesObservable()
.doOnCompleted(this::preloadNextChapter),
(view, page) -> {},
(view, error) -> Timber.e("An error occurred while downloading an image"));
@ -89,6 +92,11 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
this::getRetryPageObservable,
(view, page) -> {},
(view, error) -> Timber.e("An error occurred while downloading an image"));
restartableLatestCache(PRELOAD_NEXT_CHAPTER,
this::getPreloadNextChapterObservable,
(view, pages) -> {},
(view, error) -> Timber.e("An error occurred while preloading a chapter"));
}
@Override
@ -105,7 +113,7 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
@Override
protected void onDestroy() {
onChapterChange();
onChapterLeft();
super.onDestroy();
}
@ -125,10 +133,73 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
retryPageSubject.onNext(page);
}
// Returns the page list of a chapter
private Observable<List<Page>> getPageListObservable() {
return isDownloaded ?
// Fetch the page list from disk
Observable.just(downloadManager.getSavedPageList(source, manga, chapter)) :
// Fetch the page list from cache or fallback to network
source.getCachedPageListOrPullFromNetwork(chapter.url)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
// Get the chapter images from network or disk
private Observable<Page> getPageImagesObservable() {
Observable<Page> pageObservable;
if (!isDownloaded) {
pageObservable = Observable.from(pageList)
.filter(page -> page.getImageUrl() != null)
.mergeWith(source.getRemainingImageUrlsFromPageList(pageList))
.flatMap(source::getCachedImage, 3);
} else {
File chapterDir = downloadManager.getAbsoluteChapterDirectory(source, manga, chapter);
pageObservable = Observable.from(pageList)
.flatMap(page -> downloadManager.getDownloadedImage(page, source, chapterDir));
}
return pageObservable
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
// Listen for retry page events
private Observable<Page> getRetryPageObservable() {
return retryPageSubject
.observeOn(Schedulers.io())
.flatMap(page -> page.getImageUrl() == null ?
source.getImageUrlFromPage(page) :
Observable.just(page))
.flatMap(source::getCachedImage)
.observeOn(AndroidSchedulers.mainThread());
}
// Preload the first pages of the next chapter
private Observable<Page> getPreloadNextChapterObservable() {
return source.getCachedPageListOrPullFromNetwork(nextChapter.url)
.flatMap(pages -> {
nextChapterPageList = pages;
// Preload at most 5 pages
int pagesToPreload = Math.min(pages.size(), 5);
return Observable.from(pages)
.take(pagesToPreload)
.concatMap(source::getImageUrlFromPage)
.doOnCompleted(this::stopPreloadingNextChapter);
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
// Loads the given chapter
private void loadChapter(Chapter chapter) {
// Before loading the chapter, stop preloading (if it's working) and save current progress
stopPreloadingNextChapter();
this.chapter = chapter;
isDownloaded = isChapterDownloaded(chapter);
if (chapter.last_page_read != 0 && !chapter.read)
// If the chapter is partially read, set the starting page to the last the user read
if (!chapter.read && chapter.last_page_read != 0)
currentPage = chapter.last_page_read;
else
currentPage = 0;
@ -136,11 +207,22 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
// Reset next and previous chapter. They have to be fetched again
nextChapter = null;
previousChapter = null;
nextChapterPageList = null;
start(GET_PAGE_LIST);
}
private void onChapterChange() {
// Check whether the given chapter is downloaded
public boolean isChapterDownloaded(Chapter chapter) {
File dir = downloadManager.getAbsoluteChapterDirectory(source, manga, chapter);
List<Page> pageList = downloadManager.getSavedPageList(source, manga, chapter);
return pageList != null && pageList.size() + 1 == dir.listFiles().length;
}
// Called before loading another chapter or leaving the reader. It allows to do operations
// over the chapter read like saving progress
private void onChapterLeft() {
if (pageList == null)
return;
@ -158,6 +240,7 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
db.insertChapter(chapter).executeAsBlocking();
}
// Check whether the chapter has been read
private boolean isChapterFinished() {
return !chapter.read && currentPage == pageList.size() - 1;
}
@ -185,46 +268,6 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
}
}
private Observable<List<Page>> getPageListObservable() {
if (!isDownloaded)
return source.getCachedPageListOrPullFromNetwork(chapter.url)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
else
return Observable.just(downloadManager.getSavedPageList(source, manga, chapter));
}
private Observable<Page> getPageImagesObservable() {
Observable<Page> pages;
if (!isDownloaded) {
pages = Observable.from(pageList)
.filter(page -> page.getImageUrl() != null)
.mergeWith(source.getRemainingImageUrlsFromPageList(pageList))
.flatMap(source::getCachedImage, 3);
} else {
File chapterDir = downloadManager.getAbsoluteChapterDirectory(source, manga, chapter);
pages = Observable.from(pageList)
.flatMap(page -> downloadManager.getDownloadedImage(page, source, chapterDir));
}
return pages
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
private Observable<Page> getRetryPageObservable() {
return retryPageSubject
.flatMap(page -> {
if (page.getImageUrl() == null)
return source.getImageUrlFromPage(page);
return Observable.just(page);
})
.flatMap(source::getCachedImage)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
public void setCurrentPage(int currentPage) {
this.currentPage = currentPage;
}
@ -247,33 +290,49 @@ public class ReaderPresenter extends BasePresenter<ReaderActivity> {
.subscribe(result -> previousChapter = result));
}
public boolean isChapterDownloaded(Chapter chapter) {
File dir = downloadManager.getAbsoluteChapterDirectory(source, manga, chapter);
List<Page> pageList = downloadManager.getSavedPageList(source, manga, chapter);
return pageList != null && pageList.size() + 1 == dir.listFiles().length;
}
public void loadNextChapter() {
if (nextChapter != null) {
onChapterChange();
if (hasNextChapter()) {
onChapterLeft();
loadChapter(nextChapter);
}
}
public void loadPreviousChapter() {
if (previousChapter != null) {
onChapterChange();
if (hasPreviousChapter()) {
onChapterLeft();
loadChapter(previousChapter);
}
}
public Manga getManga() {
return manga;
public boolean hasNextChapter() {
return nextChapter != null;
}
public boolean hasPreviousChapter() {
return previousChapter != null;
}
private void preloadNextChapter() {
if (hasNextChapter() && !isChapterDownloaded(nextChapter)) {
start(PRELOAD_NEXT_CHAPTER);
}
}
private void stopPreloadingNextChapter() {
if (isStarted(PRELOAD_NEXT_CHAPTER)) {
stop(PRELOAD_NEXT_CHAPTER);
if (nextChapterPageList != null)
source.savePageList(nextChapter.url, nextChapterPageList);
}
}
public void updateMangaViewer(int viewer) {
manga.viewer = viewer;
db.insertManga(manga).executeAsBlocking();
}
public Manga getManga() {
return manga;
}
}

View File

@ -5,18 +5,23 @@ import android.view.ViewGroup;
import java.util.List;
import eu.kanade.mangafeed.R;
import eu.kanade.mangafeed.data.source.model.Page;
import eu.kanade.mangafeed.ui.reader.ReaderActivity;
import eu.kanade.mangafeed.ui.reader.ReaderPresenter;
import eu.kanade.mangafeed.util.ToastUtil;
public abstract class BaseReader {
protected ReaderActivity activity;
protected ReaderPresenter presenter;
protected ViewGroup container;
protected int currentPosition;
public BaseReader(ReaderActivity activity) {
this.activity = activity;
this.container = activity.getContainer();
this.presenter = activity.getPresenter();
}
public void updatePageNumber() {
@ -34,13 +39,22 @@ public abstract class BaseReader {
}
public void requestNextChapter() {
activity.getPresenter().setCurrentPage(getCurrentPosition());
activity.getPresenter().loadNextChapter();
if (presenter.hasNextChapter()) {
presenter.setCurrentPage(getCurrentPosition());
presenter.loadNextChapter();
} else {
ToastUtil.showShort(activity, R.string.no_next_chapter);
}
}
public void requestPreviousChapter() {
activity.getPresenter().setCurrentPage(getCurrentPosition());
activity.getPresenter().loadPreviousChapter();
if (presenter.hasPreviousChapter()) {
presenter.setCurrentPage(getCurrentPosition());
presenter.loadPreviousChapter();
} else {
ToastUtil.showShort(activity, R.string.no_previous_chapter);
}
}
public void destroy() {}

View File

@ -30,7 +30,7 @@ public abstract class HorizontalReader extends BaseReader {
transitionsSubscription = activity.getPreferences().enableTransitions().asObservable()
.subscribe(value -> transitions = value);
viewPager.setOffscreenPageLimit(3);
viewPager.setOffscreenPageLimit(2);
viewPager.addOnPageChangeListener(new HorizontalViewPager.SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {

View File

@ -31,7 +31,7 @@ public class VerticalReader extends BaseReader {
transitionsSubscription = activity.getPreferences().enableTransitions().asObservable()
.subscribe(value -> transitions = value);
viewPager.setOffscreenPageLimit(3);
viewPager.setOffscreenPageLimit(2);
viewPager.setOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {

View File

@ -92,6 +92,8 @@
<string name="chapter_progress">Page: %1$d</string>
<string name="page_list_error">Error fetching page list. Is network available?</string>
<string name="chapter_subtitle">Chapter %1$s</string>
<string name="no_next_chapter">Next chapter not found</string>
<string name="no_previous_chapter">Previous chapter not found</string>
<!-- Library update service notifications -->
<string name="notification_progress">Update progress: %1$d/%2$d</string>