package com.quran.labs.androidquran.presenter.bookmark;
import android.content.Context;
import android.support.annotation.VisibleForTesting;
import android.support.design.widget.Snackbar;
import com.crashlytics.android.answers.Answers;
import com.crashlytics.android.answers.CustomEvent;
import com.quran.labs.androidquran.dao.Bookmark;
import com.quran.labs.androidquran.dao.BookmarkData;
import com.quran.labs.androidquran.dao.RecentPage;
import com.quran.labs.androidquran.dao.Tag;
import com.quran.labs.androidquran.data.Constants;
import com.quran.labs.androidquran.model.bookmark.BookmarkModel;
import com.quran.labs.androidquran.model.bookmark.BookmarkResult;
import com.quran.labs.androidquran.model.translation.ArabicDatabaseUtils;
import com.quran.labs.androidquran.presenter.Presenter;
import com.quran.labs.androidquran.ui.fragment.BookmarksFragment;
import com.quran.labs.androidquran.ui.helpers.QuranRow;
import com.quran.labs.androidquran.ui.helpers.QuranRowFactory;
import com.quran.labs.androidquran.util.QuranSettings;
import com.quran.labs.androidquran.util.QuranUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import javax.inject.Singleton;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.observers.DisposableSingleObserver;
import io.reactivex.schedulers.Schedulers;
import timber.log.Timber;
@Singleton
public class BookmarkPresenter implements Presenter<BookmarksFragment> {
@Snackbar.Duration public static final int DELAY_DELETION_DURATION_IN_MS = 4 * 1000; // 4 seconds
private static final long BOOKMARKS_WITHOUT_TAGS_ID = -1;
private final Context appContext;
private final BookmarkModel bookmarkModel;
private final QuranSettings quranSettings;
private int sortOrder;
private boolean groupByTags;
private boolean showRecents;
private BookmarkResult cachedData;
private BookmarksFragment fragment;
private ArabicDatabaseUtils arabicDatabaseUtils;
private boolean isRtl;
private DisposableSingleObserver<BookmarkResult> pendingRemoval;
private List<QuranRow> itemsToRemove;
@Inject
BookmarkPresenter(Context appContext,
BookmarkModel bookmarkModel,
QuranSettings quranSettings,
ArabicDatabaseUtils arabicDatabaseUtils) {
this.appContext = appContext;
this.quranSettings = quranSettings;
this.bookmarkModel = bookmarkModel;
this.arabicDatabaseUtils = arabicDatabaseUtils;
sortOrder = quranSettings.getBookmarksSortOrder();
groupByTags = quranSettings.getBookmarksGroupedByTags();
showRecents = quranSettings.getShowRecents();
subscribeToChanges();
}
void subscribeToChanges() {
Observable.merge(bookmarkModel.tagsObservable(),
bookmarkModel.bookmarksObservable(), bookmarkModel.recentPagesUpdatedObservable())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> {
if (fragment != null) {
requestData(false);
} else {
cachedData = null;
}
});
}
public int getSortOrder() {
return sortOrder;
}
public void setSortOrder(int sortOrder) {
this.sortOrder = sortOrder;
quranSettings.setBookmarksSortOrder(this.sortOrder);
requestData(false);
}
public void toggleGroupByTags() {
groupByTags = !groupByTags;
quranSettings.setBookmarksGroupedByTags(groupByTags);
requestData(false);
Answers.getInstance().logCustom(
new CustomEvent(groupByTags ? "groupByTags" : "doNotGroupByTags"));
}
public void toggleShowRecents() {
showRecents = !showRecents;
quranSettings.setShowRecents(showRecents);
requestData(false);
Answers.getInstance().logCustom(
new CustomEvent(showRecents ? "showRecents" : "doNotMinimizeRecents"));
}
public boolean isShowingRecents() {
return showRecents;
}
public boolean shouldShowInlineTags() {
return !groupByTags;
}
public boolean isGroupedByTags() {
return groupByTags;
}
public boolean[] getContextualOperationsForItems(List<QuranRow> rows) {
boolean[] result = new boolean[3];
int headers = 0;
int bookmarks = 0;
for (int i = 0, rowsSize = rows.size(); i < rowsSize; i++) {
QuranRow row = rows.get(i);
if (row.isBookmarkHeader()) {
headers++;
} else if (row.isBookmark()) {
bookmarks++;
}
}
result[0] = headers == 1 && bookmarks == 0;
result[1] = (headers + bookmarks) > 0;
result[2] = headers == 0 && bookmarks > 0;
return result;
}
public void requestData(boolean canCache) {
if (canCache && cachedData != null) {
if (fragment != null) {
Timber.d("sending cached bookmark data");
fragment.onNewData(cachedData);
}
} else {
Timber.d("requesting bookmark data from the database");
getBookmarks(sortOrder, groupByTags);
}
}
public void deleteAfterSomeTime(List<QuranRow> remove) {
// the fragment just called this, so fragment should be valid
fragment.onNewData(predictQuranListAfterDeletion(remove));
if (pendingRemoval != null) {
// handle a new delete request when one is already happening by adding those items to delete
// now and un-subscribing from the old request.
if (itemsToRemove != null) {
remove.addAll(itemsToRemove);
}
cancelDeletion();
}
itemsToRemove = remove;
pendingRemoval = Single.timer(DELAY_DELETION_DURATION_IN_MS, TimeUnit.MILLISECONDS)
.flatMap(ignore -> removeItemsObservable())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeWith(new DisposableSingleObserver<BookmarkResult>() {
@Override
public void onSuccess(BookmarkResult result) {
pendingRemoval = null;
cachedData = result;
if (fragment != null) {
fragment.onNewData(result);
}
}
@Override
public void onError(Throwable e) {
}
});
}
private BookmarkResult predictQuranListAfterDeletion(List<QuranRow> remove) {
if (cachedData != null) {
List<QuranRow> placeholder = new ArrayList<>(cachedData.rows.size() - remove.size());
List<QuranRow> rows = cachedData.rows;
List<Long> removedTags = new ArrayList<>();
for (int i = 0, rowsSize = rows.size(); i < rowsSize; i++) {
QuranRow row = rows.get(i);
if (!remove.contains(row)) {
placeholder.add(row);
}
}
for (int i = 0, removedSize = remove.size(); i < removedSize; i++) {
QuranRow row = remove.get(i);
if (row.isHeader() && row.tagId > 0) {
removedTags.add(row.tagId);
}
}
Map<Long, Tag> tagMap;
if (removedTags.isEmpty()) {
tagMap = cachedData.tagMap;
} else {
tagMap = new HashMap<>(cachedData.tagMap);
for (int i = 0, removedTagsSize = removedTags.size(); i < removedTagsSize; i++) {
Long tagId = removedTags.get(i);
tagMap.remove(tagId);
}
}
return new BookmarkResult(placeholder, tagMap);
}
return null;
}
private Single<BookmarkResult> removeItemsObservable() {
return bookmarkModel.removeItemsObservable(new ArrayList<>(itemsToRemove))
.andThen(getBookmarksListObservable(sortOrder, groupByTags));
}
public void cancelDeletion() {
if (pendingRemoval != null) {
pendingRemoval.dispose();
pendingRemoval = null;
itemsToRemove = null;
}
}
private Single<BookmarkData> getBookmarksWithAyatObservable(int sortOrder) {
return bookmarkModel.getBookmarkDataObservable(sortOrder)
.map(bookmarkData -> {
try {
return new BookmarkData(bookmarkData.getTags(),
arabicDatabaseUtils.hydrateAyahText(bookmarkData.getBookmarks()),
bookmarkData.getRecentPages());
} catch (Exception e) {
return bookmarkData;
}
});
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
Single<BookmarkResult> getBookmarksListObservable(
int sortOrder, final boolean groupByTags) {
return getBookmarksWithAyatObservable(sortOrder)
.map(bookmarkData -> {
List<QuranRow> rows = getBookmarkRows(bookmarkData, groupByTags);
Map<Long, Tag> tagMap = generateTagMap(bookmarkData.getTags());
return new BookmarkResult(rows, tagMap);
})
.subscribeOn(Schedulers.io());
}
private void getBookmarks(final int sortOrder, final boolean groupByTags) {
getBookmarksListObservable(sortOrder, groupByTags)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
// notify the ui if we're attached
cachedData = result;
if (fragment != null) {
if (pendingRemoval != null && itemsToRemove != null) {
fragment.onNewData(predictQuranListAfterDeletion(itemsToRemove));
} else {
fragment.onNewData(result);
}
}
});
}
private List<QuranRow> getBookmarkRows(BookmarkData data, boolean groupByTags) {
List<QuranRow> rows;
List<Tag> tags = data.getTags();
List<Bookmark> bookmarks = data.getBookmarks();
if (groupByTags) {
rows = getRowsSortedByTags(tags, bookmarks);
} else {
rows = getSortedRows(bookmarks);
}
List<RecentPage> recentPages = data.getRecentPages();
int size = recentPages.size();
if (size > 0) {
if (!showRecents) {
// only show the last bookmark if show recents is off
size = 1;
}
rows.add(0, QuranRowFactory.fromRecentPageHeader(appContext, size));
for (int i = 0; i < size; i++) {
int page = recentPages.get(i).page;
if (page < Constants.PAGES_FIRST || page > Constants.PAGES_LAST) {
page = 1;
}
rows.add(i + 1, QuranRowFactory.fromCurrentPage(appContext, page));
}
}
return rows;
}
private List<QuranRow> getRowsSortedByTags(List<Tag> tags, List<Bookmark> bookmarks) {
List<QuranRow> rows = new ArrayList<>();
// sort by tags, alphabetical
Map<Long, List<Bookmark>> tagsMapping = generateTagsMapping(tags, bookmarks);
for (int i = 0, tagsSize = tags.size(); i < tagsSize; i++) {
Tag tag = tags.get(i);
rows.add(QuranRowFactory.fromTag(tag));
List<Bookmark> tagBookmarks = tagsMapping.get(tag.id);
for (int j = 0, tagBookmarksSize = tagBookmarks.size(); j < tagBookmarksSize; j++) {
rows.add(QuranRowFactory.fromBookmark(appContext, tagBookmarks.get(j), tag.id));
}
}
// add untagged bookmarks
List<Bookmark> untagged = tagsMapping.get(BOOKMARKS_WITHOUT_TAGS_ID);
if (untagged.size() > 0) {
rows.add(QuranRowFactory.fromNotTaggedHeader(appContext));
for (int i = 0, untaggedSize = untagged.size(); i < untaggedSize; i++) {
rows.add(QuranRowFactory.fromBookmark(appContext, untagged.get(i)));
}
}
return rows;
}
private List<QuranRow> getSortedRows(List<Bookmark> bookmarks) {
List<QuranRow> rows = new ArrayList<>(bookmarks.size());
List<Bookmark> ayahBookmarks = new ArrayList<>();
// add the page bookmarks directly, save ayah bookmarks for later
for (int i = 0, bookmarksSize = bookmarks.size(); i < bookmarksSize; i++) {
Bookmark bookmark = bookmarks.get(i);
if (bookmark.isPageBookmark()) {
rows.add(QuranRowFactory.fromBookmark(appContext, bookmark));
} else {
ayahBookmarks.add(bookmark);
}
}
// add page bookmarks header if needed
if (rows.size() > 0) {
rows.add(0, QuranRowFactory.fromPageBookmarksHeader(appContext));
}
// add ayah bookmarks if any
if (ayahBookmarks.size() > 0) {
rows.add(QuranRowFactory.fromAyahBookmarksHeader(appContext));
for (int i = 0, ayahBookmarksSize = ayahBookmarks.size(); i < ayahBookmarksSize; i++) {
rows.add(QuranRowFactory.fromBookmark(appContext, ayahBookmarks.get(i)));
}
}
return rows;
}
private Map<Long, List<Bookmark>> generateTagsMapping(
List<Tag> tags, List<Bookmark> bookmarks) {
Set<Long> seenBookmarks = new HashSet<>();
Map<Long, List<Bookmark>> tagMappings = new HashMap<>();
for (int i = 0, tagSize = tags.size(); i < tagSize; i++) {
long id = tags.get(i).id;
List<Bookmark> matchingBookmarks = new ArrayList<>();
for (int j = 0, bookmarkSize = bookmarks.size(); j < bookmarkSize; j++) {
Bookmark bookmark = bookmarks.get(j);
if (bookmark.tags.contains(id)) {
matchingBookmarks.add(bookmark);
seenBookmarks.add(bookmark.id);
}
}
tagMappings.put(id, matchingBookmarks);
}
List<Bookmark> untaggedBookmarks = new ArrayList<>();
for (int i = 0, bookmarksSize = bookmarks.size(); i < bookmarksSize; i++) {
Bookmark bookmark = bookmarks.get(i);
if (!seenBookmarks.contains(bookmark.id)) {
untaggedBookmarks.add(bookmark);
}
}
tagMappings.put(BOOKMARKS_WITHOUT_TAGS_ID, untaggedBookmarks);
return tagMappings;
}
private Map<Long, Tag> generateTagMap(List<Tag> tags) {
Map<Long, Tag> tagMap = new HashMap<>(tags.size());
for (int i = 0, size = tags.size(); i < size; i++) {
Tag tag = tags.get(i);
tagMap.put(tag.id, tag);
}
return tagMap;
}
@Override
public void bind(BookmarksFragment fragment) {
this.fragment = fragment;
boolean isRtl = quranSettings.isArabicNames() || QuranUtils.isRtl();
if (isRtl == this.isRtl) {
requestData(true);
} else {
// don't use the cache if rtl changed
this.isRtl = isRtl;
requestData(false);
}
}
@Override
public void unbind(BookmarksFragment fragment) {
if (fragment == this.fragment) {
this.fragment = null;
}
}
}