Bug 703421 - Implement the Android draw metadata listener to fix races. r=kats
authorPatrick Walton <pwalton@mozilla.com>
Fri, 18 Nov 2011 18:07:14 -0800
changeset 83552 bed0b38129edba1061a70a483c6d3c9b63c2c10c
parent 83551 9b308d10628a5470a5b9b235e7c4de0c66101ac3
child 83553 bcab5ec25b5e99be6f5155b94c6bfa4a65fa681a
push id519
push userakeybl@mozilla.com
push dateWed, 01 Feb 2012 00:38:35 +0000
treeherdermozilla-beta@788ea1ef610b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskats
bugs703421
milestone11.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 703421 - Implement the Android draw metadata listener to fix races. r=kats
mobile/android/base/GeckoApp.java
mobile/android/base/GeckoAppShell.java
mobile/android/base/Makefile.in
mobile/android/base/gfx/BufferedCairoImage.java
mobile/android/base/gfx/CairoGLInfo.java
mobile/android/base/gfx/CairoImage.java
mobile/android/base/gfx/CairoUtils.java
mobile/android/base/gfx/GeckoSoftwareLayerClient.java
mobile/android/base/gfx/Layer.java
mobile/android/base/gfx/LayerController.java
mobile/android/base/gfx/LayerRenderer.java
mobile/android/base/gfx/NinePatchTileLayer.java
mobile/android/base/gfx/PlaceholderLayerClient.java
mobile/android/base/gfx/SingleTileLayer.java
mobile/android/base/gfx/TextLayer.java
mobile/android/base/gfx/TileLayer.java
mobile/android/chrome/content/browser.js
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -810,19 +810,16 @@ abstract public class GeckoApp
 
                 // request the preferences. doing it here means we don't need
                 // to wait when we open the GeckoPreferences activity.
                 JSONArray jsonPrefs = new JSONArray(prefs);
                 GeckoEvent getPrefsEvent = new GeckoEvent("Preferences:Get", jsonPrefs.toString());
                 GeckoAppShell.sendEventToGecko(getPrefsEvent);
 
                 connectGeckoLayerClient();
-            } else if (event.equals("PanZoom:Ack")) {
-                Rect rect = RectUtils.create(message.getJSONObject("rect"));
-                mSoftwareLayerClient.jsPanZoomCompleted(rect);
             } else if (event.equals("PanZoom:Resize")) {
                 IntSize size = new IntSize(message.getJSONObject("size"));
                 int layoutWidth = mGeckoLayout.getMeasuredWidth();
                 int layoutHeight = mGeckoLayout.getMeasuredHeight();
                 // increase page size if smaller than layout dimensions
                 if (size.width < layoutWidth || size.height < layoutHeight) {
                     size = new IntSize(Math.max(size.width, layoutWidth),
                                        Math.max(size.height, layoutHeight));
@@ -1234,17 +1231,16 @@ abstract public class GeckoApp
         GeckoAppShell.registerGeckoEventListener("Tab:Selected", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Doorhanger:Add", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Doorhanger:Remove", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Menu:Add", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Menu:Remove", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Preferences:Data", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Gecko:Ready", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("Toast:Show", GeckoApp.mAppContext);
-        GeckoAppShell.registerGeckoEventListener("PanZoom:Ack", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("PanZoom:Resize", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("ToggleChrome:Hide", GeckoApp.mAppContext);
         GeckoAppShell.registerGeckoEventListener("ToggleChrome:Show", GeckoApp.mAppContext);
 
         mConnectivityFilter = new IntentFilter();
         mConnectivityFilter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
         mConnectivityReceiver = new GeckoConnectivityReceiver();
 
--- a/mobile/android/base/GeckoAppShell.java
+++ b/mobile/android/base/GeckoAppShell.java
@@ -451,16 +451,19 @@ public class GeckoAppShell
     private static void geckoLoaded() {
         final LayerController layerController = GeckoApp.mAppContext.getLayerController();
         LayerView v = layerController.getView();
         mInputConnection = new GeckoInputConnection(v);
         v.setInputConnectionHandler(mInputConnection);
 
         layerController.setOnTouchListener(new View.OnTouchListener() {
             public boolean onTouch(View view, MotionEvent event) {
+                if (event == null)
+                    return true;
+
                 float origX = event.getX();
                 float origY = event.getY();
                 /* Transform the point to the layer offset. */
                 PointF eventPoint = new PointF(origX, origY);
                 PointF geckoPoint = layerController.convertViewPointToLayerPoint(eventPoint);
                 if (geckoPoint == null) {
                     return false;
                 }
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -69,16 +69,17 @@ JAVAFILES = \
   SurfaceLockInfo.java \
   Tab.java \
   Tabs.java \
   TabsTray.java \
   GeckoBatteryManager.java \
   GeckoThread.java \
   GeckoAsyncTask.java \
   gfx/BufferedCairoImage.java \
+  gfx/CairoGLInfo.java \
   gfx/CairoImage.java \
   gfx/CairoUtils.java \
   gfx/GeckoSoftwareLayerClient.java \
   gfx/InputConnectionHandler.java \
   gfx/IntSize.java \
   gfx/Layer.java \
   gfx/LayerClient.java \
   gfx/LayerController.java \
--- a/mobile/android/base/gfx/BufferedCairoImage.java
+++ b/mobile/android/base/gfx/BufferedCairoImage.java
@@ -57,17 +57,17 @@ public class BufferedCairoImage extends 
         mFormat = CairoUtils.bitmapConfigToCairoFormat(bitmap.getConfig());
         mWidth = bitmap.getWidth();
         mHeight = bitmap.getHeight();
         mBuffer = ByteBuffer.allocateDirect(mWidth * mHeight * 4);
         bitmap.copyPixelsToBuffer(mBuffer.asIntBuffer());
     }
 
     @Override
-    public ByteBuffer lockBuffer() { return mBuffer; }
+    public ByteBuffer getBuffer() { return mBuffer; }
     @Override
     public int getWidth() { return mWidth; }
     @Override
     public int getHeight() { return mHeight; }
     @Override
     public int getFormat() { return mFormat; }
 }
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/gfx/CairoGLInfo.java
@@ -0,0 +1,67 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Mozilla Android code.
+ *
+ * The Initial Developer of the Original Code is Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2009-2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Patrick Walton <pcwalton@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+package org.mozilla.gecko.gfx;
+
+import javax.microedition.khronos.opengles.GL10;
+
+/** Information needed to render Cairo bitmaps using OpenGL ES. */
+public class CairoGLInfo {
+    public final int internalFormat;
+    public final int format;
+    public final int type;
+
+    public CairoGLInfo(int cairoFormat) {
+        switch (cairoFormat) {
+        case CairoImage.FORMAT_ARGB32:
+            internalFormat = format = GL10.GL_RGBA; type = GL10.GL_UNSIGNED_BYTE;
+            break;
+        case CairoImage.FORMAT_RGB24:
+            internalFormat = format = GL10.GL_RGB; type = GL10.GL_UNSIGNED_BYTE;
+            break;
+        case CairoImage.FORMAT_RGB16_565:
+            internalFormat = format = GL10.GL_RGB; type = GL10.GL_UNSIGNED_SHORT_5_6_5;
+            break;
+        case CairoImage.FORMAT_A8:
+        case CairoImage.FORMAT_A1:
+            throw new RuntimeException("Cairo FORMAT_A1 and FORMAT_A8 unsupported");
+        default:
+            throw new RuntimeException("Unknown Cairo format");
+        }
+    }
+}
+
--- a/mobile/android/base/gfx/CairoImage.java
+++ b/mobile/android/base/gfx/CairoImage.java
@@ -38,18 +38,17 @@
 package org.mozilla.gecko.gfx;
 
 import java.nio.ByteBuffer;
 
 /*
  * A bitmap with pixel data in one of the formats that Cairo understands.
  */
 public abstract class CairoImage {
-    public abstract ByteBuffer lockBuffer();
-    public void unlockBuffer() { /* By default, a no-op. */ }
+    public abstract ByteBuffer getBuffer();
 
     public abstract int getWidth();
     public abstract int getHeight();
     public abstract int getFormat();
 
     public static final int FORMAT_INVALID = -1;
     public static final int FORMAT_ARGB32 = 0;
     public static final int FORMAT_RGB24 = 1;
--- a/mobile/android/base/gfx/CairoUtils.java
+++ b/mobile/android/base/gfx/CairoUtils.java
@@ -42,61 +42,16 @@ import android.graphics.Bitmap;
 import javax.microedition.khronos.opengles.GL10;
 
 /**
  * Utility methods useful when displaying Cairo bitmaps using OpenGL ES.
  */
 public class CairoUtils {
     private CairoUtils() { /* Don't call me. */ }
 
-    public static int cairoFormatToGLInternalFormat(int cairoFormat) {
-        switch (cairoFormat) {
-        case CairoImage.FORMAT_ARGB32:
-            return GL10.GL_RGBA;
-        case CairoImage.FORMAT_RGB24:
-        case CairoImage.FORMAT_RGB16_565:
-            return GL10.GL_RGB;
-        case CairoImage.FORMAT_A8:
-        case CairoImage.FORMAT_A1:
-            throw new RuntimeException("Cairo FORMAT_A1 and FORMAT_A8 unsupported");
-        default:
-            throw new RuntimeException("Unknown Cairo format");
-        }
-    }
-
-    public static int cairoFormatToGLFormat(int cairoFormat) {
-        switch (cairoFormat) {
-        case CairoImage.FORMAT_ARGB32:
-            return GL10.GL_RGBA;
-        case CairoImage.FORMAT_RGB24:
-        case CairoImage.FORMAT_RGB16_565:
-            return GL10.GL_RGB;
-        case CairoImage.FORMAT_A8:
-        case CairoImage.FORMAT_A1:
-            return GL10.GL_ALPHA;
-        default:
-            throw new RuntimeException("Unknown Cairo format");
-        }
-    }
-
-    public static int cairoFormatToGLType(int cairoFormat) {
-        switch (cairoFormat) {
-        case CairoImage.FORMAT_ARGB32:
-        case CairoImage.FORMAT_RGB24:
-        case CairoImage.FORMAT_A8:
-            return GL10.GL_UNSIGNED_BYTE;
-        case CairoImage.FORMAT_A1:
-            throw new RuntimeException("Cairo FORMAT_A1 unsupported in Android OpenGL");
-        case CairoImage.FORMAT_RGB16_565:
-            return GL10.GL_UNSIGNED_SHORT_5_6_5;
-        default:
-            throw new RuntimeException("Unknown Cairo format");
-        }
-    }
-
     public static int bitsPerPixelForCairoFormat(int cairoFormat) {
         switch (cairoFormat) {
         case CairoImage.FORMAT_A1:          return 1;
         case CairoImage.FORMAT_A8:          return 8;
         case CairoImage.FORMAT_RGB16_565:   return 16;
         case CairoImage.FORMAT_RGB24:       return 24;
         case CairoImage.FORMAT_ARGB32:      return 32;
         default:
--- a/mobile/android/base/gfx/GeckoSoftwareLayerClient.java
+++ b/mobile/android/base/gfx/GeckoSoftwareLayerClient.java
@@ -48,161 +48,121 @@ import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import android.content.Context;
 import android.graphics.Point;
 import android.graphics.PointF;
 import android.graphics.Rect;
 import android.graphics.RectF;
 import android.util.Log;
+import org.json.JSONException;
+import org.json.JSONObject;
 import java.nio.ByteBuffer;
-import java.util.concurrent.Semaphore;
 import java.util.Timer;
 import java.util.TimerTask;
 
 /**
  * Transfers a software-rendered Gecko to an ImageLayer so that it can be rendered by our
  * compositor.
  *
  * TODO: Throttle down Gecko's priority when we pan and zoom.
  */
 public class GeckoSoftwareLayerClient extends LayerClient {
     private Context mContext;
     private int mWidth, mHeight, mFormat;
     private ByteBuffer mBuffer;
-    private Semaphore mBufferSemaphore;
-    private SingleTileLayer mTileLayer;
+    private final SingleTileLayer mTileLayer;
     private ViewportController mViewportController;
 
+    /* The viewport rect that Gecko is currently displaying. */
     private RectF mGeckoVisibleRect;
-    /* The viewport rect that Gecko is currently displaying. */
-
-    private Rect mJSPanningToRect;
-    /* The rect that we just told chrome JavaScript to pan to. */
-
-    private boolean mWaitingForJSPanZoom;
-    /* This will be set to true if we are waiting on the chrome JavaScript to finish panning or
-     * zooming before we can render. */
 
     private CairoImage mCairoImage;
 
+    private static final long MIN_VIEWPORT_CHANGE_DELAY = 350L;
+    private long mLastViewportChangeTime;
+    private Timer mViewportRedrawTimer;
+
     /* The initial page width and height that we use before a page is loaded. */
     private static final int PAGE_WIDTH = 980;      /* Matches MobileSafari. */
     private static final int PAGE_HEIGHT = 1500;
 
     public GeckoSoftwareLayerClient(Context context) {
         mContext = context;
 
         mViewportController = new ViewportController(new IntSize(PAGE_WIDTH, PAGE_HEIGHT),
                                                      new RectF(0, 0, 1, 1));
 
         mWidth = LayerController.TILE_WIDTH;
         mHeight = LayerController.TILE_HEIGHT;
         mFormat = CairoImage.FORMAT_RGB16_565;
 
         mBuffer = ByteBuffer.allocateDirect(mWidth * mHeight * 2);
-        mBufferSemaphore = new Semaphore(1);
-
-        mWaitingForJSPanZoom = false;
 
         mCairoImage = new CairoImage() {
             @Override
-            public ByteBuffer lockBuffer() {
-                try {
-                    mBufferSemaphore.acquire();
-                } catch (InterruptedException e) {
-                    throw new RuntimeException(e);
-                }
-                return mBuffer;
-            }
-            @Override
-            public void unlockBuffer() {
-                mBufferSemaphore.release();
-            }
+            public ByteBuffer getBuffer() { return mBuffer; }
             @Override
             public int getWidth() { return mWidth; }
             @Override
             public int getHeight() { return mHeight; }
             @Override
             public int getFormat() { return mFormat; }
         };
 
-        mTileLayer = new SingleTileLayer();
+        mTileLayer = new SingleTileLayer(mCairoImage);
     }
 
     /** Attaches the root layer to the layer controller so that Gecko appears. */
     @Override
     public void init() {
         getLayerController().setRoot(mTileLayer);
         render();
     }
 
     public void beginDrawing() {
-        /* no-op */
+        mTileLayer.beginTransaction();
     }
 
     /*
      * TODO: Would be cleaner if this took an android.graphics.Rect instead, but that would require
      * a little more JNI magic.
      */
-    public void endDrawing(int x, int y, int width, int height) {
-        LayerController controller = getLayerController();
-        if (controller == null)
-            return;
-        //controller.unzoom();  /* FIXME */
-        controller.notifyViewOfGeometryChange();
+    public void endDrawing(int x, int y, int width, int height, String metadata) {
+        try {
+            LayerController controller = getLayerController();
+            controller.notifyViewOfGeometryChange();
 
-        mViewportController.setVisibleRect(mGeckoVisibleRect);
+            try {
+                JSONObject metadataObject = new JSONObject(metadata);
+                float originX = (float)metadataObject.getDouble("x");
+                float originY = (float)metadataObject.getDouble("y");
+                mTileLayer.setOrigin(new PointF(originX, originY));
+            } catch (JSONException e) {
+                throw new RuntimeException(e);
+            }
 
-        if (mGeckoVisibleRect != null) {
-            RectF layerRect = mViewportController.untransformVisibleRect(mGeckoVisibleRect,
-                                                                         getPageSize());
-            mTileLayer.origin = new PointF(layerRect.left, layerRect.top);
+            Rect rect = new Rect(x, y, x + width, y + height);
+            mTileLayer.invalidate(rect);
+        } finally {
+            mTileLayer.endTransaction();
         }
-
-        repaint(new Rect(x, y, x + width, y + height));
     }
 
-    /*
-     * Temporary fix to allow both old and new widget APIs to access this object. See bug 703421.
-     */
-    public void endDrawing(int x, int y, int width, int height, String metadata) {
-        endDrawing(x, y, width, height);
-    }
-
-    private void repaint(Rect rect) {
-        mTileLayer.paintSubimage(mCairoImage, rect);
-    }
-
-    /** Called whenever the chrome JS finishes panning or zooming to some location. */
-    public void jsPanZoomCompleted(Rect rect) {
-        mGeckoVisibleRect = new RectF(rect);
-        if (mWaitingForJSPanZoom)
-            render();
-    }
-
-    /**
-     * Acquires a lock on the back buffer and returns it, blocking until it's unlocked. This
-     * function is for Gecko to use.
-     */
+    /** Returns the back buffer. This function is for Gecko to use. */
     public ByteBuffer lockBuffer() {
-        try {
-            mBufferSemaphore.acquire();
-        } catch (InterruptedException e) {
-            throw new RuntimeException(e);
-        }
         return mBuffer;
     }
 
     /**
-     * Releases the lock on the back buffer. After this call, it is forbidden for Gecko to touch
-     * the buffer. This function is, again, for Gecko to use.
+     * Gecko calls this function to signal that it is done with the back buffer. After this call,
+     * it is forbidden for Gecko to touch the buffer.
      */
     public void unlockBuffer() {
-        mBufferSemaphore.release();
+        /* no-op */
     }
 
     /** Called whenever the page changes size. */
     public void setPageSize(IntSize pageSize) {
         mViewportController.setPageSize(pageSize);
         getLayerController().setPageSize(pageSize);
     }
 
@@ -212,74 +172,61 @@ public class GeckoSoftwareLayerClient ex
         render();
     }
 
     @Override
     public IntSize getPageSize() { return mViewportController.getPageSize(); }
 
     @Override
     public void render() {
+        adjustViewportWithThrottling();
+    }
+
+    private void adjustViewportWithThrottling() {
+        if (!getLayerController().getRedrawHint())
+            return;
+
+        if (System.currentTimeMillis() < mLastViewportChangeTime + MIN_VIEWPORT_CHANGE_DELAY) {
+            if (mViewportRedrawTimer != null)
+                return;
+
+            mViewportRedrawTimer = new Timer();
+            mViewportRedrawTimer.schedule(new TimerTask() {
+                @Override
+                public void run() {
+                    /* We jump back to the UI thread to avoid possible races here. */
+                    getLayerController().getView().post(new Runnable() {
+                        @Override
+                        public void run() {
+                            mViewportRedrawTimer = null;
+                            adjustViewportWithThrottling();
+                        }
+                    });
+                }
+            }, MIN_VIEWPORT_CHANGE_DELAY);
+            return;
+        }
+
+        adjustViewport();
+    }
+
+    private void adjustViewport() {
         LayerController layerController = getLayerController();
         RectF visibleRect = layerController.getVisibleRect();
         RectF tileRect = mViewportController.widenRect(visibleRect);
         tileRect = mViewportController.clampRect(tileRect);
 
-        IntSize pageSize = layerController.getPageSize();
-        RectF viewportRect = mViewportController.transformVisibleRect(tileRect, pageSize);
-
-        /* Prevent null pointer exceptions at the start. */
-        if (mGeckoVisibleRect == null)
-            mGeckoVisibleRect = viewportRect;
-
-        if (!getLayerController().getRedrawHint())
-            return;
-
-        /* If Gecko's visible rect is the same as our visible rect, then we can actually kick off a
-         * draw event. */
-        if (mGeckoVisibleRect.equals(viewportRect)) {
-            mWaitingForJSPanZoom = false;
-            mJSPanningToRect = null;
-            GeckoAppShell.scheduleRedraw();
-            return;
-        }
+        int x = (int)Math.round(tileRect.left), y = (int)Math.round(tileRect.top);
+        GeckoEvent event = new GeckoEvent("Viewport:Change", "{\"x\": " + x +
+                                          ", \"y\": " + y + "}");
+        GeckoAppShell.sendEventToGecko(event);
 
-        /* Otherwise, we need to get Gecko's visible rect equal to our visible rect before we can
-         * safely draw. If we're just waiting for chrome JavaScript to catch up, we do nothing.
-         * This check avoids bombarding the chrome JavaScript with messages. */
-        int viewportRectX = (int)Math.round(viewportRect.left);
-        int viewportRectY = (int)Math.round(viewportRect.top);
-        Rect panToRect = new Rect(viewportRectX, viewportRectY,
-                                  viewportRectX + LayerController.TILE_WIDTH,
-                                  viewportRectY + LayerController.TILE_HEIGHT);
-
-        if (mWaitingForJSPanZoom && mJSPanningToRect != null &&
-                mJSPanningToRect.equals(panToRect)) {
-            return;
-        }
-
-        /* We send Gecko a message telling it to move its visible rect to the appropriate spot and
-         * set a flag to remind us to try the redraw again. */
-
-        GeckoAppShell.sendEventToGecko(new GeckoEvent("PanZoom:PanZoom",
-            "{\"x\": " + panToRect.left + ", \"y\": " + panToRect.top +
-            ", \"width\": " + panToRect.width() + ", \"height\": " + panToRect.height() +
-            ", \"zoomFactor\": " + getZoomFactor() + "}"));
-
-        mJSPanningToRect = panToRect;
-        mWaitingForJSPanZoom = true;
+        mLastViewportChangeTime = System.currentTimeMillis();
     }
 
     /* Returns the dimensions of the box in page coordinates that the user is viewing. */
     private RectF getTransformedVisibleRect() {
         LayerController layerController = getLayerController();
         return mViewportController.transformVisibleRect(layerController.getVisibleRect(),
                                                         layerController.getPageSize());
     }
-
-    private float getZoomFactor() {
-        return 1.0f;    // FIXME
-        /*LayerController layerController = getLayerController();
-        return mViewportController.getZoomFactor(layerController.getVisibleRect(),
-                                                 layerController.getPageSize(),
-                                                 layerController.getScreenSize());*/
-    }
 }
 
--- a/mobile/android/base/gfx/Layer.java
+++ b/mobile/android/base/gfx/Layer.java
@@ -33,33 +33,105 @@
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.gfx;
 
 import android.graphics.PointF;
+import android.util.Log;
+import java.util.concurrent.locks.ReentrantLock;
 import javax.microedition.khronos.opengles.GL10;
 
 public abstract class Layer {
-    public PointF origin;
+    private final ReentrantLock mTransactionLock;
+    private boolean mInTransaction;
+    private PointF mOrigin;
+    private PointF mNewOrigin;
 
     public Layer() {
-        origin = new PointF(0.0f, 0.0f);
+        mTransactionLock = new ReentrantLock();
+        mOrigin = new PointF(0.0f, 0.0f);
     }
 
     /** Draws the layer. Automatically applies the translation. */
     public final void draw(GL10 gl) {
+        if (mTransactionLock.isHeldByCurrentThread()) {
+            throw new RuntimeException("draw() called while transaction lock held by this " +
+                                       "thread?!");
+        }
+
+        if (mTransactionLock.tryLock()) {
+            try {
+                performUpdates(gl);
+            } finally {
+                mTransactionLock.unlock();
+            }
+        }
+
         gl.glPushMatrix();
-        gl.glTranslatef(origin.x, origin.y, 0.0f);
+        gl.glTranslatef(mOrigin.x, mOrigin.y, 0.0f);
+
         onDraw(gl);
+
         gl.glPopMatrix();
     }
 
     /**
+     * Call this before modifying the layer. Note that, for TileLayers, "modifying the layer"
+     * includes altering the underlying CairoImage in any way. Thus you must call this function
+     * before modifying the byte buffer associated with this layer.
+     *
+     * This function may block, so you should never call this on the main UI thread.
+     */
+    public void beginTransaction() {
+        if (mTransactionLock.isHeldByCurrentThread())
+            throw new RuntimeException("Nested transactions are not supported");
+        mTransactionLock.lock();
+        mInTransaction = true;
+    }
+
+    /** Call this when you're done modifying the layer. */
+    public void endTransaction() {
+        if (!mInTransaction)
+            throw new RuntimeException("endTransaction() called outside a transaction");
+        mInTransaction = false;
+        mTransactionLock.unlock();
+    }
+
+    /** Returns true if the layer is currently in a transaction and false otherwise. */
+    protected boolean inTransaction() {
+        return mInTransaction;
+    }
+
+    /** Returns the current layer origin. */
+    public PointF getOrigin() {
+        return mOrigin;
+    }
+
+    /** Sets the origin. Only valid inside a transaction. */
+    public void setOrigin(PointF newOrigin) {
+        if (!mInTransaction)
+            throw new RuntimeException("setOrigin() is only valid inside a transaction");
+        mNewOrigin = newOrigin;
+    }
+
+    /**
      * Subclasses implement this method to perform drawing.
      *
      * Invariant: The current matrix mode must be GL_MODELVIEW both before and after this call.
      */
     protected abstract void onDraw(GL10 gl);
+
+    /**
+     * Subclasses may override this method to perform custom layer updates. This will be called
+     * with the transaction lock held. Subclass implementations of this method must call the
+     * superclass implementation.
+     */
+    protected void performUpdates(GL10 gl) {
+        if (mNewOrigin != null) {
+            mOrigin = mNewOrigin;
+            mNewOrigin = null;
+        }
+    }
 }
 
--- a/mobile/android/base/gfx/LayerController.java
+++ b/mobile/android/base/gfx/LayerController.java
@@ -212,17 +212,17 @@ public class LayerController {
      * Returns true if this controller is fine with performing a redraw operation or false if it
      * would prefer that the action didn't take place.
      */
     public boolean getRedrawHint() {
         return aboutToCheckerboard();
     }
 
     private RectF getTileRect() {
-        float x = mRootLayer.origin.x, y = mRootLayer.origin.y;
+        float x = mRootLayer.getOrigin().x, y = mRootLayer.getOrigin().y;
         return new RectF(x, y, x + TILE_WIDTH, y + TILE_HEIGHT);
     }
 
     // Returns true if a checkerboard is about to be visible.
     private boolean aboutToCheckerboard() {
         Rect pageRect = new Rect(0, 0, mPageSize.width, mPageSize.height);
         Rect adjustedPageRect = RectUtils.contract(pageRect, DANGER_ZONE_X, DANGER_ZONE_Y);
         RectF visiblePageRect = RectUtils.intersect(mVisibleRect, new RectF(adjustedPageRect));
@@ -267,17 +267,17 @@ public class LayerController {
     public PointF convertViewPointToLayerPoint(PointF viewPoint) {
         if (mRootLayer == null)
             return null;
 
         // Undo the transforms.
         PointF scaledPoint = PointUtils.scale(viewPoint, 1.0f / getZoomFactor());
         return PointUtils.subtract(PointUtils.add(new PointF(mVisibleRect.left, mVisibleRect.top),
                                                   scaledPoint),
-                                   mRootLayer.origin);
+                                   mRootLayer.getOrigin());
     }
 
     /*
      * Gesture detection. This is handled only at a high level in this class; we dispatch to the
      * pan/zoom controller to do the dirty work.
      */
 
     public boolean onTouchEvent(MotionEvent event) {
--- a/mobile/android/base/gfx/LayerRenderer.java
+++ b/mobile/android/base/gfx/LayerRenderer.java
@@ -74,22 +74,21 @@ public class LayerRenderer implements GL
     private long mFrameCountTimestamp;
     private int mFrameCount;            // number of frames since last timestamp
 
     public LayerRenderer(LayerView view) {
         mView = view;
 
         /* FIXME: Layers should not be directly connected to the layer controller. */
         LayerController controller = view.getController();
-        mCheckerboardLayer = new SingleTileLayer(true);
-        mCheckerboardLayer.paintImage(new BufferedCairoImage(controller.getCheckerboardPattern()));
-        mShadowLayer = new NinePatchTileLayer(controller);
-        mShadowLayer.paintImage(new BufferedCairoImage(controller.getShadowPattern()));
-        mFPSLayer = new TextLayer(new IntSize(64, 32));
-        mFPSLayer.setText("-- FPS");
+        CairoImage checkerboardImage = new BufferedCairoImage(controller.getCheckerboardPattern());
+        mCheckerboardLayer = new SingleTileLayer(true, checkerboardImage);
+        CairoImage shadowImage = new BufferedCairoImage(controller.getShadowPattern());
+        mShadowLayer = new NinePatchTileLayer(controller, shadowImage);
+        mFPSLayer = TextLayer.create(new IntSize(64, 32), "-- FPS");
 
         mFrameCountTimestamp = System.currentTimeMillis();
         mFrameCount = 0;
     }
 
     public void onSurfaceCreated(GL10 gl, EGLConfig config) {
         gl.glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
         gl.glClearDepthf(1.0f);             /* FIXME: Is this needed? */
@@ -188,16 +187,23 @@ public class LayerRenderer implements GL
         mView.setScreenSize(width, height);
 
         /* TODO: Throw away tile images? */
     }
 
     private void checkFPS() {
         if (System.currentTimeMillis() >= mFrameCountTimestamp + 1000) {
             mFrameCountTimestamp = System.currentTimeMillis();
-            mFPSLayer.setText(mFrameCount + " FPS");
+
+            mFPSLayer.beginTransaction();
+            try {
+                mFPSLayer.setText(mFrameCount + " FPS");
+            } finally {
+                mFPSLayer.endTransaction();
+            }
+
             mFrameCount = 0;
         } else {
             mFrameCount++;
         }
     }
 }
 
--- a/mobile/android/base/gfx/NinePatchTileLayer.java
+++ b/mobile/android/base/gfx/NinePatchTileLayer.java
@@ -32,19 +32,16 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.gfx;
 
-import org.mozilla.gecko.gfx.IntSize;
-import org.mozilla.gecko.gfx.LayerController;
-import org.mozilla.gecko.gfx.TileLayer;
 import javax.microedition.khronos.opengles.GL10;
 import java.nio.FloatBuffer;
 
 /**
  * Encapsulates the logic needed to draw a nine-patch bitmap using OpenGL ES.
  *
  * For more information on nine-patch bitmaps, see the following document:
  *   http://developer.android.com/guide/topics/graphics/2d-graphics.html#nine-patch
@@ -85,18 +82,18 @@ public class NinePatchTileLayer extends 
 
     private static final float[] TOP_TEX_COORDS = {
         0.25f,  0.0f,
         0.50f,  0.0f,
         0.25f,  0.25f,
         0.50f,  0.25f,
     };
 
-    public NinePatchTileLayer(LayerController layerController) {
-        super(false);
+    public NinePatchTileLayer(LayerController layerController, CairoImage image) {
+        super(false, image);
 
         mLayerController = layerController;
 
         mSideTexCoordBuffer = createBuffer(SIDE_TEX_COORDS);
         mTopTexCoordBuffer = createBuffer(TOP_TEX_COORDS);
 
         recreateVertexBuffers();
     }
--- a/mobile/android/base/gfx/PlaceholderLayerClient.java
+++ b/mobile/android/base/gfx/PlaceholderLayerClient.java
@@ -105,19 +105,19 @@ public class PlaceholderLayerClient exte
             mBuffer = ByteBuffer.allocateDirect(mWidth * mHeight * bpp);
 
             bitmap.copyPixelsToBuffer(mBuffer.asIntBuffer());
             return null;
         }
         
         @Override
         protected void onPostExecute(Void unused) {
-            SingleTileLayer tileLayer = new SingleTileLayer();
+            BufferedCairoImage image = new BufferedCairoImage(mBuffer, mWidth, mHeight, mFormat);
+            SingleTileLayer tileLayer = new SingleTileLayer(image);
             getLayerController().setRoot(tileLayer);
-            tileLayer.paintImage(new BufferedCairoImage(mBuffer, mWidth, mHeight, mFormat));
         }
     }
 
     @Override
     public void geometryChanged() { /* no-op */ }
     @Override
     public IntSize getPageSize() { return mPageSize; }
     @Override
--- a/mobile/android/base/gfx/SingleTileLayer.java
+++ b/mobile/android/base/gfx/SingleTileLayer.java
@@ -63,20 +63,20 @@ public class SingleTileLayer extends Til
 
     private static final float[] TEX_COORDS = {
         0.0f, 0.0f,
         1.0f, 0.0f,
         0.0f, 1.0f,
         1.0f, 1.0f
     };
 
-    public SingleTileLayer() { this(false); }
+    public SingleTileLayer(CairoImage image) { this(false, image); }
 
-    public SingleTileLayer(boolean repeat) {
-        super(repeat);
+    public SingleTileLayer(boolean repeat, CairoImage image) {
+        super(repeat, image);
 
         mVertexBuffer = createBuffer(VERTICES);
         mTexCoordBuffer = createBuffer(TEX_COORDS);
     }
 
     @Override
     protected void onTileDraw(GL10 gl) {
         IntSize size = getSize();
--- a/mobile/android/base/gfx/TextLayer.java
+++ b/mobile/android/base/gfx/TextLayer.java
@@ -49,51 +49,56 @@ import android.graphics.Typeface;
 import android.util.Log;
 import java.nio.ByteBuffer;
 import java.nio.IntBuffer;
 
 /**
  * Draws text on a layer. This is used for the frame rate meter.
  */
 public class TextLayer extends SingleTileLayer {
-    private ByteBuffer mBuffer;
-    private BufferedCairoImage mImage;
-    private IntSize mSize;
-    private String mText;
+    private final ByteBuffer mBuffer;
+    private final IntSize mSize;
 
-    public TextLayer(IntSize size) {
-        super(false);
+    /*
+     * This awkward pattern is necessary due to Java's restrictions on when one can call superclass
+     * constructors.
+     */
+    private TextLayer(ByteBuffer buffer, BufferedCairoImage image, IntSize size, String text) {
+        super(false, image);
+        mBuffer = buffer;
+        mSize = size;
+        renderText(text);
+    }
 
-        mBuffer = ByteBuffer.allocateDirect(size.width * size.height * 4);
-        mSize = size;
-        mImage = new BufferedCairoImage(mBuffer, size.width, size.height,
-                                        CairoImage.FORMAT_ARGB32);
-        mText = "";
+    public static TextLayer create(IntSize size, String text) {
+        ByteBuffer buffer = ByteBuffer.allocateDirect(size.width * size.height * 4);
+        BufferedCairoImage image = new BufferedCairoImage(buffer, size.width, size.height,
+                                                          CairoImage.FORMAT_ARGB32);
+        return new TextLayer(buffer, image, size, text);
     }
 
     public void setText(String text) {
-        mText = text;
-        renderText();
-        paintImage(mImage);
+        renderText(text);
+        invalidate();
     }
 
-    private void renderText() {
+    private void renderText(String text) {
         Bitmap bitmap = Bitmap.createBitmap(mSize.width, mSize.height, Bitmap.Config.ARGB_8888);
         Canvas canvas = new Canvas(bitmap);
 
         Paint textPaint = new Paint();
         textPaint.setAntiAlias(true);
         textPaint.setColor(Color.WHITE);
         textPaint.setFakeBoldText(true);
         textPaint.setTextSize(18.0f);
         textPaint.setTypeface(Typeface.DEFAULT_BOLD);
-        float width = textPaint.measureText(mText) + 18.0f;
+        float width = textPaint.measureText(text) + 18.0f;
 
         Paint backgroundPaint = new Paint();
         backgroundPaint.setColor(Color.argb(127, 0, 0, 0));
         canvas.drawRect(0.0f, 0.0f, width, 18.0f + 6.0f, backgroundPaint);
 
-        canvas.drawText(mText, 6.0f, 18.0f, textPaint);
+        canvas.drawText(text, 6.0f, 18.0f, textPaint);
 
         bitmap.copyPixelsToBuffer(mBuffer.asIntBuffer());
     }
 }
 
--- a/mobile/android/base/gfx/TileLayer.java
+++ b/mobile/android/base/gfx/TileLayer.java
@@ -32,49 +32,53 @@
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
 package org.mozilla.gecko.gfx;
 
-import org.mozilla.gecko.gfx.CairoImage;
-import org.mozilla.gecko.gfx.IntSize;
-import org.mozilla.gecko.gfx.Layer;
-import org.mozilla.gecko.gfx.TextureReaper;
 import android.graphics.Rect;
 import android.util.Log;
 import javax.microedition.khronos.opengles.GL10;
 import java.nio.Buffer;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
 import java.nio.FloatBuffer;
+import java.util.ArrayList;
 
 /**
  * Base class for tile layers, which encapsulate the logic needed to draw textured tiles in OpenGL
  * ES.
  */
 public abstract class TileLayer extends Layer {
-    private CairoImage mImage;
-    private boolean mRepeat;
-    private IntSize mSize;
+    private static final String LOGTAG = "GeckoTileLayer";
+
+    private final ArrayList<Rect> mDirtyRects;
+    private final CairoImage mImage;
+    private final boolean mRepeat;
+    private final IntSize mSize;
     private int[] mTextureIDs;
 
-    /* The rect that needs to be uploaded to the texture.
-     * This field should not be exposed to other classes, since it is
-     * mutated by calls to union(), and this may lead to odd behavior
-     * if other classes assume it is immutable.
-     */
-    private Rect mTextureUploadRect;
+    public TileLayer(boolean repeat, CairoImage image) {
+        mRepeat = repeat;
+        mImage = image;
+        mSize = new IntSize(image.getWidth(), image.getHeight());
+        mDirtyRects = new ArrayList<Rect>();
 
-    public TileLayer(boolean repeat) {
-        super();
-        mRepeat = repeat;
-        mTextureUploadRect = null;
+        /*
+         * Assert that the image has a power-of-two size. OpenGL ES < 2.0 doesn't support NPOT
+         * textures and OpenGL ES doesn't seem to let us efficiently slice up a NPOT bitmap.
+         */
+        int width = mImage.getWidth(), height = mImage.getHeight();
+        if ((width & (width - 1)) != 0 || (height & (height - 1)) != 0) {
+            throw new RuntimeException("TileLayer: NPOT images are unsupported (dimensions are " +
+                                       width + "x" + height + ")");
+        }
     }
 
     public IntSize getSize() { return mSize; }
 
     protected boolean repeats() { return mRepeat; }
     protected int getTextureID() { return mTextureIDs[0]; }
 
     @Override
@@ -89,94 +93,104 @@ public abstract class TileLayer extends 
      * Invariant: The current matrix mode must be GL_MODELVIEW both before and after this call.
      */
     protected abstract void onTileDraw(GL10 gl);
 
     @Override
     protected void onDraw(GL10 gl) {
         if (mImage == null)
             return;
-        if (mTextureUploadRect != null)
-            uploadTexture(gl);
 
         gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
         gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
         gl.glPushMatrix();
 
         onTileDraw(gl);
 
         gl.glPopMatrix();
         gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
     }
 
-    public void paintSubimage(CairoImage image, Rect rect) {
-        mImage = image;
-        mTextureUploadRect = rect;
+    /**
+     * Invalidates the given rect so that it will be uploaded again. Only valid inside a
+     * transaction.
+     */
+    public void invalidate(Rect rect) {
+        if (!inTransaction())
+            throw new RuntimeException("invalidate() is only valid inside a transaction");
+        mDirtyRects.add(rect);
+    }
 
-        /*
-         * Assert that the image has a power-of-two size. OpenGL ES < 2.0 doesn't support NPOT
-         * textures and OpenGL ES doesn't seem to let us efficiently slice up a NPOT bitmap.
-         */
-        int width = mImage.getWidth(), height = mImage.getHeight();
-        assert (width & (width - 1)) == 0;
-        assert (height & (height - 1)) == 0;
+    public void invalidate() {
+        invalidate(new Rect(0, 0, mSize.width, mSize.height));
+    }
+
+    @Override
+    protected void performUpdates(GL10 gl) {
+        super.performUpdates(gl);
+
+        if (mTextureIDs == null) {
+            uploadFullTexture(gl);
+        } else {
+            for (Rect dirtyRect : mDirtyRects)
+                uploadDirtyRect(gl, dirtyRect);
+        }
+
+        mDirtyRects.clear();
     }
 
-    public void paintImage(CairoImage image) {
-        paintSubimage(image, new Rect(0, 0, image.getWidth(), image.getHeight()));
+    private void uploadFullTexture(GL10 gl) {
+        mTextureIDs = new int[1];
+        gl.glGenTextures(mTextureIDs.length, mTextureIDs, 0);
+
+        int cairoFormat = mImage.getFormat();
+        CairoGLInfo glInfo = new CairoGLInfo(cairoFormat);
+
+        bindAndSetGLParameters(gl);
+
+        gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, glInfo.internalFormat, mSize.width, mSize.height,
+                        0, glInfo.format, glInfo.type, mImage.getBuffer());
     }
 
-    private void uploadTexture(GL10 gl) {
-        boolean newTexture = mTextureIDs == null;
-        if (newTexture) {
-            mTextureIDs = new int[1];
-            gl.glGenTextures(mTextureIDs.length, mTextureIDs, 0);
+    private void uploadDirtyRect(GL10 gl, Rect dirtyRect) {
+        if (mTextureIDs == null)
+            throw new RuntimeException("uploadDirtyRect() called with null texture ID!");
+
+        int width = mSize.width;
+        int cairoFormat = mImage.getFormat();
+        CairoGLInfo glInfo = new CairoGLInfo(cairoFormat);
+
+        bindAndSetGLParameters(gl);
+
+        /*
+         * Upload the changed rect. We have to widen to the full width of the texture
+         * because we can't count on the device having support for GL_EXT_unpack_subimage,
+         * and going line-by-line is too slow.
+         */
+        Buffer viewBuffer = mImage.getBuffer().slice();
+        int bpp = CairoUtils.bitsPerPixelForCairoFormat(cairoFormat) / 8;
+        int position = dirtyRect.top * width * bpp;
+        if (position > viewBuffer.limit()) {
+            Log.e(LOGTAG, "### Position outside tile! " + dirtyRect.top);
+            return;
         }
 
-        int width = mImage.getWidth(), height = mImage.getHeight();
-        mSize = new IntSize(width, height);
+        viewBuffer.position(position);
+        gl.glTexSubImage2D(gl.GL_TEXTURE_2D, 0, 0, dirtyRect.top, width, dirtyRect.height(),
+                           glInfo.format, glInfo.type, viewBuffer);
+    }
 
-        int cairoFormat = mImage.getFormat();
-        int internalFormat = CairoUtils.cairoFormatToGLInternalFormat(cairoFormat);
-        int format = CairoUtils.cairoFormatToGLFormat(cairoFormat);
-        int type = CairoUtils.cairoFormatToGLType(cairoFormat);
-
+    private void bindAndSetGLParameters(GL10 gl) {
         gl.glBindTexture(GL10.GL_TEXTURE_2D, mTextureIDs[0]);
         gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST);
         gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
 
         int repeatMode = mRepeat ? GL10.GL_REPEAT : GL10.GL_CLAMP_TO_EDGE;
         gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, repeatMode);
         gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, repeatMode);
-
-        ByteBuffer buffer = mImage.lockBuffer();
-        try {
-            if (newTexture) {
-                /* The texture is new; we have to upload the whole image. */
-                gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, internalFormat, mSize.width, mSize.height, 0,
-                                format, type, buffer);
-            } else {
-                /*
-                 * The texture is already existing, so upload only the changed rect. We have to
-                 * widen to the full width of the texture because we can't count on the device
-                 * having support for GL_EXT_unpack_subimage, and going line-by-line is too slow.
-                 */
-                Buffer viewBuffer = buffer.slice();
-                int bpp = CairoUtils.bitsPerPixelForCairoFormat(cairoFormat) / 8;
-                viewBuffer.position(mTextureUploadRect.top * width * bpp);
-
-                gl.glTexSubImage2D(gl.GL_TEXTURE_2D,
-                                   0, 0, mTextureUploadRect.top, width, mTextureUploadRect.height(),
-                                   format, type, viewBuffer);
-            }
-        } finally {
-            mImage.unlockBuffer();
-        }
-
-        mTextureUploadRect = null;
     }
 
     protected static FloatBuffer createBuffer(float[] values) {
         ByteBuffer byteBuffer = ByteBuffer.allocateDirect(values.length * 4);
         byteBuffer.order(ByteOrder.nativeOrder());
 
         FloatBuffer floatBuffer = byteBuffer.asFloatBuffer();
         floatBuffer.put(values);
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -89,19 +89,22 @@ const kElementsReceivingInput = {
     textarea: true,
     video: true
 };
 
 function dump(a) {
   Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService).logStringMessage(a);
 }
 
+function getBridge() {
+  return Cc["@mozilla.org/android/bridge;1"].getService(Ci.nsIAndroidBridge);
+}
+
 function sendMessageToJava(aMessage) {
-  let bridge = Cc["@mozilla.org/android/bridge;1"].getService(Ci.nsIAndroidBridge);
-  return bridge.handleGeckoMessage(JSON.stringify(aMessage));
+  return getBridge().handleGeckoMessage(JSON.stringify(aMessage));
 }
 
 #ifdef MOZ_CRASHREPORTER
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 XPCOMUtils.defineLazyServiceGetter(this, "CrashReporter",
   "@mozilla.org/xre/app-info;1", "nsICrashReporter");
 #endif
 
@@ -142,32 +145,35 @@ var BrowserApp = {
     window.QueryInterface(Ci.nsIDOMChromeWindow).browserDOMWindow = new nsBrowserAccess();
     dump("zerdatime " + Date.now() + " - browser chrome startup finished.");
 
     this.deck = document.getElementById("browsers");
     this.vertScroller = document.getElementById("vertical-scroller");
     this.horizScroller = document.getElementById("horizontal-scroller");
     BrowserEventHandler.init();
 
+    getBridge().setDrawMetadataProvider(this.getDrawMetadata.bind(this));
+
     Services.obs.addObserver(this, "Tab:Add", false);
     Services.obs.addObserver(this, "Tab:Load", false);
     Services.obs.addObserver(this, "Tab:Select", false);
     Services.obs.addObserver(this, "Tab:Close", false);
     Services.obs.addObserver(this, "Session:Back", false);
     Services.obs.addObserver(this, "Session:Forward", false);
     Services.obs.addObserver(this, "Session:Reload", false);
     Services.obs.addObserver(this, "Session:Stop", false);
     Services.obs.addObserver(this, "SaveAs:PDF", false);
     Services.obs.addObserver(this, "Browser:Quit", false);
     Services.obs.addObserver(this, "Preferences:Get", false);
     Services.obs.addObserver(this, "Preferences:Set", false);
     Services.obs.addObserver(this, "ScrollTo:FocusedInput", false);
     Services.obs.addObserver(this, "Sanitize:ClearAll", false);
     Services.obs.addObserver(this, "PanZoom:PanZoom", false);
     Services.obs.addObserver(this, "FullScreen:Exit", false);
+    Services.obs.addObserver(this, "Viewport:Change", false);
 
     Services.obs.addObserver(XPInstallObserver, "addon-install-blocked", false);
     Services.obs.addObserver(XPInstallObserver, "addon-install-started", false);
 
     function showFullScreenWarning() {
       NativeWindow.toast.show(Strings.browser.GetStringFromName("alertFullScreenToast"), "short");
     }
 
@@ -531,26 +537,16 @@ var BrowserApp = {
     let doc = aBrowser.contentDocument;
     if (!doc)
       return;
     let focused = doc.activeElement;
     if ((focused instanceof HTMLInputElement && focused.mozIsTextField(false)) || (focused instanceof HTMLTextAreaElement))
       focused.scrollIntoView(false);
   },
 
-  panZoom: function(aData) {
-    let data = JSON.parse(aData);
-    let browser = this.selectedBrowser;
-    browser.contentWindow.scrollTo(data.x, data.y);
-
-    /* TODO (bug 695449): Scale. */
-
-    sendMessageToJava({ gecko: { type: "PanZoom:Ack", rect: data } });
-  },
-
   updateScrollbarsFor: function(aElement) {
     // only draw the scrollbars if we're scrolling the root content element
     let doc = this.selectedBrowser.contentDocument;
     if (aElement != doc.documentElement && aElement != doc.body)
       return;
 
     // draw the vertical scrollbar as needed
     let scrollMax = aElement.scrollHeight;
@@ -581,16 +577,28 @@ var BrowserApp = {
     }
   },
 
   hideScrollbars: function() {
     this.vertScroller.setAttribute("panning", "");
     this.horizScroller.setAttribute("panning", "");
   },
 
+  getDrawMetadata: function getDrawMetadata() {
+    return JSON.stringify(this.selectedTab.viewportMetrics);
+  },
+
+  setViewport: function setViewport(aNewViewport) {
+    this.selectedTab.viewportMetrics = aNewViewport;
+
+    /* TODO: Translate at edges. */
+    /* TODO: Zoom. */
+    this.selectedBrowser.contentWindow.scrollTo(aNewViewport.x, aNewViewport.y);
+  },
+
   observe: function(aSubject, aTopic, aData) {
     let browser = this.selectedBrowser;
     if (!browser)
       return;
 
     if (aTopic == "Session:Back") {
       browser.goBack();
     } else if (aTopic == "Session:Forward") {
@@ -617,20 +625,20 @@ var BrowserApp = {
     } else if (aTopic == "Preferences:Get") {
       this.getPreferences(aData);
     } else if (aTopic == "Preferences:Set") {
       this.setPreferences(aData);
     } else if (aTopic == "ScrollTo:FocusedInput") {
       this.scrollToFocusedInput(browser);
     } else if (aTopic == "Sanitize:ClearAll") {
       Sanitizer.sanitize();
-    } else if (aTopic == "PanZoom:PanZoom") {
-      this.panZoom(aData);
     } else if (aTopic == "FullScreen:Exit") {
       browser.contentDocument.mozCancelFullScreen();
+    } else if (aTopic == "Viewport:Change") {
+      this.setViewport(JSON.parse(aData));
     }
   }
 }
 
 var NativeWindow = {
   init: function() {
     Services.obs.addObserver(this, "Menu:Clicked", false);
     Services.obs.addObserver(this, "Doorhanger:Reply", false);
@@ -970,16 +978,17 @@ nsBrowserAccess.prototype = {
 
 
 let gTabIDFactory = 0;
 
 function Tab(aURL, aParams) {
   this.browser = null;
   this.id = 0;
   this.create(aURL, aParams);
+  this.viewportMetrics = { x: 0, y: 0 };
 }
 
 Tab.prototype = {
   create: function(aURL, aParams) {
     if (this.browser)
       return;
 
     this.browser = document.createElement("browser");