package org.ebookdroid.core; import org.sufficientlysecure.viewer.R; import org.ebookdroid.common.keysbinding.KeyBindingsManager; import org.ebookdroid.common.settings.AppSettings; import org.ebookdroid.common.settings.SettingsManager; import org.ebookdroid.common.settings.books.BookSettings; import org.ebookdroid.common.settings.types.DocumentViewMode; import org.ebookdroid.common.settings.types.PageAlign; import org.ebookdroid.common.touch.DefaultGestureDetector; import org.ebookdroid.common.touch.IGestureDetector; import org.ebookdroid.common.touch.IMultiTouchListener; import org.ebookdroid.common.touch.MultiTouchGestureDetector; import org.ebookdroid.common.touch.TouchManager; import org.ebookdroid.common.touch.TouchManager.Touch; import org.ebookdroid.core.codec.PageLink; import org.ebookdroid.core.models.DocumentModel; import org.ebookdroid.core.models.DocumentModel.PageIterator; import org.ebookdroid.ui.viewer.IActivityController; import org.ebookdroid.ui.viewer.IView; import org.ebookdroid.ui.viewer.IViewController; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.RectF; import android.net.Uri; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; import android.util.FloatMath; import android.util.TypedValue; import android.view.GestureDetector.SimpleOnGestureListener; import android.view.KeyEvent; import android.view.MotionEvent; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.emdev.common.log.LogContext; import org.emdev.common.log.LogManager; import org.emdev.ui.actions.AbstractComponentController; import org.emdev.ui.actions.ActionEx; import org.emdev.ui.actions.ActionMethod; import org.emdev.ui.actions.params.Constant; import org.emdev.ui.progress.IProgressIndicator; import org.emdev.utils.LengthUtils; public abstract class AbstractViewController extends AbstractComponentController<IView> implements IViewController { protected static final LogContext LCTX = LogManager.root().lctx("View", false); public static final int DOUBLE_TAP_TIME = 500; /** * Allow tapping on links up to this many dp outside of the link rectangle */ private static final float LINK_TAP_THRESHOLD_DP = 10.0f; private static final Float FZERO = Float.valueOf(0); public final IActivityController base; public final DocumentModel model; public final DocumentViewMode mode; protected boolean isInitialized = false; protected boolean isShown = false; protected final AtomicBoolean inZoom = new AtomicBoolean(); protected final AtomicBoolean inQuickZoom = new AtomicBoolean(); protected final AtomicBoolean inZoomToColumn = new AtomicBoolean(); protected final PageIndex pageToGo; protected int firstVisiblePage; protected int lastVisiblePage; protected boolean layoutLocked; private List<IGestureDetector> detectors; public AbstractViewController(final IActivityController base, final DocumentViewMode mode) { super(base, base.getView()); this.base = base; this.mode = mode; this.model = base.getDocumentModel(); this.firstVisiblePage = -1; this.lastVisiblePage = -1; this.pageToGo = base.getBookSettings().getCurrentPage(); createAction(R.id.actions_verticalConfigScrollUp, new Constant("direction", -1)); createAction(R.id.actions_verticalConfigScrollDown, new Constant("direction", +1)); createAction(R.id.actions_leftTopCorner, new Constant("offsetX", 0), new Constant("offsetY", 0)); createAction(R.id.actions_leftBottomCorner, new Constant("offsetX", 0), new Constant("offsetY", 1)); createAction(R.id.actions_rightTopCorner, new Constant("offsetX", 1), new Constant("offsetY", 0)); createAction(R.id.actions_rightBottomCorner, new Constant("offsetX", 1), new Constant("offsetY", 1)); } protected List<IGestureDetector> getGestureDetectors() { if (detectors == null) { detectors = initGestureDetectors(new ArrayList<IGestureDetector>(4)); } return detectors; } protected List<IGestureDetector> initGestureDetectors(final List<IGestureDetector> list) { final GestureListener listener = new GestureListener(); list.add(new MultiTouchGestureDetector(listener)); list.add(new DefaultGestureDetector(base.getContext(), listener)); return list; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#getView() */ @Override public final IView getView() { return base.getView(); } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#getBase() */ @Override public final IActivityController getBase() { return base; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#init(org.ebookdroid.ui.viewer.IActivityController.IBookLoadTask) */ @Override @WorkerThread public final void init(final IProgressIndicator task) { if (!isInitialized) { try { model.initPages(base, task); } finally { isInitialized = true; } } } /** * */ @Override public final void onDestroy() { // isShown = false; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#show() */ @Override public final void show() { if (!isInitialized) { if (LCTX.isDebugEnabled()) { LCTX.d("View is not initialized yet"); } return; } if (!isShown) { isShown = true; if (LCTX.isDebugEnabled()) { LCTX.d("Showing view content..."); } invalidatePageSizes(InvalidateSizeReason.INIT, null); final BookSettings bs = base.getBookSettings(); bs.lastChanged = System.currentTimeMillis(); final Page page = pageToGo.getActualPage(model, bs); final int toPage = page != null ? page.index.viewIndex : 0; goToPage(toPage, bs.offsetX, bs.offsetY); } else { if (LCTX.isDebugEnabled()) { LCTX.d("View has been shown before"); } } } protected final void updatePosition(final Page page, final ViewState viewState) { final PointF pos = viewState.getPositionOnPage(page); SettingsManager.positionChanged(base.getBookSettings(), pos.x, pos.y); } /** * {@inheritDoc} * * @see org.ebookdroid.core.events.ZoomListener#zoomChanged(float, float, boolean) */ @Override public final void zoomChanged(final float oldZoom, final float newZoom, final boolean committed, @Nullable PointF center) { if (!isShown) { return; } inZoom.set(!committed); EventPool.newEventZoom(this, oldZoom, newZoom, committed, center).process().release(); if (committed) { base.getManagedComponent().zoomChanged(newZoom); } else { inQuickZoom.set(false); inZoomToColumn.set(false); } } @ActionMethod(ids = R.id.actions_quickZoom) public final void quickZoom(final ActionEx action) { if (inZoom.get()) { return; } float zoomFactor = 2.0f; if (inQuickZoom.compareAndSet(true, false)) { zoomFactor = 1.0f / zoomFactor; } else { inQuickZoom.set(true); inZoomToColumn.set(false); } base.getZoomModel().scaleAndCommitZoom(zoomFactor); } @ActionMethod(ids = R.id.actions_zoomToColumn) public final void zoomToColumn(final ActionEx action) { if (inZoom.get()) { return; } final int tapX = action.getParameter("tap_x", FZERO).intValue(); final int tapY = action.getParameter("tap_y", FZERO).intValue(); if (tapX == 0 && tapY == 0) { return; } // System.out.println("AbstractViewController.zoomToColumn(" + tapX + "," + tapY + ")"); PointF pos = null; Page page = null; final ViewState vs = ViewState.get(this); try { final PageIterator pages = model.getPages(firstVisiblePage, lastVisiblePage + 1); try { for (final Page p : pages) { pos = vs.getPositionOnPage(p, tapX, tapY); if ((0 <= pos.x && pos.x <= 1) && (0 <= pos.y && pos.y <= 1)) { page = p; break; } } } finally { pages.release(); } if (page == null) { return; } final IView view = base.getView(); if (inZoomToColumn.compareAndSet(true, false)) { base.getZoomModel().setZoom(1.0f, true); final float offsetX = 0; final float offsetY = pos.y - 0.5f * (view.getHeight() / page.getBounds(1.0f).height()); goToPage(page.index.viewIndex, offsetX, offsetY); return; } final RectF column = page.getColumn(pos); // System.out.println("AbstractViewController.zoomToColumn(): column = " + column); if (column == null || column.width() > 0.95f) { return; } inZoomToColumn.set(true); inQuickZoom.set(false); final int screenWidth = view.getWidth(); final int screenHeight = view.getHeight(); final RectF pb = vs.getBounds(page); final float columnScreenWidth = page.getPageRegion(pb, new RectF(column)).width(); final float newZoom = screenWidth / columnScreenWidth; base.getZoomModel().setZoom(newZoom, true); scrollToColumn(page, column, pos, screenHeight); } finally { vs.release(); } } protected void scrollToColumn(final Page page, final RectF column, final PointF pos, final int screenHeight) { final ViewState vs = ViewState.get(AbstractViewController.this); final RectF pb = vs.getBounds(page); final RectF columnRegion = page.getPageRegion(pb, new RectF(column)); columnRegion.offset(-vs.viewBase.x, -vs.viewBase.y); final float toX = columnRegion.left; final float toY = pb.top + pos.y * pb.height() - 0.5f * screenHeight; getView().scrollTo((int) toX, (int) toY); vs.release(); } @ActionMethod(ids = { R.id.actions_leftTopCorner, R.id.actions_leftBottomCorner, R.id.actions_rightTopCorner, R.id.actions_rightBottomCorner }) public void scrollToCorner(final ActionEx action) { final Integer offX = action.getParameter("offsetX"); final Integer offY = action.getParameter("offsetY"); final float offsetX = offX != null ? offX.floatValue() : 0; final float offsetY = offY != null ? offY.floatValue() : 0; new EventGotoPageCorner(this, offsetX, offsetY).process().release(); } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#updateMemorySettings() */ @Override public final void updateMemorySettings() { EventPool.newEventReset(this, null, false).process().release(); } public final int getScrollX() { return getView().getScrollX(); } public final int getWidth() { return getView().getWidth(); } public final int getScrollY() { return getView().getScrollY(); } public final int getHeight() { return getView().getHeight(); } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#dispatchKeyEvent(android.view.KeyEvent) */ @Override public final boolean dispatchKeyEvent(final KeyEvent event) { if (event.isCanceled()) { return false; } // Special case to ignore KeyEvent.KEYCODE_VOLUME_UP and KeyEvent.KEYCODE_VOLUME_DOWN // if the app setting is disabled final int eventKeyCode = event.getKeyCode(); if (!AppSettings.current().volumeKeyScrolling && (eventKeyCode == KeyEvent.KEYCODE_VOLUME_UP || eventKeyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) { return false; } // By default, this is only used to handle actions_verticalConfigScrollUp and // actions_verticalConfigScrollDown. // The best UX for these actions is to deliver them on key down events only. if (event.getAction() == KeyEvent.ACTION_DOWN) { final Integer actionId = KeyBindingsManager.getAction(event); final ActionEx action = actionId != null ? getOrCreateAction(actionId) : null; if (action != null) { if (LCTX.isDebugEnabled()) { LCTX.d("Key action: " + action.name + ", " + action.getMethod().toString()); } action.run(); return true; } else { if (LCTX.isDebugEnabled()) { LCTX.d("Key action not found: " + event); } } } else if (event.getAction() == KeyEvent.ACTION_UP) { final Integer id = KeyBindingsManager.getAction(event); if (id != null) { // We handled the KeyEvent.ACTION_DOWN, so return true to indicate we are handling // the KeyEvent.ACTION_UP as well. // Returning false here causes the volume keys to beep when scrolling. return true; } } return false; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#onTouchEvent(android.view.MotionEvent) */ @Override public final boolean onTouchEvent(final MotionEvent ev) { for (final IGestureDetector d : getGestureDetectors()) { if (d.enabled() && d.onTouchEvent(ev)) { return true; } } return false; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#onLayoutChanged(boolean, boolean, android.graphics.Rect, * android.graphics.Rect) */ @Override public boolean onLayoutChanged(final boolean layoutChanged, final boolean layoutLocked, final Rect oldLaout, final Rect newLayout) { if (LCTX.isDebugEnabled()) { LCTX.d("onLayoutChanged(" + layoutChanged + ", " + layoutLocked + "," + oldLaout + ", " + newLayout + ")"); } if (layoutChanged && !layoutLocked) { if (isShown) { EventPool.newEventReset(this, InvalidateSizeReason.LAYOUT, true).process().release(); return true; } else { if (LCTX.isDebugEnabled()) { LCTX.d("onLayoutChanged(): view not shown yet"); } } } return false; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#toggleRenderingEffects() */ @Override public final void toggleRenderingEffects() { EventPool.newEventReset(this, null, true).process().release(); } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#invalidateScroll() */ @Override public final void invalidateScroll() { if (!isShown) { return; } getView().invalidateScroll(); } /** * Sets the page align flag. * * @param align * the new flag indicating align */ @Override public final void setAlign(final PageAlign align) { EventPool.newEventReset(this, InvalidateSizeReason.PAGE_ALIGN, false).process().release(); } /** * Checks if view is initialized. * * @return true, if is initialized */ protected final boolean isShown() { return isShown; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#getFirstVisiblePage() */ @Override public final int getFirstVisiblePage() { return firstVisiblePage; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#getLastVisiblePage() */ @Override public final int getLastVisiblePage() { return lastVisiblePage; } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#redrawView() */ @Override public final void redrawView() { getView().redrawView(ViewState.get(this)); } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#redrawView(org.ebookdroid.core.ViewState) */ @Override public final void redrawView(final ViewState viewState) { getView().redrawView(viewState); } @ActionMethod(ids = { R.id.actions_verticalConfigScrollUp, R.id.actions_verticalConfigScrollDown }) public final void verticalConfigScroll(final ActionEx action) { final Integer direction = action.getParameter("direction"); verticalConfigScroll(direction); } protected final boolean processTap(final TouchManager.Touch type, final MotionEvent e) { final float x = e.getX(); final float y = e.getY(); if (type == Touch.SingleTap) { if (processLinkTap(x, y)) { return true; } } if (processActionTap(type, x, y)) { return true; } return processToggleFullscreenTap(); } private boolean processToggleFullscreenTap() { if (AppSettings.current().tapTogglesFullscreen) { AppSettings.toggleFullScreen(); // set title (toolbar) visibility to match fullscreen state AppSettings.setTitleVisibility(!AppSettings.current().fullScreen); return true; } return false; } protected boolean processActionTap(final TouchManager.Touch type, final float x, final float y) { final Integer actionId = TouchManager.getAction(type, x, y, getWidth(), getHeight()); final ActionEx action = actionId != null ? getOrCreateAction(actionId) : null; if (action != null) { if (LCTX.isDebugEnabled()) { LCTX.d("Touch action: " + action.name + ", " + action.getMethod().toString()); } action.addParameter(new Constant("tap_x", Float.valueOf(x))).addParameter( new Constant("tap_y", Float.valueOf(y))); action.run(); return true; } else { if (LCTX.isDebugEnabled()) { LCTX.d("Touch action not found"); } } return false; } protected final boolean processLinkTap(final float x, final float y) { final float threshold_px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, LINK_TAP_THRESHOLD_DP, base.getContext().getResources().getDisplayMetrics()); final float zoom = base.getZoomModel().getZoom(); final RectF rect = new RectF(x, y, x, y); rect.offset(getScrollX(), getScrollY()); final PageIterator pages = model.getPages(firstVisiblePage, lastVisiblePage + 1); try { final RectF bounds = new RectF(); for (final Page page : pages) { page.getBounds(zoom, bounds); if (RectF.intersects(bounds, rect)) { if (LengthUtils.isNotEmpty(page.links)) { for (final PageLink link : page.links) { if (processLinkTap(page, link, bounds, rect)) { return true; } } // Didn't tap exactly within any link. Try enlarging the tap rectangle rect.inset(-threshold_px, -threshold_px); for (final PageLink link : page.links) { if (processLinkTap(page, link, bounds, rect)) { return true; } } } return false; } } } finally { pages.release(); } return false; } protected final boolean processLinkTap(final Page page, final PageLink link, final RectF pageBounds, final RectF tapRect) { final RectF linkRect = page.getLinkSourceRect(pageBounds, link); if (linkRect == null || !RectF.intersects(linkRect, tapRect)) { return false; } if (LCTX.isDebugEnabled()) { LCTX.d("Page link found under tap: " + link); } if (link.url != null) { goToURL(link.url); return true; } goToLink(link.targetPage, link.targetRect, AppSettings.current().storeLinkGotoHistory); return true; } private void goToURL(String url) { Context ctx = base.getContext(); Uri parsed = Uri.parse(url); Intent intent = new Intent(Intent.ACTION_VIEW, parsed); if (intent.resolveActivity(ctx.getPackageManager()) != null) { ctx.startActivity(intent); } } /** * {@inheritDoc} * * @see org.ebookdroid.ui.viewer.IViewController#goToLink(int, android.graphics.RectF) */ @Override public void goToLink(final int pageDocIndex, final RectF targetRect, final boolean addToHistory) { if (pageDocIndex >= 0) { final PointF linkPoint = new PointF(); final Page target = model.getLinkTargetPage(pageDocIndex, targetRect, linkPoint, base.getBookSettings().splitRTL); if (LCTX.isDebugEnabled()) { LCTX.d("Target page found: " + target); } if (target != null) { base.jumpToPage(target.index.viewIndex, linkPoint.x, linkPoint.y, addToHistory); } } } protected class GestureListener extends SimpleOnGestureListener implements IMultiTouchListener { protected final LogContext LCTX = LogManager.root().lctx("Gesture", false); private boolean ignoreNextTap; /** * {@inheritDoc} * * @see android.view.GestureDetector.SimpleOnGestureListener#onDoubleTap(android.view.MotionEvent) */ @Override public boolean onDoubleTap(final MotionEvent e) { if (LCTX.isDebugEnabled()) { LCTX.d("onDoubleTap(" + e + ")"); } return processTap(TouchManager.Touch.DoubleTap, e); } /** * {@inheritDoc} * * @see android.view.GestureDetector.SimpleOnGestureListener#onDown(android.view.MotionEvent) */ @Override public boolean onDown(final MotionEvent e) { if (getView().forceFinishScroll()) { // this touch down caused scrolling to finish, so ignore the next onSingleTapConfirmed() ignoreNextTap = true; } else { ignoreNextTap = false; } if (LCTX.isDebugEnabled()) { LCTX.d("onDown(" + e + ")"); } return true; } /** * {@inheritDoc} * * @see android.view.GestureDetector.SimpleOnGestureListener#onFling(android.view.MotionEvent, * android.view.MotionEvent, float, float) */ @Override public boolean onFling(final MotionEvent e1, final MotionEvent e2, final float vX, final float vY) { final Rect l = getScrollLimits(); float x = vX, y = vY; if (Math.abs(vX / vY) < 0.5) { x = 0; } if (Math.abs(vY / vX) < 0.5) { y = 0; } if (LCTX.isDebugEnabled()) { LCTX.d("onFling(" + x + ", " + y + ")"); } getView().startFling(x, y, l); getView().redrawView(); return true; } /** * {@inheritDoc} * * @see android.view.GestureDetector.SimpleOnGestureListener#onScroll(android.view.MotionEvent, * android.view.MotionEvent, float, float) */ @Override public boolean onScroll(final MotionEvent e1, final MotionEvent e2, final float distanceX, final float distanceY) { float x = distanceX, y = distanceY; if (Math.abs(distanceX / distanceY) < 0.5) { x = 0; } if (Math.abs(distanceY / distanceX) < 0.5) { y = 0; } if (LCTX.isDebugEnabled()) { LCTX.d("onScroll(" + x + ", " + y + ")"); } getView().scrollBy((int) x, (int) y); return true; } /** * {@inheritDoc} * * @see android.view.GestureDetector.SimpleOnGestureListener#onSingleTapUp(android.view.MotionEvent) */ @Override public boolean onSingleTapUp(final MotionEvent e) { if (LCTX.isDebugEnabled()) { LCTX.d("onSingleTapUp(" + e + ")"); } return true; } /** * {@inheritDoc} * * @see android.view.GestureDetector.SimpleOnGestureListener#onSingleTapConfirmed(android.view.MotionEvent) */ @Override public boolean onSingleTapConfirmed(final MotionEvent e) { if (LCTX.isDebugEnabled()) { LCTX.d("onSingleTapConfirmed(" + e + ")"); } if (ignoreNextTap) { ignoreNextTap = false; return false; } return processTap(TouchManager.Touch.SingleTap, e); } /** * {@inheritDoc} * * @see android.view.GestureDetector.SimpleOnGestureListener#onLongPress(android.view.MotionEvent) */ @Override public void onLongPress(final MotionEvent e) { if (LCTX.isDebugEnabled()) { LCTX.d("onLongPress(" + e + ")"); } // LongTap operation cause side-effects // processTap(TouchManager.Touch.LongTap, e); } /** * {@inheritDoc} * * @see org.ebookdroid.common.touch.IMultiTouchListener#onTwoFingerPinch(float, float) */ @Override public void onTwoFingerPinch(final MotionEvent e, final float oldDistance, final float newDistance) { final float factor = (float) Math.sqrt(newDistance / oldDistance); if (LCTX.isDebugEnabled()) { LCTX.d("onTwoFingerPinch(" + oldDistance + ", " + newDistance + "): " + factor); } PointF center = new PointF(e.getX(), e.getY()); base.getZoomModel().scaleZoom(factor, center); } /** * {@inheritDoc} * * @see org.ebookdroid.common.touch.IMultiTouchListener#onTwoFingerPinchEnd() */ @Override public void onTwoFingerPinchEnd(final MotionEvent e) { if (LCTX.isDebugEnabled()) { LCTX.d("onTwoFingerPinch(" + e + ")"); } base.getZoomModel().commit(); } /** * {@inheritDoc} * * @see org.ebookdroid.common.touch.IMultiTouchListener#onTwoFingerTap() */ @Override public void onTwoFingerTap(final MotionEvent e) { if (LCTX.isDebugEnabled()) { LCTX.d("onTwoFingerTap(" + e + ")"); } processTap(TouchManager.Touch.TwoFingerTap, e); } } }