mobile/android/base/gfx/JavaPanZoomController.java

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/mobile/android/base/gfx/JavaPanZoomController.java	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,1461 @@
     1.4 +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
     1.5 + * This Source Code Form is subject to the terms of the Mozilla Public
     1.6 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.7 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.8 +
     1.9 +package org.mozilla.gecko.gfx;
    1.10 +
    1.11 +import org.mozilla.gecko.GeckoAppShell;
    1.12 +import org.mozilla.gecko.GeckoEvent;
    1.13 +import org.mozilla.gecko.PrefsHelper;
    1.14 +import org.mozilla.gecko.Tab;
    1.15 +import org.mozilla.gecko.Tabs;
    1.16 +import org.mozilla.gecko.ZoomConstraints;
    1.17 +import org.mozilla.gecko.EventDispatcher;
    1.18 +import org.mozilla.gecko.util.FloatUtils;
    1.19 +import org.mozilla.gecko.util.GamepadUtils;
    1.20 +import org.mozilla.gecko.util.GeckoEventListener;
    1.21 +import org.mozilla.gecko.util.ThreadUtils;
    1.22 +
    1.23 +import org.json.JSONObject;
    1.24 +
    1.25 +import android.graphics.PointF;
    1.26 +import android.graphics.RectF;
    1.27 +import android.os.Build;
    1.28 +import android.util.FloatMath;
    1.29 +import android.util.Log;
    1.30 +import android.view.GestureDetector;
    1.31 +import android.view.InputDevice;
    1.32 +import android.view.KeyEvent;
    1.33 +import android.view.MotionEvent;
    1.34 +import android.view.View;
    1.35 +
    1.36 +/*
    1.37 + * Handles the kinetic scrolling and zooming physics for a layer controller.
    1.38 + *
    1.39 + * Many ideas are from Joe Hewitt's Scrollability:
    1.40 + *   https://github.com/joehewitt/scrollability/
    1.41 + */
    1.42 +class JavaPanZoomController
    1.43 +    extends GestureDetector.SimpleOnGestureListener
    1.44 +    implements PanZoomController, SimpleScaleGestureDetector.SimpleScaleGestureListener, GeckoEventListener
    1.45 +{
    1.46 +    private static final String LOGTAG = "GeckoPanZoomController";
    1.47 +
    1.48 +    private static String MESSAGE_ZOOM_RECT = "Browser:ZoomToRect";
    1.49 +    private static String MESSAGE_ZOOM_PAGE = "Browser:ZoomToPageWidth";
    1.50 +    private static String MESSAGE_TOUCH_LISTENER = "Tab:HasTouchListener";
    1.51 +
    1.52 +    // Animation stops if the velocity is below this value when overscrolled or panning.
    1.53 +    private static final float STOPPED_THRESHOLD = 4.0f;
    1.54 +
    1.55 +    // Animation stops is the velocity is below this threshold when flinging.
    1.56 +    private static final float FLING_STOPPED_THRESHOLD = 0.1f;
    1.57 +
    1.58 +    // The distance the user has to pan before we recognize it as such (e.g. to avoid 1-pixel pans
    1.59 +    // between the touch-down and touch-up of a click). In units of density-independent pixels.
    1.60 +    public static final float PAN_THRESHOLD = 1/16f * GeckoAppShell.getDpi();
    1.61 +
    1.62 +    // Angle from axis within which we stay axis-locked
    1.63 +    private static final double AXIS_LOCK_ANGLE = Math.PI / 6.0; // 30 degrees
    1.64 +
    1.65 +    // Axis-lock breakout angle
    1.66 +    private static final double AXIS_BREAKOUT_ANGLE = Math.PI / 8.0;
    1.67 +
    1.68 +    // The distance the user has to pan before we consider breaking out of a locked axis
    1.69 +    public static final float AXIS_BREAKOUT_THRESHOLD = 1/32f * GeckoAppShell.getDpi();
    1.70 +
    1.71 +    // The maximum amount we allow you to zoom into a page
    1.72 +    private static final float MAX_ZOOM = 4.0f;
    1.73 +
    1.74 +    // The maximum amount we would like to scroll with the mouse
    1.75 +    private static final float MAX_SCROLL = 0.075f * GeckoAppShell.getDpi();
    1.76 +
    1.77 +    // The maximum zoom factor adjustment per frame of the AUTONAV animation
    1.78 +    private static final float MAX_ZOOM_DELTA = 0.125f;
    1.79 +
    1.80 +    // The duration of the bounce animation in ns
    1.81 +    private static final int BOUNCE_ANIMATION_DURATION = 250000000;
    1.82 +
    1.83 +    private enum PanZoomState {
    1.84 +        NOTHING,                /* no touch-start events received */
    1.85 +        FLING,                  /* all touches removed, but we're still scrolling page */
    1.86 +        TOUCHING,               /* one touch-start event received */
    1.87 +        PANNING_LOCKED_X,       /* touch-start followed by move (i.e. panning with axis lock) X axis */
    1.88 +        PANNING_LOCKED_Y,       /* as above for Y axis */
    1.89 +        PANNING,                /* panning without axis lock */
    1.90 +        PANNING_HOLD,           /* in panning, but not moving.
    1.91 +                                 * similar to TOUCHING but after starting a pan */
    1.92 +        PANNING_HOLD_LOCKED_X,  /* like PANNING_HOLD, but axis lock still in effect for X axis */
    1.93 +        PANNING_HOLD_LOCKED_Y,  /* as above but for Y axis */
    1.94 +        PINCHING,               /* nth touch-start, where n > 1. this mode allows pan and zoom */
    1.95 +        ANIMATED_ZOOM,          /* animated zoom to a new rect */
    1.96 +        BOUNCE,                 /* in a bounce animation */
    1.97 +        WAITING_LISTENERS,      /* a state halfway between NOTHING and TOUCHING - the user has
    1.98 +                                   put a finger down, but we don't yet know if a touch listener has
    1.99 +                                   prevented the default actions yet. we still need to abort animations. */
   1.100 +        AUTONAV,                /* We are scrolling using an AutonavRunnable animation. This is similar
   1.101 +                                   to the FLING state except that it must be stopped manually by the code that
   1.102 +                                   started it, and it's velocity can be updated while it's running. */
   1.103 +    }
   1.104 +
   1.105 +    private enum AxisLockMode {
   1.106 +        STANDARD,       /* Default axis locking mode that doesn't break out until finger release */
   1.107 +        FREE,           /* No locking at all */
   1.108 +        STICKY          /* Break out with hysteresis so that it feels as free as possible whilst locking */
   1.109 +    }
   1.110 +
   1.111 +    private final PanZoomTarget mTarget;
   1.112 +    private final SubdocumentScrollHelper mSubscroller;
   1.113 +    private final Axis mX;
   1.114 +    private final Axis mY;
   1.115 +    private final TouchEventHandler mTouchEventHandler;
   1.116 +    private final EventDispatcher mEventDispatcher;
   1.117 +
   1.118 +    /* The task that handles flings, autonav or bounces. */
   1.119 +    private PanZoomRenderTask mAnimationRenderTask;
   1.120 +    /* The zoom focus at the first zoom event (in page coordinates). */
   1.121 +    private PointF mLastZoomFocus;
   1.122 +    /* The time the last motion event took place. */
   1.123 +    private long mLastEventTime;
   1.124 +    /* Current state the pan/zoom UI is in. */
   1.125 +    private PanZoomState mState;
   1.126 +    /* The per-frame zoom delta for the currently-running AUTONAV animation. */
   1.127 +    private float mAutonavZoomDelta;
   1.128 +    /* The user selected panning mode */
   1.129 +    private AxisLockMode mMode;
   1.130 +    /* A medium-length tap/press is happening */
   1.131 +    private boolean mMediumPress;
   1.132 +    /* Used to change the scrollY direction */
   1.133 +    private boolean mNegateWheelScrollY;
   1.134 +    /* Whether the current event has been default-prevented. */
   1.135 +    private boolean mDefaultPrevented;
   1.136 +
   1.137 +    // Handler to be notified when overscroll occurs
   1.138 +    private Overscroll mOverscroll;
   1.139 +
   1.140 +    public JavaPanZoomController(PanZoomTarget target, View view, EventDispatcher eventDispatcher) {
   1.141 +        mTarget = target;
   1.142 +        mSubscroller = new SubdocumentScrollHelper(eventDispatcher);
   1.143 +        mX = new AxisX(mSubscroller);
   1.144 +        mY = new AxisY(mSubscroller);
   1.145 +        mTouchEventHandler = new TouchEventHandler(view.getContext(), view, this);
   1.146 +
   1.147 +        checkMainThread();
   1.148 +
   1.149 +        setState(PanZoomState.NOTHING);
   1.150 +
   1.151 +        mEventDispatcher = eventDispatcher;
   1.152 +        registerEventListener(MESSAGE_ZOOM_RECT);
   1.153 +        registerEventListener(MESSAGE_ZOOM_PAGE);
   1.154 +        registerEventListener(MESSAGE_TOUCH_LISTENER);
   1.155 +
   1.156 +        mMode = AxisLockMode.STANDARD;
   1.157 +
   1.158 +        String[] prefs = { "ui.scrolling.axis_lock_mode",
   1.159 +                           "ui.scrolling.negate_wheel_scrollY",
   1.160 +                           "ui.scrolling.gamepad_dead_zone" };
   1.161 +        mNegateWheelScrollY = false;
   1.162 +        PrefsHelper.getPrefs(prefs, new PrefsHelper.PrefHandlerBase() {
   1.163 +            @Override public void prefValue(String pref, String value) {
   1.164 +                if (pref.equals("ui.scrolling.axis_lock_mode")) {
   1.165 +                    if (value.equals("standard")) {
   1.166 +                        mMode = AxisLockMode.STANDARD;
   1.167 +                    } else if (value.equals("free")) {
   1.168 +                        mMode = AxisLockMode.FREE;
   1.169 +                    } else {
   1.170 +                        mMode = AxisLockMode.STICKY;
   1.171 +                    }
   1.172 +                }
   1.173 +            }
   1.174 +
   1.175 +            @Override public void prefValue(String pref, int value) {
   1.176 +                if (pref.equals("ui.scrolling.gamepad_dead_zone")) {
   1.177 +                    GamepadUtils.overrideDeadZoneThreshold((float)value / 1000f);
   1.178 +                }
   1.179 +            }
   1.180 +
   1.181 +            @Override public void prefValue(String pref, boolean value) {
   1.182 +                if (pref.equals("ui.scrolling.negate_wheel_scrollY")) {
   1.183 +                    mNegateWheelScrollY = value;
   1.184 +                }
   1.185 +            }
   1.186 +
   1.187 +            @Override
   1.188 +            public boolean isObserver() {
   1.189 +                return true;
   1.190 +            }
   1.191 +
   1.192 +        });
   1.193 +
   1.194 +        Axis.initPrefs();
   1.195 +    }
   1.196 +
   1.197 +    @Override
   1.198 +    public void destroy() {
   1.199 +        unregisterEventListener(MESSAGE_ZOOM_RECT);
   1.200 +        unregisterEventListener(MESSAGE_ZOOM_PAGE);
   1.201 +        unregisterEventListener(MESSAGE_TOUCH_LISTENER);
   1.202 +        mSubscroller.destroy();
   1.203 +        mTouchEventHandler.destroy();
   1.204 +    }
   1.205 +
   1.206 +    private final static float easeOut(float t) {
   1.207 +        // ease-out approx.
   1.208 +        // -(t-1)^2+1
   1.209 +        t = t-1;
   1.210 +        return -t*t+1;
   1.211 +    }
   1.212 +
   1.213 +    private void registerEventListener(String event) {
   1.214 +        mEventDispatcher.registerEventListener(event, this);
   1.215 +    }
   1.216 +
   1.217 +    private void unregisterEventListener(String event) {
   1.218 +        mEventDispatcher.unregisterEventListener(event, this);
   1.219 +    }
   1.220 +
   1.221 +    private void setState(PanZoomState state) {
   1.222 +        if (state != mState) {
   1.223 +            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("PanZoom:StateChange", state.toString()));
   1.224 +            mState = state;
   1.225 +
   1.226 +            // Let the target know we've finished with it (for now)
   1.227 +            if (state == PanZoomState.NOTHING) {
   1.228 +                mTarget.panZoomStopped();
   1.229 +            }
   1.230 +        }
   1.231 +    }
   1.232 +
   1.233 +    private ImmutableViewportMetrics getMetrics() {
   1.234 +        return mTarget.getViewportMetrics();
   1.235 +    }
   1.236 +
   1.237 +    private void checkMainThread() {
   1.238 +        if (!ThreadUtils.isOnUiThread()) {
   1.239 +            // log with full stack trace
   1.240 +            Log.e(LOGTAG, "Uh-oh, we're running on the wrong thread!", new Exception());
   1.241 +        }
   1.242 +    }
   1.243 +
   1.244 +    @Override
   1.245 +    public void handleMessage(String event, JSONObject message) {
   1.246 +        try {
   1.247 +            if (MESSAGE_ZOOM_RECT.equals(event)) {
   1.248 +                float x = (float)message.getDouble("x");
   1.249 +                float y = (float)message.getDouble("y");
   1.250 +                final RectF zoomRect = new RectF(x, y,
   1.251 +                                     x + (float)message.getDouble("w"),
   1.252 +                                     y + (float)message.getDouble("h"));
   1.253 +                if (message.optBoolean("animate", true)) {
   1.254 +                    mTarget.post(new Runnable() {
   1.255 +                        @Override
   1.256 +                        public void run() {
   1.257 +                            animatedZoomTo(zoomRect);
   1.258 +                        }
   1.259 +                    });
   1.260 +                } else {
   1.261 +                    mTarget.setViewportMetrics(getMetricsToZoomTo(zoomRect));
   1.262 +                }
   1.263 +            } else if (MESSAGE_ZOOM_PAGE.equals(event)) {
   1.264 +                ImmutableViewportMetrics metrics = getMetrics();
   1.265 +                RectF cssPageRect = metrics.getCssPageRect();
   1.266 +
   1.267 +                RectF viewableRect = metrics.getCssViewport();
   1.268 +                float y = viewableRect.top;
   1.269 +                // attempt to keep zoom keep focused on the center of the viewport
   1.270 +                float newHeight = viewableRect.height() * cssPageRect.width() / viewableRect.width();
   1.271 +                float dh = viewableRect.height() - newHeight; // increase in the height
   1.272 +                final RectF r = new RectF(0.0f,
   1.273 +                                    y + dh/2,
   1.274 +                                    cssPageRect.width(),
   1.275 +                                    y + dh/2 + newHeight);
   1.276 +                if (message.optBoolean("animate", true)) {
   1.277 +                    mTarget.post(new Runnable() {
   1.278 +                        @Override
   1.279 +                        public void run() {
   1.280 +                            animatedZoomTo(r);
   1.281 +                        }
   1.282 +                    });
   1.283 +                } else {
   1.284 +                    mTarget.setViewportMetrics(getMetricsToZoomTo(r));
   1.285 +                }
   1.286 +            } else if (MESSAGE_TOUCH_LISTENER.equals(event)) {
   1.287 +                int tabId = message.getInt("tabID");
   1.288 +                final Tab tab = Tabs.getInstance().getTab(tabId);
   1.289 +                tab.setHasTouchListeners(true);
   1.290 +                mTarget.post(new Runnable() {
   1.291 +                    @Override
   1.292 +                    public void run() {
   1.293 +                        if (Tabs.getInstance().isSelectedTab(tab))
   1.294 +                            mTouchEventHandler.setWaitForTouchListeners(true);
   1.295 +                    }
   1.296 +                });
   1.297 +            }
   1.298 +        } catch (Exception e) {
   1.299 +            Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
   1.300 +        }
   1.301 +    }
   1.302 +
   1.303 +    /** This function MUST be called on the UI thread */
   1.304 +    @Override
   1.305 +    public boolean onKeyEvent(KeyEvent event) {
   1.306 +        if (Build.VERSION.SDK_INT <= 11) {
   1.307 +            return false;
   1.308 +        }
   1.309 +
   1.310 +        if ((event.getSource() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD
   1.311 +            && event.getAction() == KeyEvent.ACTION_DOWN) {
   1.312 +
   1.313 +            switch (event.getKeyCode()) {
   1.314 +            case KeyEvent.KEYCODE_ZOOM_IN:
   1.315 +                return animatedScale(0.2f);
   1.316 +            case KeyEvent.KEYCODE_ZOOM_OUT:
   1.317 +                return animatedScale(-0.2f);
   1.318 +            }
   1.319 +        }
   1.320 +        return false;
   1.321 +    }
   1.322 +
   1.323 +    /** This function MUST be called on the UI thread */
   1.324 +    @Override
   1.325 +    public boolean onMotionEvent(MotionEvent event) {
   1.326 +        if (Build.VERSION.SDK_INT <= 11) {
   1.327 +            return false;
   1.328 +        }
   1.329 +
   1.330 +        switch (event.getSource() & InputDevice.SOURCE_CLASS_MASK) {
   1.331 +        case InputDevice.SOURCE_CLASS_POINTER:
   1.332 +            switch (event.getAction() & MotionEvent.ACTION_MASK) {
   1.333 +            case MotionEvent.ACTION_SCROLL: return handlePointerScroll(event);
   1.334 +            }
   1.335 +            break;
   1.336 +        case InputDevice.SOURCE_CLASS_JOYSTICK:
   1.337 +            switch (event.getAction() & MotionEvent.ACTION_MASK) {
   1.338 +            case MotionEvent.ACTION_MOVE: return handleJoystickNav(event);
   1.339 +            }
   1.340 +            break;
   1.341 +        }
   1.342 +        return false;
   1.343 +    }
   1.344 +
   1.345 +    /** This function MUST be called on the UI thread */
   1.346 +    @Override
   1.347 +    public boolean onTouchEvent(MotionEvent event) {
   1.348 +        return mTouchEventHandler.handleEvent(event);
   1.349 +    }
   1.350 +
   1.351 +    boolean handleEvent(MotionEvent event, boolean defaultPrevented) {
   1.352 +        mDefaultPrevented = defaultPrevented;
   1.353 +
   1.354 +        switch (event.getAction() & MotionEvent.ACTION_MASK) {
   1.355 +        case MotionEvent.ACTION_DOWN:   return handleTouchStart(event);
   1.356 +        case MotionEvent.ACTION_MOVE:   return handleTouchMove(event);
   1.357 +        case MotionEvent.ACTION_UP:     return handleTouchEnd(event);
   1.358 +        case MotionEvent.ACTION_CANCEL: return handleTouchCancel(event);
   1.359 +        }
   1.360 +        return false;
   1.361 +    }
   1.362 +
   1.363 +    /** This function MUST be called on the UI thread */
   1.364 +    @Override
   1.365 +    public void notifyDefaultActionPrevented(boolean prevented) {
   1.366 +        mTouchEventHandler.handleEventListenerAction(!prevented);
   1.367 +    }
   1.368 +
   1.369 +    /** This function must be called from the UI thread. */
   1.370 +    @Override
   1.371 +    public void abortAnimation() {
   1.372 +        checkMainThread();
   1.373 +        // this happens when gecko changes the viewport on us or if the device is rotated.
   1.374 +        // if that's the case, abort any animation in progress and re-zoom so that the page
   1.375 +        // snaps to edges. for other cases (where the user's finger(s) are down) don't do
   1.376 +        // anything special.
   1.377 +        switch (mState) {
   1.378 +        case FLING:
   1.379 +            mX.stopFling();
   1.380 +            mY.stopFling();
   1.381 +            // fall through
   1.382 +        case BOUNCE:
   1.383 +        case ANIMATED_ZOOM:
   1.384 +            // the zoom that's in progress likely makes no sense any more (such as if
   1.385 +            // the screen orientation changed) so abort it
   1.386 +            setState(PanZoomState.NOTHING);
   1.387 +            // fall through
   1.388 +        case NOTHING:
   1.389 +            // Don't do animations here; they're distracting and can cause flashes on page
   1.390 +            // transitions.
   1.391 +            synchronized (mTarget.getLock()) {
   1.392 +                mTarget.setViewportMetrics(getValidViewportMetrics());
   1.393 +                mTarget.forceRedraw(null);
   1.394 +            }
   1.395 +            break;
   1.396 +        }
   1.397 +    }
   1.398 +
   1.399 +    /** This function must be called on the UI thread. */
   1.400 +    public void startingNewEventBlock(MotionEvent event, boolean waitingForTouchListeners) {
   1.401 +        checkMainThread();
   1.402 +        mSubscroller.cancel();
   1.403 +        if (waitingForTouchListeners && (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN) {
   1.404 +            // this is the first touch point going down, so we enter the pending state
   1.405 +            // seting the state will kill any animations in progress, possibly leaving
   1.406 +            // the page in overscroll
   1.407 +            setState(PanZoomState.WAITING_LISTENERS);
   1.408 +        }
   1.409 +    }
   1.410 +
   1.411 +    /** This must be called on the UI thread. */
   1.412 +    @Override
   1.413 +    public void pageRectUpdated() {
   1.414 +        if (mState == PanZoomState.NOTHING) {
   1.415 +            synchronized (mTarget.getLock()) {
   1.416 +                ImmutableViewportMetrics validated = getValidViewportMetrics();
   1.417 +                if (!getMetrics().fuzzyEquals(validated)) {
   1.418 +                    // page size changed such that we are now in overscroll. snap to the
   1.419 +                    // the nearest valid viewport
   1.420 +                    mTarget.setViewportMetrics(validated);
   1.421 +                }
   1.422 +            }
   1.423 +        }
   1.424 +    }
   1.425 +
   1.426 +    /*
   1.427 +     * Panning/scrolling
   1.428 +     */
   1.429 +
   1.430 +    private boolean handleTouchStart(MotionEvent event) {
   1.431 +        // user is taking control of movement, so stop
   1.432 +        // any auto-movement we have going
   1.433 +        stopAnimationTask();
   1.434 +
   1.435 +        switch (mState) {
   1.436 +        case ANIMATED_ZOOM:
   1.437 +            // We just interrupted a double-tap animation, so force a redraw in
   1.438 +            // case this touchstart is just a tap that doesn't end up triggering
   1.439 +            // a redraw
   1.440 +            mTarget.forceRedraw(null);
   1.441 +            // fall through
   1.442 +        case FLING:
   1.443 +        case AUTONAV:
   1.444 +        case BOUNCE:
   1.445 +        case NOTHING:
   1.446 +        case WAITING_LISTENERS:
   1.447 +            startTouch(event.getX(0), event.getY(0), event.getEventTime());
   1.448 +            return false;
   1.449 +        case TOUCHING:
   1.450 +        case PANNING:
   1.451 +        case PANNING_LOCKED_X:
   1.452 +        case PANNING_LOCKED_Y:
   1.453 +        case PANNING_HOLD:
   1.454 +        case PANNING_HOLD_LOCKED_X:
   1.455 +        case PANNING_HOLD_LOCKED_Y:
   1.456 +        case PINCHING:
   1.457 +            Log.e(LOGTAG, "Received impossible touch down while in " + mState);
   1.458 +            return false;
   1.459 +        }
   1.460 +        Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchStart");
   1.461 +        return false;
   1.462 +    }
   1.463 +
   1.464 +    private boolean handleTouchMove(MotionEvent event) {
   1.465 +
   1.466 +        switch (mState) {
   1.467 +        case FLING:
   1.468 +        case AUTONAV:
   1.469 +        case BOUNCE:
   1.470 +        case WAITING_LISTENERS:
   1.471 +            // should never happen
   1.472 +            Log.e(LOGTAG, "Received impossible touch move while in " + mState);
   1.473 +            // fall through
   1.474 +        case ANIMATED_ZOOM:
   1.475 +        case NOTHING:
   1.476 +            // may happen if user double-taps and drags without lifting after the
   1.477 +            // second tap. ignore the move if this happens.
   1.478 +            return false;
   1.479 +
   1.480 +        case TOUCHING:
   1.481 +            // Don't allow panning if there is an element in full-screen mode. See bug 775511.
   1.482 +            if ((mTarget.isFullScreen() && !mSubscroller.scrolling()) || panDistance(event) < PAN_THRESHOLD) {
   1.483 +                return false;
   1.484 +            }
   1.485 +            cancelTouch();
   1.486 +            startPanning(event.getX(0), event.getY(0), event.getEventTime());
   1.487 +            track(event);
   1.488 +            return true;
   1.489 +
   1.490 +        case PANNING_HOLD_LOCKED_X:
   1.491 +            setState(PanZoomState.PANNING_LOCKED_X);
   1.492 +            track(event);
   1.493 +            return true;
   1.494 +        case PANNING_HOLD_LOCKED_Y:
   1.495 +            setState(PanZoomState.PANNING_LOCKED_Y);
   1.496 +            // fall through
   1.497 +        case PANNING_LOCKED_X:
   1.498 +        case PANNING_LOCKED_Y:
   1.499 +            track(event);
   1.500 +            return true;
   1.501 +
   1.502 +        case PANNING_HOLD:
   1.503 +            setState(PanZoomState.PANNING);
   1.504 +            // fall through
   1.505 +        case PANNING:
   1.506 +            track(event);
   1.507 +            return true;
   1.508 +
   1.509 +        case PINCHING:
   1.510 +            // scale gesture listener will handle this
   1.511 +            return false;
   1.512 +        }
   1.513 +        Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchMove");
   1.514 +        return false;
   1.515 +    }
   1.516 +
   1.517 +    private boolean handleTouchEnd(MotionEvent event) {
   1.518 +
   1.519 +        switch (mState) {
   1.520 +        case FLING:
   1.521 +        case AUTONAV:
   1.522 +        case BOUNCE:
   1.523 +        case ANIMATED_ZOOM:
   1.524 +        case NOTHING:
   1.525 +            // may happen if user double-taps and drags without lifting after the
   1.526 +            // second tap. ignore if this happens.
   1.527 +            return false;
   1.528 +
   1.529 +        case WAITING_LISTENERS:
   1.530 +            if (!mDefaultPrevented) {
   1.531 +              // should never happen
   1.532 +              Log.e(LOGTAG, "Received impossible touch end while in " + mState);
   1.533 +            }
   1.534 +            // fall through
   1.535 +        case TOUCHING:
   1.536 +            // the switch into TOUCHING might have happened while the page was
   1.537 +            // snapping back after overscroll. we need to finish the snap if that
   1.538 +            // was the case
   1.539 +            bounce();
   1.540 +            return false;
   1.541 +
   1.542 +        case PANNING:
   1.543 +        case PANNING_LOCKED_X:
   1.544 +        case PANNING_LOCKED_Y:
   1.545 +        case PANNING_HOLD:
   1.546 +        case PANNING_HOLD_LOCKED_X:
   1.547 +        case PANNING_HOLD_LOCKED_Y:
   1.548 +            setState(PanZoomState.FLING);
   1.549 +            fling();
   1.550 +            return true;
   1.551 +
   1.552 +        case PINCHING:
   1.553 +            setState(PanZoomState.NOTHING);
   1.554 +            return true;
   1.555 +        }
   1.556 +        Log.e(LOGTAG, "Unhandled case " + mState + " in handleTouchEnd");
   1.557 +        return false;
   1.558 +    }
   1.559 +
   1.560 +    private boolean handleTouchCancel(MotionEvent event) {
   1.561 +        cancelTouch();
   1.562 +
   1.563 +        // ensure we snap back if we're overscrolled
   1.564 +        bounce();
   1.565 +        return false;
   1.566 +    }
   1.567 +
   1.568 +    private boolean handlePointerScroll(MotionEvent event) {
   1.569 +        if (mState == PanZoomState.NOTHING || mState == PanZoomState.FLING) {
   1.570 +            float scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL);
   1.571 +            float scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
   1.572 +            if (mNegateWheelScrollY) {
   1.573 +                scrollY *= -1.0;
   1.574 +            }
   1.575 +            scrollBy(scrollX * MAX_SCROLL, scrollY * MAX_SCROLL);
   1.576 +            bounce();
   1.577 +            return true;
   1.578 +        }
   1.579 +        return false;
   1.580 +    }
   1.581 +
   1.582 +    private float filterDeadZone(MotionEvent event, int axis) {
   1.583 +        return (GamepadUtils.isValueInDeadZone(event, axis) ? 0 : event.getAxisValue(axis));
   1.584 +    }
   1.585 +
   1.586 +    private float normalizeJoystickScroll(MotionEvent event, int axis) {
   1.587 +        return filterDeadZone(event, axis) * MAX_SCROLL;
   1.588 +    }
   1.589 +
   1.590 +    private float normalizeJoystickZoom(MotionEvent event, int axis) {
   1.591 +        // negate MAX_ZOOM_DELTA so that pushing up on the stick zooms in
   1.592 +        return filterDeadZone(event, axis) * -MAX_ZOOM_DELTA;
   1.593 +    }
   1.594 +
   1.595 +    // Since this event is a position-based event rather than a motion-based event, we need to
   1.596 +    // set up an AUTONAV animation to keep scrolling even while we don't get events.
   1.597 +    private boolean handleJoystickNav(MotionEvent event) {
   1.598 +        float velocityX = normalizeJoystickScroll(event, MotionEvent.AXIS_X);
   1.599 +        float velocityY = normalizeJoystickScroll(event, MotionEvent.AXIS_Y);
   1.600 +        float zoomDelta = normalizeJoystickZoom(event, MotionEvent.AXIS_RZ);
   1.601 +
   1.602 +        if (velocityX == 0 && velocityY == 0 && zoomDelta == 0) {
   1.603 +            if (mState == PanZoomState.AUTONAV) {
   1.604 +                bounce(); // if not needed, this will automatically go to state NOTHING
   1.605 +                return true;
   1.606 +            }
   1.607 +            return false;
   1.608 +        }
   1.609 +
   1.610 +        if (mState == PanZoomState.NOTHING) {
   1.611 +            setState(PanZoomState.AUTONAV);
   1.612 +            startAnimationRenderTask(new AutonavRenderTask());
   1.613 +        }
   1.614 +        if (mState == PanZoomState.AUTONAV) {
   1.615 +            mX.setAutoscrollVelocity(velocityX);
   1.616 +            mY.setAutoscrollVelocity(velocityY);
   1.617 +            mAutonavZoomDelta = zoomDelta;
   1.618 +            return true;
   1.619 +        }
   1.620 +        return false;
   1.621 +    }
   1.622 +
   1.623 +    private void startTouch(float x, float y, long time) {
   1.624 +        mX.startTouch(x);
   1.625 +        mY.startTouch(y);
   1.626 +        setState(PanZoomState.TOUCHING);
   1.627 +        mLastEventTime = time;
   1.628 +    }
   1.629 +
   1.630 +    private void startPanning(float x, float y, long time) {
   1.631 +        float dx = mX.panDistance(x);
   1.632 +        float dy = mY.panDistance(y);
   1.633 +        double angle = Math.atan2(dy, dx); // range [-pi, pi]
   1.634 +        angle = Math.abs(angle); // range [0, pi]
   1.635 +
   1.636 +        // When the touch move breaks through the pan threshold, reposition the touch down origin
   1.637 +        // so the page won't jump when we start panning.
   1.638 +        mX.startTouch(x);
   1.639 +        mY.startTouch(y);
   1.640 +        mLastEventTime = time;
   1.641 +
   1.642 +        if (mMode == AxisLockMode.STANDARD || mMode == AxisLockMode.STICKY) {
   1.643 +            if (!mX.scrollable() || !mY.scrollable()) {
   1.644 +                setState(PanZoomState.PANNING);
   1.645 +            } else if (angle < AXIS_LOCK_ANGLE || angle > (Math.PI - AXIS_LOCK_ANGLE)) {
   1.646 +                mY.setScrollingDisabled(true);
   1.647 +                setState(PanZoomState.PANNING_LOCKED_X);
   1.648 +            } else if (Math.abs(angle - (Math.PI / 2)) < AXIS_LOCK_ANGLE) {
   1.649 +                mX.setScrollingDisabled(true);
   1.650 +                setState(PanZoomState.PANNING_LOCKED_Y);
   1.651 +            } else {
   1.652 +                setState(PanZoomState.PANNING);
   1.653 +            }
   1.654 +        } else if (mMode == AxisLockMode.FREE) {
   1.655 +            setState(PanZoomState.PANNING);
   1.656 +        }
   1.657 +    }
   1.658 +
   1.659 +    private float panDistance(MotionEvent move) {
   1.660 +        float dx = mX.panDistance(move.getX(0));
   1.661 +        float dy = mY.panDistance(move.getY(0));
   1.662 +        return FloatMath.sqrt(dx * dx + dy * dy);
   1.663 +    }
   1.664 +
   1.665 +    private void track(float x, float y, long time) {
   1.666 +        float timeDelta = (float)(time - mLastEventTime);
   1.667 +        if (FloatUtils.fuzzyEquals(timeDelta, 0)) {
   1.668 +            // probably a duplicate event, ignore it. using a zero timeDelta will mess
   1.669 +            // up our velocity
   1.670 +            return;
   1.671 +        }
   1.672 +        mLastEventTime = time;
   1.673 +
   1.674 +
   1.675 +        // if we're axis-locked check if the user is trying to scroll away from the lock
   1.676 +        if (mMode == AxisLockMode.STICKY) {
   1.677 +            float dx = mX.panDistance(x);
   1.678 +            float dy = mY.panDistance(y);
   1.679 +            double angle = Math.atan2(dy, dx); // range [-pi, pi]
   1.680 +            angle = Math.abs(angle); // range [0, pi]
   1.681 +
   1.682 +            if (Math.abs(dx) > AXIS_BREAKOUT_THRESHOLD || Math.abs(dy) > AXIS_BREAKOUT_THRESHOLD) {
   1.683 +                if (mState == PanZoomState.PANNING_LOCKED_X) {
   1.684 +                    if (angle > AXIS_BREAKOUT_ANGLE && angle < (Math.PI - AXIS_BREAKOUT_ANGLE)) {
   1.685 +                        mY.setScrollingDisabled(false);
   1.686 +                        setState(PanZoomState.PANNING);
   1.687 +                    }
   1.688 +                 } else if (mState == PanZoomState.PANNING_LOCKED_Y) {
   1.689 +                    if (Math.abs(angle - (Math.PI / 2)) > AXIS_BREAKOUT_ANGLE) {
   1.690 +                        mX.setScrollingDisabled(false);
   1.691 +                        setState(PanZoomState.PANNING);
   1.692 +                    }
   1.693 +                }
   1.694 +            }
   1.695 +        }
   1.696 +
   1.697 +        mX.updateWithTouchAt(x, timeDelta);
   1.698 +        mY.updateWithTouchAt(y, timeDelta);
   1.699 +    }
   1.700 +
   1.701 +    private void track(MotionEvent event) {
   1.702 +        mX.saveTouchPos();
   1.703 +        mY.saveTouchPos();
   1.704 +
   1.705 +        for (int i = 0; i < event.getHistorySize(); i++) {
   1.706 +            track(event.getHistoricalX(0, i),
   1.707 +                  event.getHistoricalY(0, i),
   1.708 +                  event.getHistoricalEventTime(i));
   1.709 +        }
   1.710 +        track(event.getX(0), event.getY(0), event.getEventTime());
   1.711 +
   1.712 +        if (stopped()) {
   1.713 +            if (mState == PanZoomState.PANNING) {
   1.714 +                setState(PanZoomState.PANNING_HOLD);
   1.715 +            } else if (mState == PanZoomState.PANNING_LOCKED_X) {
   1.716 +                setState(PanZoomState.PANNING_HOLD_LOCKED_X);
   1.717 +            } else if (mState == PanZoomState.PANNING_LOCKED_Y) {
   1.718 +                setState(PanZoomState.PANNING_HOLD_LOCKED_Y);
   1.719 +            } else {
   1.720 +                // should never happen, but handle anyway for robustness
   1.721 +                Log.e(LOGTAG, "Impossible case " + mState + " when stopped in track");
   1.722 +                setState(PanZoomState.PANNING_HOLD);
   1.723 +            }
   1.724 +        }
   1.725 +
   1.726 +        mX.startPan();
   1.727 +        mY.startPan();
   1.728 +        updatePosition();
   1.729 +    }
   1.730 +
   1.731 +    private void scrollBy(float dx, float dy) {
   1.732 +        mTarget.scrollBy(dx, dy);
   1.733 +    }
   1.734 +
   1.735 +    private void fling() {
   1.736 +        updatePosition();
   1.737 +
   1.738 +        stopAnimationTask();
   1.739 +
   1.740 +        boolean stopped = stopped();
   1.741 +        mX.startFling(stopped);
   1.742 +        mY.startFling(stopped);
   1.743 +
   1.744 +        startAnimationRenderTask(new FlingRenderTask());
   1.745 +    }
   1.746 +
   1.747 +    /* Performs a bounce-back animation to the given viewport metrics. */
   1.748 +    private void bounce(ImmutableViewportMetrics metrics, PanZoomState state) {
   1.749 +        stopAnimationTask();
   1.750 +
   1.751 +        ImmutableViewportMetrics bounceStartMetrics = getMetrics();
   1.752 +        if (bounceStartMetrics.fuzzyEquals(metrics)) {
   1.753 +            setState(PanZoomState.NOTHING);
   1.754 +            return;
   1.755 +        }
   1.756 +
   1.757 +        setState(state);
   1.758 +
   1.759 +        // At this point we have already set mState to BOUNCE or ANIMATED_ZOOM, so
   1.760 +        // getRedrawHint() is returning false. This means we can safely call
   1.761 +        // setAnimationTarget to set the new final display port and not have it get
   1.762 +        // clobbered by display ports from intermediate animation frames.
   1.763 +        mTarget.setAnimationTarget(metrics);
   1.764 +        startAnimationRenderTask(new BounceRenderTask(bounceStartMetrics, metrics));
   1.765 +    }
   1.766 +
   1.767 +    /* Performs a bounce-back animation to the nearest valid viewport metrics. */
   1.768 +    private void bounce() {
   1.769 +        bounce(getValidViewportMetrics(), PanZoomState.BOUNCE);
   1.770 +    }
   1.771 +
   1.772 +    /* Starts the fling or bounce animation. */
   1.773 +    private void startAnimationRenderTask(final PanZoomRenderTask task) {
   1.774 +        if (mAnimationRenderTask != null) {
   1.775 +            Log.e(LOGTAG, "Attempted to start a new task without canceling the old one!");
   1.776 +            stopAnimationTask();
   1.777 +        }
   1.778 +
   1.779 +        mAnimationRenderTask = task;
   1.780 +        mTarget.postRenderTask(mAnimationRenderTask);
   1.781 +    }
   1.782 +
   1.783 +    /* Stops the fling or bounce animation. */
   1.784 +    private void stopAnimationTask() {
   1.785 +        if (mAnimationRenderTask != null) {
   1.786 +            mAnimationRenderTask.terminate();
   1.787 +            mTarget.removeRenderTask(mAnimationRenderTask);
   1.788 +            mAnimationRenderTask = null;
   1.789 +        }
   1.790 +    }
   1.791 +
   1.792 +    private float getVelocity() {
   1.793 +        float xvel = mX.getRealVelocity();
   1.794 +        float yvel = mY.getRealVelocity();
   1.795 +        return FloatMath.sqrt(xvel * xvel + yvel * yvel);
   1.796 +    }
   1.797 +
   1.798 +    @Override
   1.799 +    public PointF getVelocityVector() {
   1.800 +        return new PointF(mX.getRealVelocity(), mY.getRealVelocity());
   1.801 +    }
   1.802 +
   1.803 +    private boolean stopped() {
   1.804 +        return getVelocity() < STOPPED_THRESHOLD;
   1.805 +    }
   1.806 +
   1.807 +    PointF resetDisplacement() {
   1.808 +        return new PointF(mX.resetDisplacement(), mY.resetDisplacement());
   1.809 +    }
   1.810 +
   1.811 +    private void updatePosition() {
   1.812 +        mX.displace();
   1.813 +        mY.displace();
   1.814 +        PointF displacement = resetDisplacement();
   1.815 +        if (FloatUtils.fuzzyEquals(displacement.x, 0.0f) && FloatUtils.fuzzyEquals(displacement.y, 0.0f)) {
   1.816 +            return;
   1.817 +        }
   1.818 +        if (mDefaultPrevented || mSubscroller.scrollBy(displacement)) {
   1.819 +            synchronized (mTarget.getLock()) {
   1.820 +                mTarget.scrollMarginsBy(displacement.x, displacement.y);
   1.821 +            }
   1.822 +        } else {
   1.823 +            synchronized (mTarget.getLock()) {
   1.824 +                scrollBy(displacement.x, displacement.y);
   1.825 +            }
   1.826 +        }
   1.827 +    }
   1.828 +
   1.829 +    /**
   1.830 +     * This class is an implementation of RenderTask which enforces its implementor to run in the UI thread.
   1.831 +     *
   1.832 +     */
   1.833 +    private abstract class PanZoomRenderTask extends RenderTask {
   1.834 +
   1.835 +        /**
   1.836 +         * the time when the current frame was started in ns.
   1.837 +         */
   1.838 +        protected long mCurrentFrameStartTime;
   1.839 +        /**
   1.840 +         * The current frame duration in ns.
   1.841 +         */
   1.842 +        protected long mLastFrameTimeDelta;
   1.843 +
   1.844 +        private final Runnable mRunnable = new Runnable() {
   1.845 +            @Override
   1.846 +            public final void run() {
   1.847 +                if (mContinueAnimation) {
   1.848 +                    animateFrame();
   1.849 +                }
   1.850 +            }
   1.851 +        };
   1.852 +
   1.853 +        private boolean mContinueAnimation = true;
   1.854 +
   1.855 +        public PanZoomRenderTask() {
   1.856 +            super(false);
   1.857 +        }
   1.858 +
   1.859 +        @Override
   1.860 +        protected final boolean internalRun(long timeDelta, long currentFrameStartTime) {
   1.861 +
   1.862 +            mCurrentFrameStartTime = currentFrameStartTime;
   1.863 +            mLastFrameTimeDelta = timeDelta;
   1.864 +
   1.865 +            mTarget.post(mRunnable);
   1.866 +            return mContinueAnimation;
   1.867 +        }
   1.868 +
   1.869 +        /**
   1.870 +         * The method subclasses must override. This method is run on the UI thread thanks to internalRun
   1.871 +         */
   1.872 +        protected abstract void animateFrame();
   1.873 +
   1.874 +        /**
   1.875 +         * Terminate the animation.
   1.876 +         */
   1.877 +        public void terminate() {
   1.878 +            mContinueAnimation = false;
   1.879 +        }
   1.880 +    }
   1.881 +
   1.882 +    private class AutonavRenderTask extends PanZoomRenderTask {
   1.883 +        public AutonavRenderTask() {
   1.884 +            super();
   1.885 +        }
   1.886 +
   1.887 +        @Override
   1.888 +        protected void animateFrame() {
   1.889 +            if (mState != PanZoomState.AUTONAV) {
   1.890 +                finishAnimation();
   1.891 +                return;
   1.892 +            }
   1.893 +
   1.894 +            updatePosition();
   1.895 +            synchronized (mTarget.getLock()) {
   1.896 +                mTarget.setViewportMetrics(applyZoomDelta(getMetrics(), mAutonavZoomDelta));
   1.897 +            }
   1.898 +        }
   1.899 +    }
   1.900 +
   1.901 +    /* The task that performs the bounce animation. */
   1.902 +    private class BounceRenderTask extends PanZoomRenderTask {
   1.903 +
   1.904 +        /*
   1.905 +         * The viewport metrics that represent the start and end of the bounce-back animation,
   1.906 +         * respectively.
   1.907 +         */
   1.908 +        private ImmutableViewportMetrics mBounceStartMetrics;
   1.909 +        private ImmutableViewportMetrics mBounceEndMetrics;
   1.910 +        // How long ago this bounce was started in ns.
   1.911 +        private long mBounceDuration;
   1.912 +
   1.913 +        BounceRenderTask(ImmutableViewportMetrics startMetrics, ImmutableViewportMetrics endMetrics) {
   1.914 +            super();
   1.915 +            mBounceStartMetrics = startMetrics;
   1.916 +            mBounceEndMetrics = endMetrics;
   1.917 +        }
   1.918 +
   1.919 +        @Override
   1.920 +        protected void animateFrame() {
   1.921 +            /*
   1.922 +             * The pan/zoom controller might have signaled to us that it wants to abort the
   1.923 +             * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail
   1.924 +             * out.
   1.925 +             */
   1.926 +            if (!(mState == PanZoomState.BOUNCE || mState == PanZoomState.ANIMATED_ZOOM)) {
   1.927 +                finishAnimation();
   1.928 +                return;
   1.929 +            }
   1.930 +
   1.931 +            /* Perform the next frame of the bounce-back animation. */
   1.932 +            mBounceDuration = mCurrentFrameStartTime - getStartTime();
   1.933 +            if (mBounceDuration < BOUNCE_ANIMATION_DURATION) {
   1.934 +                advanceBounce();
   1.935 +                return;
   1.936 +            }
   1.937 +
   1.938 +            /* Finally, if there's nothing else to do, complete the animation and go to sleep. */
   1.939 +            finishBounce();
   1.940 +            finishAnimation();
   1.941 +            setState(PanZoomState.NOTHING);
   1.942 +        }
   1.943 +
   1.944 +        /* Performs one frame of a bounce animation. */
   1.945 +        private void advanceBounce() {
   1.946 +            synchronized (mTarget.getLock()) {
   1.947 +                float t = easeOut((float)mBounceDuration / BOUNCE_ANIMATION_DURATION);
   1.948 +                ImmutableViewportMetrics newMetrics = mBounceStartMetrics.interpolate(mBounceEndMetrics, t);
   1.949 +                mTarget.setViewportMetrics(newMetrics);
   1.950 +            }
   1.951 +        }
   1.952 +
   1.953 +        /* Concludes a bounce animation and snaps the viewport into place. */
   1.954 +        private void finishBounce() {
   1.955 +            synchronized (mTarget.getLock()) {
   1.956 +                mTarget.setViewportMetrics(mBounceEndMetrics);
   1.957 +            }
   1.958 +        }
   1.959 +    }
   1.960 +
   1.961 +    // The callback that performs the fling animation.
   1.962 +    private class FlingRenderTask extends PanZoomRenderTask {
   1.963 +
   1.964 +        public FlingRenderTask() {
   1.965 +            super();
   1.966 +        }
   1.967 +
   1.968 +        @Override
   1.969 +        protected void animateFrame() {
   1.970 +            /*
   1.971 +             * The pan/zoom controller might have signaled to us that it wants to abort the
   1.972 +             * animation by setting the state to PanZoomState.NOTHING. Handle this case and bail
   1.973 +             * out.
   1.974 +             */
   1.975 +            if (mState != PanZoomState.FLING) {
   1.976 +                finishAnimation();
   1.977 +                return;
   1.978 +            }
   1.979 +
   1.980 +            /* Advance flings, if necessary. */
   1.981 +            boolean flingingX = mX.advanceFling(mLastFrameTimeDelta);
   1.982 +            boolean flingingY = mY.advanceFling(mLastFrameTimeDelta);
   1.983 +
   1.984 +            boolean overscrolled = (mX.overscrolled() || mY.overscrolled());
   1.985 +
   1.986 +            /* If we're still flinging in any direction, update the origin. */
   1.987 +            if (flingingX || flingingY) {
   1.988 +                updatePosition();
   1.989 +
   1.990 +                /*
   1.991 +                 * Check to see if we're still flinging with an appreciable velocity. The threshold is
   1.992 +                 * higher in the case of overscroll, so we bounce back eagerly when overscrolling but
   1.993 +                 * coast smoothly to a stop when not. In other words, require a greater velocity to
   1.994 +                 * maintain the fling once we enter overscroll.
   1.995 +                 */
   1.996 +                float threshold = (overscrolled && !mSubscroller.scrolling() ? STOPPED_THRESHOLD : FLING_STOPPED_THRESHOLD);
   1.997 +                if (getVelocity() >= threshold) {
   1.998 +                    // we're still flinging
   1.999 +                    return;
  1.1000 +                }
  1.1001 +
  1.1002 +                mX.stopFling();
  1.1003 +                mY.stopFling();
  1.1004 +            }
  1.1005 +
  1.1006 +            /* Perform a bounce-back animation if overscrolled. */
  1.1007 +            if (overscrolled) {
  1.1008 +                bounce();
  1.1009 +            } else {
  1.1010 +                finishAnimation();
  1.1011 +                setState(PanZoomState.NOTHING);
  1.1012 +            }
  1.1013 +        }
  1.1014 +    }
  1.1015 +
  1.1016 +    private void finishAnimation() {
  1.1017 +        checkMainThread();
  1.1018 +
  1.1019 +        stopAnimationTask();
  1.1020 +
  1.1021 +        // Force a viewport synchronisation
  1.1022 +        mTarget.forceRedraw(null);
  1.1023 +    }
  1.1024 +
  1.1025 +    /* Returns the nearest viewport metrics with no overscroll visible. */
  1.1026 +    private ImmutableViewportMetrics getValidViewportMetrics() {
  1.1027 +        return getValidViewportMetrics(getMetrics());
  1.1028 +    }
  1.1029 +
  1.1030 +    private ImmutableViewportMetrics getValidViewportMetrics(ImmutableViewportMetrics viewportMetrics) {
  1.1031 +        /* First, we adjust the zoom factor so that we can make no overscrolled area visible. */
  1.1032 +        float zoomFactor = viewportMetrics.zoomFactor;
  1.1033 +        RectF pageRect = viewportMetrics.getPageRect();
  1.1034 +        RectF viewport = viewportMetrics.getViewport();
  1.1035 +
  1.1036 +        float focusX = viewport.width() / 2.0f;
  1.1037 +        float focusY = viewport.height() / 2.0f;
  1.1038 +
  1.1039 +        float minZoomFactor = 0.0f;
  1.1040 +        float maxZoomFactor = MAX_ZOOM;
  1.1041 +
  1.1042 +        ZoomConstraints constraints = mTarget.getZoomConstraints();
  1.1043 +
  1.1044 +        if (constraints.getMinZoom() > 0)
  1.1045 +            minZoomFactor = constraints.getMinZoom();
  1.1046 +        if (constraints.getMaxZoom() > 0)
  1.1047 +            maxZoomFactor = constraints.getMaxZoom();
  1.1048 +
  1.1049 +        if (!constraints.getAllowZoom()) {
  1.1050 +            // If allowZoom is false, clamp to the default zoom level.
  1.1051 +            maxZoomFactor = minZoomFactor = constraints.getDefaultZoom();
  1.1052 +        }
  1.1053 +
  1.1054 +        // Ensure minZoomFactor keeps the page at least as big as the viewport.
  1.1055 +        if (pageRect.width() > 0) {
  1.1056 +            float pageWidth = pageRect.width() +
  1.1057 +              viewportMetrics.marginLeft +
  1.1058 +              viewportMetrics.marginRight;
  1.1059 +            float scaleFactor = viewport.width() / pageWidth;
  1.1060 +            minZoomFactor = Math.max(minZoomFactor, zoomFactor * scaleFactor);
  1.1061 +            if (viewport.width() > pageWidth)
  1.1062 +                focusX = 0.0f;
  1.1063 +        }
  1.1064 +        if (pageRect.height() > 0) {
  1.1065 +            float pageHeight = pageRect.height() +
  1.1066 +              viewportMetrics.marginTop +
  1.1067 +              viewportMetrics.marginBottom;
  1.1068 +            float scaleFactor = viewport.height() / pageHeight;
  1.1069 +            minZoomFactor = Math.max(minZoomFactor, zoomFactor * scaleFactor);
  1.1070 +            if (viewport.height() > pageHeight)
  1.1071 +                focusY = 0.0f;
  1.1072 +        }
  1.1073 +
  1.1074 +        maxZoomFactor = Math.max(maxZoomFactor, minZoomFactor);
  1.1075 +
  1.1076 +        if (zoomFactor < minZoomFactor) {
  1.1077 +            // if one (or both) of the page dimensions is smaller than the viewport,
  1.1078 +            // zoom using the top/left as the focus on that axis. this prevents the
  1.1079 +            // scenario where, if both dimensions are smaller than the viewport, but
  1.1080 +            // by different scale factors, we end up scrolled to the end on one axis
  1.1081 +            // after applying the scale
  1.1082 +            PointF center = new PointF(focusX, focusY);
  1.1083 +            viewportMetrics = viewportMetrics.scaleTo(minZoomFactor, center);
  1.1084 +        } else if (zoomFactor > maxZoomFactor) {
  1.1085 +            PointF center = new PointF(viewport.width() / 2.0f, viewport.height() / 2.0f);
  1.1086 +            viewportMetrics = viewportMetrics.scaleTo(maxZoomFactor, center);
  1.1087 +        }
  1.1088 +
  1.1089 +        /* Now we pan to the right origin. */
  1.1090 +        viewportMetrics = viewportMetrics.clampWithMargins();
  1.1091 +
  1.1092 +        return viewportMetrics;
  1.1093 +    }
  1.1094 +
  1.1095 +    private class AxisX extends Axis {
  1.1096 +        AxisX(SubdocumentScrollHelper subscroller) { super(subscroller); }
  1.1097 +        @Override
  1.1098 +        public float getOrigin() { return getMetrics().viewportRectLeft; }
  1.1099 +        @Override
  1.1100 +        protected float getViewportLength() { return getMetrics().getWidth(); }
  1.1101 +        @Override
  1.1102 +        protected float getPageStart() { return getMetrics().pageRectLeft; }
  1.1103 +        @Override
  1.1104 +        protected float getMarginStart() { return mTarget.getMaxMargins().left - getMetrics().marginLeft; }
  1.1105 +        @Override
  1.1106 +        protected float getMarginEnd() { return mTarget.getMaxMargins().right - getMetrics().marginRight; }
  1.1107 +        @Override
  1.1108 +        protected float getPageLength() { return getMetrics().getPageWidthWithMargins(); }
  1.1109 +        @Override
  1.1110 +        protected boolean marginsHidden() {
  1.1111 +            ImmutableViewportMetrics metrics = getMetrics();
  1.1112 +            RectF maxMargins = mTarget.getMaxMargins();
  1.1113 +            return (metrics.marginLeft < maxMargins.left || metrics.marginRight < maxMargins.right);
  1.1114 +        }
  1.1115 +        @Override
  1.1116 +        protected void overscrollFling(final float velocity) {
  1.1117 +            if (mOverscroll != null) {
  1.1118 +                mOverscroll.setVelocity(velocity, Overscroll.Axis.X);
  1.1119 +            }
  1.1120 +        }
  1.1121 +        @Override
  1.1122 +        protected void overscrollPan(final float distance) {
  1.1123 +            if (mOverscroll != null) {
  1.1124 +                mOverscroll.setDistance(distance, Overscroll.Axis.X);
  1.1125 +            }
  1.1126 +        }
  1.1127 +    }
  1.1128 +
  1.1129 +    private class AxisY extends Axis {
  1.1130 +        AxisY(SubdocumentScrollHelper subscroller) { super(subscroller); }
  1.1131 +        @Override
  1.1132 +        public float getOrigin() { return getMetrics().viewportRectTop; }
  1.1133 +        @Override
  1.1134 +        protected float getViewportLength() { return getMetrics().getHeight(); }
  1.1135 +        @Override
  1.1136 +        protected float getPageStart() { return getMetrics().pageRectTop; }
  1.1137 +        @Override
  1.1138 +        protected float getPageLength() { return getMetrics().getPageHeightWithMargins(); }
  1.1139 +        @Override
  1.1140 +        protected float getMarginStart() { return mTarget.getMaxMargins().top - getMetrics().marginTop; }
  1.1141 +        @Override
  1.1142 +        protected float getMarginEnd() { return mTarget.getMaxMargins().bottom - getMetrics().marginBottom; }
  1.1143 +        @Override
  1.1144 +        protected boolean marginsHidden() {
  1.1145 +            ImmutableViewportMetrics metrics = getMetrics();
  1.1146 +            RectF maxMargins = mTarget.getMaxMargins();
  1.1147 +            return (metrics.marginTop < maxMargins.top || metrics.marginBottom < maxMargins.bottom);
  1.1148 +        }
  1.1149 +        @Override
  1.1150 +        protected void overscrollFling(final float velocity) {
  1.1151 +            if (mOverscroll != null) {
  1.1152 +                mOverscroll.setVelocity(velocity, Overscroll.Axis.Y);
  1.1153 +            }
  1.1154 +        }
  1.1155 +        @Override
  1.1156 +        protected void overscrollPan(final float distance) {
  1.1157 +            if (mOverscroll != null) {
  1.1158 +                mOverscroll.setDistance(distance, Overscroll.Axis.Y);
  1.1159 +            }
  1.1160 +        }
  1.1161 +    }
  1.1162 +
  1.1163 +    /*
  1.1164 +     * Zooming
  1.1165 +     */
  1.1166 +    @Override
  1.1167 +    public boolean onScaleBegin(SimpleScaleGestureDetector detector) {
  1.1168 +        if (mState == PanZoomState.ANIMATED_ZOOM)
  1.1169 +            return false;
  1.1170 +
  1.1171 +        if (!mTarget.getZoomConstraints().getAllowZoom())
  1.1172 +            return false;
  1.1173 +
  1.1174 +        setState(PanZoomState.PINCHING);
  1.1175 +        mLastZoomFocus = new PointF(detector.getFocusX(), detector.getFocusY());
  1.1176 +        cancelTouch();
  1.1177 +
  1.1178 +        GeckoAppShell.sendEventToGecko(GeckoEvent.createNativeGestureEvent(GeckoEvent.ACTION_MAGNIFY_START, mLastZoomFocus, getMetrics().zoomFactor));
  1.1179 +
  1.1180 +        return true;
  1.1181 +    }
  1.1182 +
  1.1183 +    @Override
  1.1184 +    public boolean onScale(SimpleScaleGestureDetector detector) {
  1.1185 +        if (mTarget.isFullScreen())
  1.1186 +            return false;
  1.1187 +
  1.1188 +        if (mState != PanZoomState.PINCHING)
  1.1189 +            return false;
  1.1190 +
  1.1191 +        float prevSpan = detector.getPreviousSpan();
  1.1192 +        if (FloatUtils.fuzzyEquals(prevSpan, 0.0f)) {
  1.1193 +            // let's eat this one to avoid setting the new zoom to infinity (bug 711453)
  1.1194 +            return true;
  1.1195 +        }
  1.1196 +
  1.1197 +        synchronized (mTarget.getLock()) {
  1.1198 +            float zoomFactor = getAdjustedZoomFactor(detector.getCurrentSpan() / prevSpan);
  1.1199 +            scrollBy(mLastZoomFocus.x - detector.getFocusX(),
  1.1200 +                     mLastZoomFocus.y - detector.getFocusY());
  1.1201 +            mLastZoomFocus.set(detector.getFocusX(), detector.getFocusY());
  1.1202 +            ImmutableViewportMetrics target = getMetrics().scaleTo(zoomFactor, mLastZoomFocus);
  1.1203 +
  1.1204 +            // If overscroll is diabled, prevent zooming outside the normal document pans.
  1.1205 +            if (mX.getOverScrollMode() == View.OVER_SCROLL_NEVER || mY.getOverScrollMode() == View.OVER_SCROLL_NEVER) {
  1.1206 +                target = getValidViewportMetrics(target);
  1.1207 +            }
  1.1208 +            mTarget.setViewportMetrics(target);
  1.1209 +        }
  1.1210 +
  1.1211 +        GeckoEvent event = GeckoEvent.createNativeGestureEvent(GeckoEvent.ACTION_MAGNIFY, mLastZoomFocus, getMetrics().zoomFactor);
  1.1212 +        GeckoAppShell.sendEventToGecko(event);
  1.1213 +
  1.1214 +        return true;
  1.1215 +    }
  1.1216 +
  1.1217 +    private ImmutableViewportMetrics applyZoomDelta(ImmutableViewportMetrics metrics, float zoomDelta) {
  1.1218 +        float oldZoom = metrics.zoomFactor;
  1.1219 +        float newZoom = oldZoom + zoomDelta;
  1.1220 +        float adjustedZoom = getAdjustedZoomFactor(newZoom / oldZoom);
  1.1221 +        // since we don't have a particular focus to zoom to, just use the center
  1.1222 +        PointF center = new PointF(metrics.getWidth() / 2.0f, metrics.getHeight() / 2.0f);
  1.1223 +        metrics = metrics.scaleTo(adjustedZoom, center);
  1.1224 +        return metrics;
  1.1225 +    }
  1.1226 +
  1.1227 +    private boolean animatedScale(float zoomDelta) {
  1.1228 +        if (mState != PanZoomState.NOTHING && mState != PanZoomState.BOUNCE) {
  1.1229 +            return false;
  1.1230 +        }
  1.1231 +        synchronized (mTarget.getLock()) {
  1.1232 +            ImmutableViewportMetrics metrics = applyZoomDelta(getMetrics(), zoomDelta);
  1.1233 +            bounce(getValidViewportMetrics(metrics), PanZoomState.BOUNCE);
  1.1234 +        }
  1.1235 +        return true;
  1.1236 +    }
  1.1237 +
  1.1238 +    private float getAdjustedZoomFactor(float zoomRatio) {
  1.1239 +        /*
  1.1240 +         * Apply edge resistance if we're zoomed out smaller than the page size by scaling the zoom
  1.1241 +         * factor toward 1.0.
  1.1242 +         */
  1.1243 +        float resistance = Math.min(mX.getEdgeResistance(true), mY.getEdgeResistance(true));
  1.1244 +        if (zoomRatio > 1.0f)
  1.1245 +            zoomRatio = 1.0f + (zoomRatio - 1.0f) * resistance;
  1.1246 +        else
  1.1247 +            zoomRatio = 1.0f - (1.0f - zoomRatio) * resistance;
  1.1248 +
  1.1249 +        float newZoomFactor = getMetrics().zoomFactor * zoomRatio;
  1.1250 +        float minZoomFactor = 0.0f;
  1.1251 +        float maxZoomFactor = MAX_ZOOM;
  1.1252 +
  1.1253 +        ZoomConstraints constraints = mTarget.getZoomConstraints();
  1.1254 +
  1.1255 +        if (constraints.getMinZoom() > 0)
  1.1256 +            minZoomFactor = constraints.getMinZoom();
  1.1257 +        if (constraints.getMaxZoom() > 0)
  1.1258 +            maxZoomFactor = constraints.getMaxZoom();
  1.1259 +
  1.1260 +        if (newZoomFactor < minZoomFactor) {
  1.1261 +            // apply resistance when zooming past minZoomFactor,
  1.1262 +            // such that it asymptotically reaches minZoomFactor / 2.0
  1.1263 +            // but never exceeds that
  1.1264 +            final float rate = 0.5f; // controls how quickly we approach the limit
  1.1265 +            float excessZoom = minZoomFactor - newZoomFactor;
  1.1266 +            excessZoom = 1.0f - (float)Math.exp(-excessZoom * rate);
  1.1267 +            newZoomFactor = minZoomFactor * (1.0f - excessZoom / 2.0f);
  1.1268 +        }
  1.1269 +
  1.1270 +        if (newZoomFactor > maxZoomFactor) {
  1.1271 +            // apply resistance when zooming past maxZoomFactor,
  1.1272 +            // such that it asymptotically reaches maxZoomFactor + 1.0
  1.1273 +            // but never exceeds that
  1.1274 +            float excessZoom = newZoomFactor - maxZoomFactor;
  1.1275 +            excessZoom = 1.0f - (float)Math.exp(-excessZoom);
  1.1276 +            newZoomFactor = maxZoomFactor + excessZoom;
  1.1277 +        }
  1.1278 +
  1.1279 +        return newZoomFactor;
  1.1280 +    }
  1.1281 +
  1.1282 +    @Override
  1.1283 +    public void onScaleEnd(SimpleScaleGestureDetector detector) {
  1.1284 +        if (mState == PanZoomState.ANIMATED_ZOOM)
  1.1285 +            return;
  1.1286 +
  1.1287 +        // switch back to the touching state
  1.1288 +        startTouch(detector.getFocusX(), detector.getFocusY(), detector.getEventTime());
  1.1289 +
  1.1290 +        // Force a viewport synchronisation
  1.1291 +        mTarget.forceRedraw(null);
  1.1292 +
  1.1293 +        PointF point = new PointF(detector.getFocusX(), detector.getFocusY());
  1.1294 +        GeckoEvent event = GeckoEvent.createNativeGestureEvent(GeckoEvent.ACTION_MAGNIFY_END, point, getMetrics().zoomFactor);
  1.1295 +
  1.1296 +        if (event == null) {
  1.1297 +            return;
  1.1298 +        }
  1.1299 +
  1.1300 +        GeckoAppShell.sendEventToGecko(event);
  1.1301 +    }
  1.1302 +
  1.1303 +    @Override
  1.1304 +    public boolean getRedrawHint() {
  1.1305 +        switch (mState) {
  1.1306 +            case PINCHING:
  1.1307 +            case ANIMATED_ZOOM:
  1.1308 +            case BOUNCE:
  1.1309 +                // don't redraw during these because the zoom is (or might be, in the case
  1.1310 +                // of BOUNCE) be changing rapidly and gecko will have to redraw the entire
  1.1311 +                // display port area. we trigger a force-redraw upon exiting these states.
  1.1312 +                return false;
  1.1313 +            default:
  1.1314 +                // allow redrawing in other states
  1.1315 +                return true;
  1.1316 +        }
  1.1317 +    }
  1.1318 +
  1.1319 +    private void sendPointToGecko(String event, MotionEvent motionEvent) {
  1.1320 +        String json;
  1.1321 +        try {
  1.1322 +            PointF point = new PointF(motionEvent.getX(), motionEvent.getY());
  1.1323 +            point = mTarget.convertViewPointToLayerPoint(point);
  1.1324 +            if (point == null) {
  1.1325 +                return;
  1.1326 +            }
  1.1327 +            json = PointUtils.toJSON(point).toString();
  1.1328 +        } catch (Exception e) {
  1.1329 +            Log.e(LOGTAG, "Unable to convert point to JSON for " + event, e);
  1.1330 +            return;
  1.1331 +        }
  1.1332 +
  1.1333 +        GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent(event, json));
  1.1334 +    }
  1.1335 +
  1.1336 +    @Override
  1.1337 +    public boolean onDown(MotionEvent motionEvent) {
  1.1338 +        mMediumPress = false;
  1.1339 +        return false;
  1.1340 +    }
  1.1341 +
  1.1342 +    @Override
  1.1343 +    public void onShowPress(MotionEvent motionEvent) {
  1.1344 +        // If we get this, it will be followed either by a call to
  1.1345 +        // onSingleTapUp (if the user lifts their finger before the
  1.1346 +        // long-press timeout) or a call to onLongPress (if the user
  1.1347 +        // does not). In the former case, we want to make sure it is
  1.1348 +        // treated as a click. (Note that if this is called, we will
  1.1349 +        // not get a call to onDoubleTap).
  1.1350 +        mMediumPress = true;
  1.1351 +    }
  1.1352 +
  1.1353 +    @Override
  1.1354 +    public void onLongPress(MotionEvent motionEvent) {
  1.1355 +        sendPointToGecko("Gesture:LongPress", motionEvent);
  1.1356 +    }
  1.1357 +
  1.1358 +    @Override
  1.1359 +    public boolean onSingleTapUp(MotionEvent motionEvent) {
  1.1360 +        // When double-tapping is allowed, we have to wait to see if this is
  1.1361 +        // going to be a double-tap.
  1.1362 +        // However, if mMediumPress is true then we know there will be no
  1.1363 +        // double-tap so we treat this as a click.
  1.1364 +        if (mMediumPress || !mTarget.getZoomConstraints().getAllowDoubleTapZoom()) {
  1.1365 +            sendPointToGecko("Gesture:SingleTap", motionEvent);
  1.1366 +        }
  1.1367 +        // return false because we still want to get the ACTION_UP event that triggers this
  1.1368 +        return false;
  1.1369 +    }
  1.1370 +
  1.1371 +    @Override
  1.1372 +    public boolean onSingleTapConfirmed(MotionEvent motionEvent) {
  1.1373 +        // When zooming is disabled, we handle this in onSingleTapUp.
  1.1374 +        if (mTarget.getZoomConstraints().getAllowDoubleTapZoom()) {
  1.1375 +            sendPointToGecko("Gesture:SingleTap", motionEvent);
  1.1376 +        }
  1.1377 +        return true;
  1.1378 +    }
  1.1379 +
  1.1380 +    @Override
  1.1381 +    public boolean onDoubleTap(MotionEvent motionEvent) {
  1.1382 +        if (mTarget.getZoomConstraints().getAllowDoubleTapZoom()) {
  1.1383 +            sendPointToGecko("Gesture:DoubleTap", motionEvent);
  1.1384 +        }
  1.1385 +        return true;
  1.1386 +    }
  1.1387 +
  1.1388 +    private void cancelTouch() {
  1.1389 +        GeckoEvent e = GeckoEvent.createBroadcastEvent("Gesture:CancelTouch", "");
  1.1390 +        GeckoAppShell.sendEventToGecko(e);
  1.1391 +    }
  1.1392 +
  1.1393 +    /**
  1.1394 +     * Zoom to a specified rect IN CSS PIXELS.
  1.1395 +     *
  1.1396 +     * While we usually use device pixels, @zoomToRect must be specified in CSS
  1.1397 +     * pixels.
  1.1398 +     */
  1.1399 +    private ImmutableViewportMetrics getMetricsToZoomTo(RectF zoomToRect) {
  1.1400 +        final float startZoom = getMetrics().zoomFactor;
  1.1401 +
  1.1402 +        RectF viewport = getMetrics().getViewport();
  1.1403 +        // 1. adjust the aspect ratio of zoomToRect to match that of the current viewport,
  1.1404 +        // enlarging as necessary (if it gets too big, it will get shrunk in the next step).
  1.1405 +        // while enlarging make sure we enlarge equally on both sides to keep the target rect
  1.1406 +        // centered.
  1.1407 +        float targetRatio = viewport.width() / viewport.height();
  1.1408 +        float rectRatio = zoomToRect.width() / zoomToRect.height();
  1.1409 +        if (FloatUtils.fuzzyEquals(targetRatio, rectRatio)) {
  1.1410 +            // all good, do nothing
  1.1411 +        } else if (targetRatio < rectRatio) {
  1.1412 +            // need to increase zoomToRect height
  1.1413 +            float newHeight = zoomToRect.width() / targetRatio;
  1.1414 +            zoomToRect.top -= (newHeight - zoomToRect.height()) / 2;
  1.1415 +            zoomToRect.bottom = zoomToRect.top + newHeight;
  1.1416 +        } else { // targetRatio > rectRatio) {
  1.1417 +            // need to increase zoomToRect width
  1.1418 +            float newWidth = targetRatio * zoomToRect.height();
  1.1419 +            zoomToRect.left -= (newWidth - zoomToRect.width()) / 2;
  1.1420 +            zoomToRect.right = zoomToRect.left + newWidth;
  1.1421 +        }
  1.1422 +
  1.1423 +        float finalZoom = viewport.width() / zoomToRect.width();
  1.1424 +
  1.1425 +        ImmutableViewportMetrics finalMetrics = getMetrics();
  1.1426 +        finalMetrics = finalMetrics.setViewportOrigin(
  1.1427 +            zoomToRect.left * finalMetrics.zoomFactor,
  1.1428 +            zoomToRect.top * finalMetrics.zoomFactor);
  1.1429 +        finalMetrics = finalMetrics.scaleTo(finalZoom, new PointF(0.0f, 0.0f));
  1.1430 +
  1.1431 +        // 2. now run getValidViewportMetrics on it, so that the target viewport is
  1.1432 +        // clamped down to prevent overscroll, over-zoom, and other bad conditions.
  1.1433 +        finalMetrics = getValidViewportMetrics(finalMetrics);
  1.1434 +        return finalMetrics;
  1.1435 +    }
  1.1436 +
  1.1437 +    private boolean animatedZoomTo(RectF zoomToRect) {
  1.1438 +        bounce(getMetricsToZoomTo(zoomToRect), PanZoomState.ANIMATED_ZOOM);
  1.1439 +        return true;
  1.1440 +    }
  1.1441 +
  1.1442 +    /** This function must be called from the UI thread. */
  1.1443 +    @Override
  1.1444 +    public void abortPanning() {
  1.1445 +        checkMainThread();
  1.1446 +        bounce();
  1.1447 +    }
  1.1448 +
  1.1449 +    @Override
  1.1450 +    public void setOverScrollMode(int overscrollMode) {
  1.1451 +        mX.setOverScrollMode(overscrollMode);
  1.1452 +        mY.setOverScrollMode(overscrollMode);
  1.1453 +    }
  1.1454 +
  1.1455 +    @Override
  1.1456 +    public int getOverScrollMode() {
  1.1457 +        return mX.getOverScrollMode();
  1.1458 +    }
  1.1459 +
  1.1460 +    @Override
  1.1461 +    public void setOverscrollHandler(final Overscroll handler) {
  1.1462 +        mOverscroll = handler;
  1.1463 +    }
  1.1464 +}

mercurial